Merge branch 'stable-3.4' into stable-3.5

Left replication plugin version as it was on stable-3.5, since
I1e25124e8025a4fc72891e6897bedc6a9e5ae2cc is still in review.

* stable-3.4:
  Fix bazel build on Mac M1 (aarch64)
  Update git submodules
  Use original servlet-api 3.1.0 artefact instead of tomcat's copy
  Cache change /meta ref SHA1 for each REST API request
  Validation on Invalid Filter Expression in User-Set Notifications
  Update git submodules
  dev-release: Correct link to public-keys.md
  Doc: Remove reference to "extra" keyserver keyserver.ubuntu.com
  Update existing change on cherry-pick with CommitApi
  Bazel: Bump rules_nodejs version to 5.1.0
  Bump rules_nodejs to version 3.0.0

Release-Notes: skip
Change-Id: Ic5a121dfbb26e0092a8bdc97c3d2ccc20c858cc1
diff --git a/.bazelrc b/.bazelrc
index 4117994..b4eafb1 100644
--- a/.bazelrc
+++ b/.bazelrc
@@ -2,7 +2,7 @@
 build --repository_cache=~/.gerritcodereview/bazel-cache/repository
 build --action_env=PATH
 build --disk_cache=~/.gerritcodereview/bazel-cache/cas
-build --java_toolchain=//tools:error_prone_warnings_toolchain
+build --java_toolchain=//tools:error_prone_warnings_toolchain_java11
 
 # Enable strict_action_env flag to. For more information on this feature see
 # https://groups.google.com/forum/#!topic/bazel-discuss/_VmRfMyyHBk.
@@ -15,6 +15,6 @@
 
 test --build_tests_only
 test --test_output=errors
-test --java_toolchain=//tools:error_prone_warnings_toolchain
+test --java_toolchain=//tools:error_prone_warnings_toolchain_java11
 
 import %workspace%/tools/remote-bazelrc
diff --git a/.gitignore b/.gitignore
index 00a6217..95f94ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,7 +32,13 @@
 /node_modules/
 /package-lock.json
 /plugins/*
+!/plugins/.eslintignore
+!/plugins/.eslintrc.js
+!/plugins/.prettierrc.js
 !/plugins/package.json
+!/plugins/rollup.config.js
+!/plugins/tsconfig.json
+!/plugins/tsconfig-plugins-base.json
 !/plugins/yarn.lock
 !/plugins/BUILD
 !/plugins/codemirror-editor
diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs
index 8efbd11..09ce63b 100644
--- a/.settings/org.eclipse.jdt.core.prefs
+++ b/.settings/org.eclipse.jdt.core.prefs
@@ -10,9 +10,9 @@
 org.eclipse.jdt.core.compiler.annotation.nullanalysis=disabled
 org.eclipse.jdt.core.compiler.codegen.inlineJsrBytecode=enabled
 org.eclipse.jdt.core.compiler.codegen.methodParameters=do not generate
-org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8
+org.eclipse.jdt.core.compiler.codegen.targetPlatform=11
 org.eclipse.jdt.core.compiler.codegen.unusedLocal=preserve
-org.eclipse.jdt.core.compiler.compliance=1.8
+org.eclipse.jdt.core.compiler.compliance=11
 org.eclipse.jdt.core.compiler.debug.lineNumber=generate
 org.eclipse.jdt.core.compiler.debug.localVariable=generate
 org.eclipse.jdt.core.compiler.debug.sourceFile=generate
@@ -29,6 +29,7 @@
 org.eclipse.jdt.core.compiler.problem.deprecationWhenOverridingDeprecatedMethod=disabled
 org.eclipse.jdt.core.compiler.problem.discouragedReference=warning
 org.eclipse.jdt.core.compiler.problem.emptyStatement=warning
+org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
 org.eclipse.jdt.core.compiler.problem.enumIdentifier=error
 org.eclipse.jdt.core.compiler.problem.explicitlyClosedAutoCloseable=warning
 org.eclipse.jdt.core.compiler.problem.fallthroughCase=warning
@@ -87,6 +88,7 @@
 org.eclipse.jdt.core.compiler.problem.redundantSuperinterface=ignore
 org.eclipse.jdt.core.compiler.problem.reportMethodCanBePotentiallyStatic=ignore
 org.eclipse.jdt.core.compiler.problem.reportMethodCanBeStatic=ignore
+org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=warning
 org.eclipse.jdt.core.compiler.problem.specialParameterHidingField=disabled
 org.eclipse.jdt.core.compiler.problem.staticAccessReceiver=warning
 org.eclipse.jdt.core.compiler.problem.suppressOptionalErrors=disabled
@@ -126,4 +128,5 @@
 org.eclipse.jdt.core.compiler.problem.unusedWarningToken=warning
 org.eclipse.jdt.core.compiler.problem.varargsArgumentNeedCast=warning
 org.eclipse.jdt.core.compiler.processAnnotations=enabled
-org.eclipse.jdt.core.compiler.source=1.8
+org.eclipse.jdt.core.compiler.release=enabled
+org.eclipse.jdt.core.compiler.source=11
diff --git a/Documentation/BUILD b/Documentation/BUILD
index 11d3efa..af355ca 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -53,6 +53,14 @@
 )
 
 license_map(
+    name = "backend_licenses",
+    opts = ["--asciidoctor"],
+    targets = [
+        "//java/com/google/gerrit/pgm",
+    ],
+)
+
+license_map(
     name = "js_licenses",
     json_maps = [
         "//polygerrit-ui/app/node_modules_licenses:polygerrit-licenses.json",
@@ -66,6 +74,8 @@
     name = "check_licenses",
     srcs = ["check_licenses_test.sh"],
     data = [
+        "backend_licenses.gen.txt",
+        "backend_licenses.txt",
         "js_licenses.gen.txt",
         "js_licenses.txt",
         "licenses.gen.txt",
diff --git a/Documentation/access-control.txt b/Documentation/access-control.txt
index 2a019ca..3da69df 100644
--- a/Documentation/access-control.txt
+++ b/Documentation/access-control.txt
@@ -477,6 +477,11 @@
 `+refs/heads/sandbox/${username}/*+`. If you do, it's also recommended
 you grant the users the push force permission to be able to clean up
 stale branches.
+If link:config-gerrit.html#auth.userNameCaseInsensitive[auth.userNameCaseInsensitive]
+is enabled, the `${username}` is still case sensitive and will use
+the capitalization used during account creation. This is done, since
+git branches are case sensitive, so that sandbox branches containing
+`${username}` are still reachable by the users.
 
 [[category_delete]]
 === Delete Reference
diff --git a/Documentation/backend_licenses.txt b/Documentation/backend_licenses.txt
new file mode 100755
index 0000000..586406f
--- /dev/null
+++ b/Documentation/backend_licenses.txt
@@ -0,0 +1,3157 @@
+= Gerrit Code Review - Licenses
+
+// DO NOT EDIT - GENERATED AUTOMATICALLY.
+
+Gerrit open source software is licensed under the <<Apache2_0,Apache
+License 2.0>>.  Executable distributions also include other software
+components that are provided under additional licenses.
+
+[[cryptography]]
+== Cryptography Notice
+
+This distribution includes cryptographic software.  The country
+in which you currently reside may have restrictions on the import,
+possession, use, and/or re-export to another country, of encryption
+software.  BEFORE using any encryption software, please check
+your country's laws, regulations and policies concerning the
+import, possession, or use, and re-export of encryption software,
+to see if this is permitted.  See the
+link:http://www.wassenaar.org/[Wassenaar Arrangement]
+for more information.
+
+The U.S. Government Department of Commerce, Bureau of Industry
+and Security (BIS), has classified this software as Export
+Commodity Control Number (ECCN) 5D002.C.1, which includes
+information security software using or performing cryptographic
+functions with asymmetric algorithms.  The form and manner of
+this distribution makes it eligible for export under the License
+Exception ENC Technology Software Unrestricted (TSU) exception
+(see the BIS Export Administration Regulations, Section 740.13)
+for both object code and source code.
+
+Gerrit includes an SSH daemon (Apache SSHD), to support authenticated
+uploads of changes directly from `git push` command line clients.
+
+Gerrit includes an SSH client (JSch), to support authenticated
+replication of changes to remote systems, such as for automatic
+updates of mirror servers, or realtime backups.
+
+== Licenses
+
+
+[[Apache2_0]]
+Apache2.0
+
+* 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
+* dropwizard:dropwizard-core
+* errorprone:annotations
+* flogger:api
+* guice:guice
+* guice:guice-assistedinject
+* guice:guice-library
+* guice:guice-servlet
+* guice:javax_inject
+* httpcomponents:httpclient
+* httpcomponents:httpcore
+* jetty:http
+* jetty:io
+* jetty:jmx
+* jetty:security
+* jetty:server
+* jetty:servlet
+* jetty:util
+* jetty:util-ajax
+* log:log4j
+* lucene:lucene-analyzers-common
+* lucene:lucene-core-and-backward-codecs-merged
+* lucene:lucene-misc
+* lucene:lucene-queryparser
+* mime4j:core
+* mime4j:dom
+* mina:core
+* mina:sshd
+* mina:sshd-sftp
+* openid:consumer
+* openid:nekohtml
+* openid:xerces
+* blame-cache
+* caffeine
+* caffeine-guava
+* gson
+* guava
+* guava-failureaccess
+* guava-retrying
+* html-types
+* j2objc
+* javaewah
+* jsr305
+* mime-util
+* servlet-api
+* servlet-api-without-neverlink
+* soy
+
+[[Apache2_0_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.
+
+----
+
+
+[[CC0-1_0]]
+CC0-1.0
+
+* mina:eddsa
+
+[[CC0-1_0_license]]
+----
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+    HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+  i. the right to reproduce, adapt, distribute, perform, display,
+     communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+     likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+     subject to the limitations in paragraph 4(a), below;
+  v. rights protecting the extraction, dissemination, use and reuse of data
+     in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+     European Parliament and of the Council of 11 March 1996 on the legal
+     protection of databases, and under any national implementation
+     thereof, including any amended or successor version of such
+     directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+     world based on applicable law or treaty, and any national
+     implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+    surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+    warranties of any kind concerning the Work, express, implied,
+    statutory or otherwise, including without limitation warranties of
+    title, merchantability, fitness for a particular purpose, non
+    infringement, or the absence of latent or other defects, accuracy, or
+    the present or absence of errors, whether or not discoverable, all to
+    the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+    that may apply to the Work or any use thereof, including without
+    limitation any person's Copyright and Related Rights in the Work.
+    Further, Affirmer disclaims responsibility for obtaining any necessary
+    consents, permissions or other rights required for any use of the
+    Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+    party to this document and has no duty or obligation with respect to
+    this CC0 or use of the Work.
+
+For more information, please see https://creativecommons.org/publicdomain/zero/1.0/
+
+----
+
+
+[[MPL1_1]]
+MPL1.1
+
+* juniversalchardet
+
+[[MPL1_1_license]]
+----
+                          MOZILLA PUBLIC LICENSE
+                                Version 1.1
+
+                              ---------------
+
+1. Definitions.
+
+     1.0.1. "Commercial Use" means distribution or otherwise making the
+     Covered Code available to a third party.
+
+     1.1. "Contributor" means each entity that creates or contributes to
+     the creation of Modifications.
+
+     1.2. "Contributor Version" means the combination of the Original
+     Code, prior Modifications used by a Contributor, and the Modifications
+     made by that particular Contributor.
+
+     1.3. "Covered Code" means the Original Code or Modifications or the
+     combination of the Original Code and Modifications, in each case
+     including portions thereof.
+
+     1.4. "Electronic Distribution Mechanism" means a mechanism generally
+     accepted in the software development community for the electronic
+     transfer of data.
+
+     1.5. "Executable" means Covered Code in any form other than Source
+     Code.
+
+     1.6. "Initial Developer" means the individual or entity identified
+     as the Initial Developer in the Source Code notice required by Exhibit
+     A.
+
+     1.7. "Larger Work" means a work which combines Covered Code or
+     portions thereof with code not governed by the terms of this License.
+
+     1.8. "License" means this document.
+
+     1.8.1. "Licensable" means having the right to grant, to the maximum
+     extent possible, whether at the time of the initial grant or
+     subsequently acquired, any and all of the rights conveyed herein.
+
+     1.9. "Modifications" means any addition to or deletion from the
+     substance or structure of either the Original Code or any previous
+     Modifications. When Covered Code is released as a series of files, a
+     Modification is:
+          A. Any addition to or deletion from the contents of a file
+          containing Original Code or previous Modifications.
+
+          B. Any new file that contains any part of the Original Code or
+          previous Modifications.
+
+     1.10. "Original Code" means Source Code of computer software code
+     which is described in the Source Code notice required by Exhibit A as
+     Original Code, and which, at the time of its release under this
+     License is not already Covered Code governed by this License.
+
+     1.10.1. "Patent Claims" means any patent claim(s), now owned or
+     hereafter acquired, including without limitation,  method, process,
+     and apparatus claims, in any patent Licensable by grantor.
+
+     1.11. "Source Code" means the preferred form of the Covered Code for
+     making modifications to it, including all modules it contains, plus
+     any associated interface definition files, scripts used to control
+     compilation and installation of an Executable, or source code
+     differential comparisons against either the Original Code or another
+     well known, available Covered Code of the Contributor's choice. The
+     Source Code can be in a compressed or archival form, provided the
+     appropriate decompression or de-archiving software is widely available
+     for no charge.
+
+     1.12. "You" (or "Your")  means an individual or a legal entity
+     exercising rights under, and complying with all of the terms of, this
+     License or a future version of this License issued under Section 6.1.
+     For legal entities, "You" includes any entity which controls, is
+     controlled by, or is under common control with You. For purposes of
+     this definition, "control" means (a) the power, direct or indirect,
+     to cause the direction or management of such entity, whether by
+     contract or otherwise, or (b) ownership of more than fifty percent
+     (50%) of the outstanding shares or beneficial ownership of such
+     entity.
+
+2. Source Code License.
+
+     2.1. The Initial Developer Grant.
+     The Initial Developer hereby grants You a world-wide, royalty-free,
+     non-exclusive license, subject to third party intellectual property
+     claims:
+          (a)  under intellectual property rights (other than patent or
+          trademark) Licensable by Initial Developer to use, reproduce,
+          modify, display, perform, sublicense and distribute the Original
+          Code (or portions thereof) with or without Modifications, and/or
+          as part of a Larger Work; and
+
+          (b) under Patents Claims infringed by the making, using or
+          selling of Original Code, to make, have made, use, practice,
+          sell, and offer for sale, and/or otherwise dispose of the
+          Original Code (or portions thereof).
+
+          (c) the licenses granted in this Section 2.1(a) and (b) are
+          effective on the date Initial Developer first distributes
+          Original Code under the terms of this License.
+
+          (d) Notwithstanding Section 2.1(b) above, no patent license is
+          granted: 1) for code that You delete from the Original Code; 2)
+          separate from the Original Code;  or 3) for infringements caused
+          by: i) the modification of the Original Code or ii) the
+          combination of the Original Code with other software or devices.
+
+     2.2. Contributor Grant.
+     Subject to third party intellectual property claims, each Contributor
+     hereby grants You a world-wide, royalty-free, non-exclusive license
+
+          (a)  under intellectual property rights (other than patent or
+          trademark) Licensable by Contributor, to use, reproduce, modify,
+          display, perform, sublicense and distribute the Modifications
+          created by such Contributor (or portions thereof) either on an
+          unmodified basis, with other Modifications, as Covered Code
+          and/or as part of a Larger Work; and
+
+          (b) under Patent Claims infringed by the making, using, or
+          selling of  Modifications made by that Contributor either alone
+          and/or in combination with its Contributor Version (or portions
+          of such combination), to make, use, sell, offer for sale, have
+          made, and/or otherwise dispose of: 1) Modifications made by that
+          Contributor (or portions thereof); and 2) the combination of
+          Modifications made by that Contributor with its Contributor
+          Version (or portions of such combination).
+
+          (c) the licenses granted in Sections 2.2(a) and 2.2(b) are
+          effective on the date Contributor first makes Commercial Use of
+          the Covered Code.
+
+          (d)    Notwithstanding Section 2.2(b) above, no patent license is
+          granted: 1) for any code that Contributor has deleted from the
+          Contributor Version; 2)  separate from the Contributor Version;
+          3)  for infringements caused by: i) third party modifications of
+          Contributor Version or ii)  the combination of Modifications made
+          by that Contributor with other software  (except as part of the
+          Contributor Version) or other devices; or 4) under Patent Claims
+          infringed by Covered Code in the absence of Modifications made by
+          that Contributor.
+
+3. Distribution Obligations.
+
+     3.1. Application of License.
+     The Modifications which You create or to which You contribute are
+     governed by the terms of this License, including without limitation
+     Section 2.2. The Source Code version of Covered Code may be
+     distributed only under the terms of this License or a future version
+     of this License released under Section 6.1, and You must include a
+     copy of this License with every copy of the Source Code You
+     distribute. You may not offer or impose any terms on any Source Code
+     version that alters or restricts the applicable version of this
+     License or the recipients' rights hereunder. However, You may include
+     an additional document offering the additional rights described in
+     Section 3.5.
+
+     3.2. Availability of Source Code.
+     Any Modification which You create or to which You contribute must be
+     made available in Source Code form under the terms of this License
+     either on the same media as an Executable version or via an accepted
+     Electronic Distribution Mechanism to anyone to whom you made an
+     Executable version available; and if made available via Electronic
+     Distribution Mechanism, must remain available for at least twelve (12)
+     months after the date it initially became available, or at least six
+     (6) months after a subsequent version of that particular Modification
+     has been made available to such recipients. You are responsible for
+     ensuring that the Source Code version remains available even if the
+     Electronic Distribution Mechanism is maintained by a third party.
+
+     3.3. Description of Modifications.
+     You must cause all Covered Code to which You contribute to contain a
+     file documenting the changes You made to create that Covered Code and
+     the date of any change. You must include a prominent statement that
+     the Modification is derived, directly or indirectly, from Original
+     Code provided by the Initial Developer and including the name of the
+     Initial Developer in (a) the Source Code, and (b) in any notice in an
+     Executable version or related documentation in which You describe the
+     origin or ownership of the Covered Code.
+
+     3.4. Intellectual Property Matters
+          (a) Third Party Claims.
+          If Contributor has knowledge that a license under a third party's
+          intellectual property rights is required to exercise the rights
+          granted by such Contributor under Sections 2.1 or 2.2,
+          Contributor must include a text file with the Source Code
+          distribution titled "LEGAL" which describes the claim and the
+          party making the claim in sufficient detail that a recipient will
+          know whom to contact. If Contributor obtains such knowledge after
+          the Modification is made available as described in Section 3.2,
+          Contributor shall promptly modify the LEGAL file in all copies
+          Contributor makes available thereafter and shall take other steps
+          (such as notifying appropriate mailing lists or newsgroups)
+          reasonably calculated to inform those who received the Covered
+          Code that new knowledge has been obtained.
+
+          (b) Contributor APIs.
+          If Contributor's Modifications include an application programming
+          interface and Contributor has knowledge of patent licenses which
+          are reasonably necessary to implement that API, Contributor must
+          also include this information in the LEGAL file.
+
+               (c)    Representations.
+          Contributor represents that, except as disclosed pursuant to
+          Section 3.4(a) above, Contributor believes that Contributor's
+          Modifications are Contributor's original creation(s) and/or
+          Contributor has sufficient rights to grant the rights conveyed by
+          this License.
+
+     3.5. Required Notices.
+     You must duplicate the notice in Exhibit A in each file of the Source
+     Code.  If it is not possible to put such notice in a particular Source
+     Code file due to its structure, then You must include such notice in a
+     location (such as a relevant directory) where a user would be likely
+     to look for such a notice.  If You created one or more Modification(s)
+     You may add your name as a Contributor to the notice described in
+     Exhibit A.  You must also duplicate this License in any documentation
+     for the Source Code where You describe recipients' rights or ownership
+     rights relating to Covered Code.  You may choose to offer, and to
+     charge a fee for, warranty, support, indemnity or liability
+     obligations to one or more recipients of Covered Code. However, You
+     may do so only on Your own behalf, and not on behalf of the Initial
+     Developer or any Contributor. You must make it absolutely clear than
+     any such warranty, support, indemnity or liability obligation is
+     offered by You alone, and You hereby agree to indemnify the Initial
+     Developer and every Contributor for any liability incurred by the
+     Initial Developer or such Contributor as a result of warranty,
+     support, indemnity or liability terms You offer.
+
+     3.6. Distribution of Executable Versions.
+     You may distribute Covered Code in Executable form only if the
+     requirements of Section 3.1-3.5 have been met for that Covered Code,
+     and if You include a notice stating that the Source Code version of
+     the Covered Code is available under the terms of this License,
+     including a description of how and where You have fulfilled the
+     obligations of Section 3.2. The notice must be conspicuously included
+     in any notice in an Executable version, related documentation or
+     collateral in which You describe recipients' rights relating to the
+     Covered Code. You may distribute the Executable version of Covered
+     Code or ownership rights under a license of Your choice, which may
+     contain terms different from this License, provided that You are in
+     compliance with the terms of this License and that the license for the
+     Executable version does not attempt to limit or alter the recipient's
+     rights in the Source Code version from the rights set forth in this
+     License. If You distribute the Executable version under a different
+     license You must make it absolutely clear that any terms which differ
+     from this License are offered by You alone, not by the Initial
+     Developer or any Contributor. You hereby agree to indemnify the
+     Initial Developer and every Contributor for any liability incurred by
+     the Initial Developer or such Contributor as a result of any such
+     terms You offer.
+
+     3.7. Larger Works.
+     You may create a Larger Work by combining Covered Code with other code
+     not governed by the terms of this License and distribute the Larger
+     Work as a single product. In such a case, You must make sure the
+     requirements of this License are fulfilled for the Covered Code.
+
+4. Inability to Comply Due to Statute or Regulation.
+
+     If it is impossible for You to comply with any of the terms of this
+     License with respect to some or all of the Covered Code due to
+     statute, judicial order, or regulation then You must: (a) comply with
+     the terms of this License to the maximum extent possible; and (b)
+     describe the limitations and the code they affect. Such description
+     must be included in the LEGAL file described in Section 3.4 and must
+     be included with all distributions of the Source Code. Except to the
+     extent prohibited by statute or regulation, such description must be
+     sufficiently detailed for a recipient of ordinary skill to be able to
+     understand it.
+
+5. Application of this License.
+
+     This License applies to code to which the Initial Developer has
+     attached the notice in Exhibit A and to related Covered Code.
+
+6. Versions of the License.
+
+     6.1. New Versions.
+     Netscape Communications Corporation ("Netscape") may publish revised
+     and/or new versions of the License from time to time. Each version
+     will be given a distinguishing version number.
+
+     6.2. Effect of New Versions.
+     Once Covered Code has been published under a particular version of the
+     License, You may always continue to use it under the terms of that
+     version. You may also choose to use such Covered Code under the terms
+     of any subsequent version of the License published by Netscape. No one
+     other than Netscape has the right to modify the terms applicable to
+     Covered Code created under this License.
+
+     6.3. Derivative Works.
+     If You create or use a modified version of this License (which you may
+     only do in order to apply it to code which is not already Covered Code
+     governed by this License), You must (a) rename Your license so that
+     the phrases "Mozilla", "MOZILLAPL", "MOZPL", "Netscape",
+     "MPL", "NPL" or any confusingly similar phrase do not appear in your
+     license (except to note that your license differs from this License)
+     and (b) otherwise make it clear that Your version of the license
+     contains terms which differ from the Mozilla Public License and
+     Netscape Public License. (Filling in the name of the Initial
+     Developer, Original Code or Contributor in the notice described in
+     Exhibit A shall not of themselves be deemed to be modifications of
+     this License.)
+
+7. DISCLAIMER OF WARRANTY.
+
+     COVERED CODE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
+     WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+     WITHOUT LIMITATION, WARRANTIES THAT THE COVERED CODE IS FREE OF
+     DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
+     THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED CODE
+     IS WITH YOU. SHOULD ANY COVERED CODE PROVE DEFECTIVE IN ANY RESPECT,
+     YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE
+     COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER
+     OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF
+     ANY COVERED CODE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS DISCLAIMER.
+
+8. TERMINATION.
+
+     8.1.  This License and the rights granted hereunder will terminate
+     automatically if You fail to comply with terms herein and fail to cure
+     such breach within 30 days of becoming aware of the breach. All
+     sublicenses to the Covered Code which are properly granted shall
+     survive any termination of this License. Provisions which, by their
+     nature, must remain in effect beyond the termination of this License
+     shall survive.
+
+     8.2.  If You initiate litigation by asserting a patent infringement
+     claim (excluding declatory judgment actions) against Initial Developer
+     or a Contributor (the Initial Developer or Contributor against whom
+     You file such action is referred to as "Participant")  alleging that:
+
+     (a)  such Participant's Contributor Version directly or indirectly
+     infringes any patent, then any and all rights granted by such
+     Participant to You under Sections 2.1 and/or 2.2 of this License
+     shall, upon 60 days notice from Participant terminate prospectively,
+     unless if within 60 days after receipt of notice You either: (i)
+     agree in writing to pay Participant a mutually agreeable reasonable
+     royalty for Your past and future use of Modifications made by such
+     Participant, or (ii) withdraw Your litigation claim with respect to
+     the Contributor Version against such Participant.  If within 60 days
+     of notice, a reasonable royalty and payment arrangement are not
+     mutually agreed upon in writing by the parties or the litigation claim
+     is not withdrawn, the rights granted by Participant to You under
+     Sections 2.1 and/or 2.2 automatically terminate at the expiration of
+     the 60 day notice period specified above.
+
+     (b)  any software, hardware, or device, other than such Participant's
+     Contributor Version, directly or indirectly infringes any patent, then
+     any rights granted to You by such Participant under Sections 2.1(b)
+     and 2.2(b) are revoked effective as of the date You first made, used,
+     sold, distributed, or had made, Modifications made by that
+     Participant.
+
+     8.3.  If You assert a patent infringement claim against Participant
+     alleging that such Participant's Contributor Version directly or
+     indirectly infringes any patent where such claim is resolved (such as
+     by license or settlement) prior to the initiation of patent
+     infringement litigation, then the reasonable value of the licenses
+     granted by such Participant under Sections 2.1 or 2.2 shall be taken
+     into account in determining the amount or value of any payment or
+     license.
+
+     8.4.  In the event of termination under Sections 8.1 or 8.2 above,
+     all end user license agreements (excluding distributors and resellers)
+     which have been validly granted by You or any distributor hereunder
+     prior to termination shall survive termination.
+
+9. LIMITATION OF LIABILITY.
+
+     UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
+     (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE INITIAL
+     DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED CODE,
+     OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY PERSON FOR
+     ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY
+     CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR LOSS OF GOODWILL,
+     WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER
+     COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN
+     INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF
+     LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL INJURY
+     RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT APPLICABLE LAW
+     PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO NOT ALLOW THE
+     EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO
+     THIS EXCLUSION AND LIMITATION MAY NOT APPLY TO YOU.
+
+10. U.S. GOVERNMENT END USERS.
+
+     The Covered Code is a "commercial item," as that term is defined in
+     48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial computer
+     software" and "commercial computer software documentation," as such
+     terms are used in 48 C.F.R. 12.212 (Sept. 1995). Consistent with 48
+     C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4 (June 1995),
+     all U.S. Government End Users acquire Covered Code with only those
+     rights set forth herein.
+
+11. MISCELLANEOUS.
+
+     This License represents the complete agreement concerning subject
+     matter hereof. If any provision of this License is held to be
+     unenforceable, such provision shall be reformed only to the extent
+     necessary to make it enforceable. This License shall be governed by
+     California law provisions (except to the extent applicable law, if
+     any, provides otherwise), excluding its conflict-of-law provisions.
+     With respect to disputes in which at least one party is a citizen of,
+     or an entity chartered or registered to do business in the United
+     States of America, any litigation relating to this License shall be
+     subject to the jurisdiction of the Federal Courts of the Northern
+     District of California, with venue lying in Santa Clara County,
+     California, with the losing party responsible for costs, including
+     without limitation, court costs and reasonable attorneys' fees and
+     expenses. The application of the United Nations Convention on
+     Contracts for the International Sale of Goods is expressly excluded.
+     Any law or regulation which provides that the language of a contract
+     shall be construed against the drafter shall not apply to this
+     License.
+
+12. RESPONSIBILITY FOR CLAIMS.
+
+     As between Initial Developer and the Contributors, each party is
+     responsible for claims and damages arising, directly or indirectly,
+     out of its utilization of rights under this License and You agree to
+     work with Initial Developer and Contributors to distribute such
+     responsibility on an equitable basis. Nothing herein is intended or
+     shall be deemed to constitute any admission of liability.
+
+13. MULTIPLE-LICENSED CODE.
+
+     Initial Developer may designate portions of the Covered Code as
+     "Multiple-Licensed".  "Multiple-Licensed" means that the Initial
+     Developer permits you to utilize portions of the Covered Code under
+     Your choice of the NPL or the alternative licenses, if any, specified
+     by the Initial Developer in the file described in Exhibit A.
+
+EXHIBIT A -Mozilla Public License.
+
+     ``The contents of this file are subject to the Mozilla Public License
+     Version 1.1 (the "License"); you may not use this file except in
+     compliance with the License. You may obtain a copy of the License at
+     http://www.mozilla.org/MPL/
+
+     Software distributed under the License is distributed on an "AS IS"
+     basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
+     License for the specific language governing rights and limitations
+     under the License.
+
+     The Original Code is ______________________________________.
+
+     The Initial Developer of the Original Code is ________________________.
+     Portions created by ______________________ are Copyright (C) ______
+     _______________________. All Rights Reserved.
+
+     Contributor(s): ______________________________________.
+
+     Alternatively, the contents of this file may be used under the terms
+     of the _____ license (the  "[___] License"), in which case the
+     provisions of [______] License are applicable instead of those
+     above.  If you wish to allow use of your version of this file only
+     under the terms of the [____] License and not to allow others to use
+     your version of this file under the MPL, indicate your decision by
+     deleting  the provisions above and replace  them with the notice and
+     other provisions required by the [___] License.  If you do not delete
+     the provisions above, a recipient may use your version of this file
+     under either the MPL or the [___] License."
+
+     [NOTE: The text of this Exhibit A may differ slightly from the text of
+     the notices in the Source Code files of the Original Code. You should
+     use the text of this Exhibit A rather than the text found in the
+     Original Code Source Code for Your Modifications.]
+
+----
+
+
+[[PublicDomain]]
+PublicDomain
+
+* guice:aopalliance
+
+[[PublicDomain_license]]
+----
+This software has been placed in the public domain by its author(s).
+
+----
+
+
+[[antlr]]
+antlr
+
+* antlr:antlr27
+* antlr:java-runtime
+* antlr:stringtemplate
+* antlr:tool
+
+[[antlr_license]]
+----
+Copyright (c) 2003-2008, Terence Parr
+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 author 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.
+
+----
+
+
+[[args4j]]
+args4j
+
+* args4j
+
+[[args4j_license]]
+----
+Copyright (c) 2013 Kohsuke Kawaguchi and other contributors
+
+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.
+
+----
+
+
+[[autolink]]
+autolink
+
+* autolink
+
+[[autolink_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2015 Robin Stocker
+
+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.
+
+----
+
+
+[[automaton]]
+automaton
+
+* automaton
+
+[[automaton_license]]
+----
+Copyright (c) 2001-2011 Anders Moeller
+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 JSR305 expert group 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.
+
+----
+
+
+[[bouncycastle]]
+bouncycastle
+
+* bouncycastle:bcpg-neverlink
+* bouncycastle:bcpkix-neverlink
+* bouncycastle:bcprov-neverlink
+
+[[bouncycastle_license]]
+----
+Copyright (c) 2000 - 2015 The Legion of the Bouncy Castle Inc.
+(http://www.bouncycastle.org)
+
+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.
+
+----
+
+
+[[flexmark]]
+flexmark
+
+* flexmark
+* flexmark-ext-abbreviation
+* flexmark-ext-anchorlink
+* flexmark-ext-autolink
+* flexmark-ext-definition
+* flexmark-ext-emoji
+* flexmark-ext-escaped-character
+* flexmark-ext-footnotes
+* flexmark-ext-gfm-issues
+* flexmark-ext-gfm-strikethrough
+* flexmark-ext-gfm-tables
+* flexmark-ext-gfm-tasklist
+* flexmark-ext-gfm-users
+* flexmark-ext-ins
+* flexmark-ext-jekyll-front-matter
+* flexmark-ext-superscript
+* flexmark-ext-tables
+* flexmark-ext-toc
+* flexmark-ext-typographic
+* flexmark-ext-wikilink
+* flexmark-ext-yaml-front-matter
+* flexmark-formatter
+* flexmark-html-parser
+* flexmark-profile-pegdown
+* flexmark-util
+
+[[flexmark_license]]
+----
+Copyright (c) 2015-2016, Atlassian Pty Ltd
+All rights reserved.
+
+Copyright (c) 2016, Vladimir Schneider,
+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.
+
+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.
+
+----
+
+
+[[h2]]
+h2
+
+* h2
+
+[[h2_license]]
+----
+H2 is dual licensed and available under a modified version of the
+MPL 1.1 (Mozilla Public License) or under the (unmodified) EPL 1.0.
+----
+
+link:http://www.h2database.com/html/license.html[H2 License]
+
+----
+H2 License - Version 1.0
+1. Definitions
+
+1.0.1. "Commercial Use" means distribution or otherwise making the
+       Covered Code available to a third party.
+
+1.1. "Contributor" means each entity that creates or contributes
+     to the creation of Modifications.
+
+1.2. "Contributor Version" means the combination of the Original
+     Code, prior Modifications used by a Contributor, and the
+     Modifications made by that particular Contributor.
+
+1.3. "Covered Code" means the Original Code or Modifications or
+     the combination of the Original Code and Modifications, in each
+     case including portions thereof.
+
+1.4. "Electronic Distribution Mechanism" means a mechanism generally
+     accepted in the software development community for the electronic
+     transfer of data.
+
+1.5. "Executable" means Covered Code in any form other than Source Code.
+
+1.6. "Initial Developer" means the individual or entity identified
+     as the Initial Developer in the Source Code notice required
+     by Exhibit A.
+
+1.7. "Larger Work" means a work which combines Covered Code or
+     portions thereof with code not governed by the terms of this
+     License.
+
+1.8. "License" means this document.
+
+1.8.1. "Licensable" means having the right to grant, to the maximum
+       extent possible, whether at the time of the initial grant
+       or subsequently acquired, any and all of the rights conveyed
+       herein.
+
+1.9. "Modifications" means any addition to or deletion from the
+     substance or structure of either the Original Code or any
+     previous Modifications. When Covered Code is released as a
+     series of files, a Modification is:
+
+1.9.a. Any addition to or deletion from the contents of a file
+       containing Original Code or previous Modifications.
+
+1.9.b. Any new file that contains any part of the Original Code or
+       previous Modifications.
+
+1.10. "Original Code" means Source Code of computer software
+      code which is described in the Source Code notice required
+      by Exhibit A as Original Code, and which, at the time of
+      its release under this License is not already Covered Code
+      governed by this License.
+
+1.10.1. "Patent Claims" means any patent claim(s), now owned or
+        hereafter acquired, including without limitation, method,
+        process, and apparatus claims, in any patent Licensable
+        by grantor.
+
+1.11. "Source Code" means the preferred form of the Covered Code
+      for making modifications to it, including all modules it
+      contains, plus any associated interface definition files,
+      scripts used to control compilation and installation of an
+      Executable, or source code differential comparisons against
+      either the Original Code or another well known, available
+      Covered Code of the Contributor's choice. The Source Code can
+      be in a compressed or archival form, provided the appropriate
+      decompression or de-archiving software is widely available
+      for no charge.
+
+1.12. "You" (or "Your") means an individual or a legal entity
+      exercising rights under, and complying with all of the terms
+      of, this License or a future version of this License issued
+      under Section 6.1. For legal entities, "You" includes any
+      entity which controls, is controlled by, or is under common
+      control with You. For purposes of this definition, "control"
+      means (a) the power, direct or indirect, to cause the direction
+      or management of such entity, whether by contract or otherwise,
+      or (b) ownership of more than fifty percent (50%) of the
+      outstanding shares or beneficial ownership of such entity.
+
+2. Source Code License
+
+2.1. The Initial Developer Grant
+
+The Initial Developer hereby grants You a world-wide, royalty-free,
+non-exclusive license, subject to third party intellectual property
+claims:
+
+2.1.a. under intellectual property rights (other than patent
+       or trademark) Licensable by Initial Developer to use,
+       reproduce, modify, display, perform, sublicense and distribute
+       the Original Code (or portions thereof) with or without
+       Modifications, and/or as part of a Larger Work; and
+
+2.1.b. under Patents Claims infringed by the making, using or selling
+       of Original Code, to make, have made, use, practice, sell,
+       and offer for sale, and/or otherwise dispose of the Original
+       Code (or portions thereof).
+
+2.1.c. the licenses granted in this Section 2.1 (a) and (b) are
+       effective on the date Initial Developer first distributes
+       Original Code under the terms of this License.
+
+2.1.d. Notwithstanding Section 2.1 (b) above, no patent license is
+       granted: 1) for code that You delete from the Original Code;
+       2) separate from the Original Code; or 3) for infringements
+       caused by: i) the modification of the Original Code or ii)
+       the combination of the Original Code with other software
+       or devices.
+
+2.2. Contributor Grant
+
+Subject to third party intellectual property claims, each Contributor
+hereby grants You a world-wide, royalty-free, non-exclusive license
+
+2.2.a. under intellectual property rights (other than patent or
+       trademark) Licensable by Contributor, to use, reproduce,
+       modify, display, perform, sublicense and distribute the
+       Modifications created by such Contributor (or portions
+       thereof) either on an unmodified basis, with other
+       Modifications, as Covered Code and/or as part of a Larger
+       Work; and
+
+2.2.b. under Patent Claims infringed by the making, using, or selling
+       of Modifications made by that Contributor either alone and/or
+       in combination with its Contributor Version (or portions
+       of such combination), to make, use, sell, offer for sale,
+       have made, and/or otherwise dispose of: 1) Modifications
+       made by that Contributor (or portions thereof); and 2) the
+       combination of Modifications made by that Contributor with
+       its Contributor Version (or portions of such combination).
+
+2.2.c. the licenses granted in Sections 2.2 (a) and 2.2 (b) are
+       effective on the date Contributor first makes Commercial
+       Use of the Covered Code.
+
+2.2.c. Notwithstanding Section 2.2 (b) above, no patent license is
+       granted: 1) for any code that Contributor has deleted from
+       the Contributor Version; 2) separate from the Contributor
+       Version; 3) for infringements caused by: i) third party
+       modifications of Contributor Version or ii) the combination
+       of Modifications made by that Contributor with other software
+       (except as part of the Contributor Version) or other devices;
+       or 4) under Patent Claims infringed by Covered Code in the
+       absence of Modifications made by that Contributor.
+
+3. Distribution Obligations
+
+3.1. Application of License
+
+The Modifications which You create or to which You contribute
+are governed by the terms of this License, including without
+limitation Section 2.2. The Source Code version of Covered Code may
+be distributed only under the terms of this License or a future
+version of this License released under Section 6.1, and You must
+include a copy of this License with every copy of the Source Code
+You distribute. You may not offer or impose any terms on any Source
+Code version that alters or restricts the applicable version of
+this License or the recipients' rights hereunder. However, You
+may include an additional document offering the additional rights
+described in Section 3.5.
+
+3.2. Availability of Source Code
+
+Any Modification which You create or to which You contribute must
+be made available in Source Code form under the terms of this
+License either on the same media as an Executable version or via
+an accepted Electronic Distribution Mechanism to anyone to whom
+you made an Executable version available; and if made available
+via Electronic Distribution Mechanism, must remain available for
+at least twelve (12) months after the date it initially became
+available, or at least six (6) months after a subsequent version
+of that particular Modification has been made available to such
+recipients. You are responsible for ensuring that the Source Code
+version remains available even if the Electronic Distribution
+Mechanism is maintained by a third party.
+
+3.3. Description of Modifications
+
+You must cause all Covered Code to which You contribute to contain
+a file documenting the changes You made to create that Covered
+Code and the date of any change. You must include a prominent
+statement that the Modification is derived, directly or indirectly,
+from Original Code provided by the Initial Developer and including
+the name of the Initial Developer in (a) the Source Code, and (b)
+in any notice in an Executable version or related documentation in
+which You describe the origin or ownership of the Covered Code.
+
+3.4. Intellectual Property Matters
+
+3.4.a. Third Party Claims: If Contributor has knowledge that
+       a license under a third party's intellectual property
+       rights is required to exercise the rights granted by such
+       Contributor under Sections 2.1 or 2.2, Contributor must
+       include a text file with the Source Code distribution titled
+       "LEGAL" which describes the claim and the party making the
+       claim in sufficient detail that a recipient will know whom
+       to contact. If Contributor obtains such knowledge after the
+       Modification is made available as described in Section 3.2,
+       Contributor shall promptly modify the LEGAL file in all
+       copies Contributor makes available thereafter and shall take
+       other steps (such as notifying appropriate mailing lists or
+       newsgroups) reasonably calculated to inform those who received
+       the Covered Code that new knowledge has been obtained.
+
+3.4.b. Contributor APIs: If Contributor's Modifications include
+       an application programming interface and Contributor has
+       knowledge of patent licenses which are reasonably necessary
+       to implement that API, Contributor must also include this
+       information in the legal file.
+
+3.4.c. Representations: Contributor represents that, except as
+       disclosed pursuant to Section 3.4 (a) above, Contributor
+       believes that Contributor's Modifications are Contributor's
+       original creation(s) and/or Contributor has sufficient rights
+       to grant the rights conveyed by this License.
+
+3.5. Required Notices
+
+You must duplicate the notice in Exhibit A in each file of
+the Source Code. If it is not possible to put such notice in a
+particular Source Code file due to its structure, then You must
+include such notice in a location (such as a relevant directory)
+where a user would be likely to look for such a notice. If You
+created one or more Modification(s) You may add your name as a
+Contributor to the notice described in Exhibit A. You must also
+duplicate this License in any documentation for the Source Code
+where You describe recipients' rights or ownership rights relating
+to Covered Code. You may choose to offer, and to charge a fee for,
+warranty, support, indemnity or liability obligations to one or
+more recipients of Covered Code. However, You may do so only on
+Your own behalf, and not on behalf of the Initial Developer or
+any Contributor. You must make it absolutely clear than any such
+warranty, support, indemnity or liability obligation is offered by
+You alone, and You hereby agree to indemnify the Initial Developer
+and every Contributor for any liability incurred by the Initial
+Developer or such Contributor as a result of warranty, support,
+indemnity or liability terms You offer.
+
+3.6. Distribution of Executable Versions
+
+You may distribute Covered Code in Executable form only if the
+requirements of Sections 3.1, 3.2, 3.3, 3.4 and 3.5 have been met
+for that Covered Code, and if You include a notice stating that
+the Source Code version of the Covered Code is available under the
+terms of this License, including a description of how and where
+You have fulfilled the obligations of Section 3.2. The notice
+must be conspicuously included in any notice in an Executable
+version, related documentation or collateral in which You describe
+recipients' rights relating to the Covered Code. You may distribute
+the Executable version of Covered Code or ownership rights under
+a license of Your choice, which may contain terms different from
+this License, provided that You are in compliance with the terms
+of this License and that the license for the Executable version
+does not attempt to limit or alter the recipient's rights in the
+Source Code version from the rights set forth in this License. If
+You distribute the Executable version under a different license You
+must make it absolutely clear that any terms which differ from this
+License are offered by You alone, not by the Initial Developer or any
+Contributor. You hereby agree to indemnify the Initial Developer and
+every Contributor for any liability incurred by the Initial Developer
+or such Contributor as a result of any such terms You offer.
+
+3.7. Larger Works
+
+You may create a Larger Work by combining Covered Code with other
+code not governed by the terms of this License and distribute the
+Larger Work as a single product. In such a case, You must make sure
+the requirements of this License are fulfilled for the Covered Code.
+
+4. Inability to Comply Due to Statute or Regulation.
+
+If it is impossible for You to comply with any of the terms of
+this License with respect to some or all of the Covered Code due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description
+must be included in the legal file described in Section 3.4 and
+must be included with all distributions of the Source Code. Except
+to the extent prohibited by statute or regulation, such description
+must be sufficiently detailed for a recipient of ordinary skill to
+be able to understand it.
+
+5. Application of this License.
+
+This License applies to code to which the Initial Developer has
+attached the notice in Exhibit A and to related Covered Code.
+
+6. Versions of the License.
+
+6.1. New Versions
+
+The H2 Group may publish revised and/or new versions of the License
+from time to time. Each version will be given a distinguishing
+version number.
+
+6.2. Effect of New Versions
+
+Once Covered Code has been published under a particular version of
+the License, You may always continue to use it under the terms of
+that version. You may also choose to use such Covered Code under the
+terms of any subsequent version of the License published by the H2
+Group. No one other than the H2 Group has the right to modify the
+terms applicable to Covered Code created under this License.
+
+6.3. Derivative Works
+
+If You create or use a modified version of this License (which you
+may only do in order to apply it to code which is not already Covered
+Code governed by this License), You must (a) rename Your license so
+that the phrases "H2 Group", "H2" or any confusingly similar phrase
+do not appear in your license (except to note that your license
+differs from this License) and (b) otherwise make it clear that
+Your version of the license contains terms which differ from the
+H2 License. (Filling in the name of the Initial Developer, Original
+Code or Contributor in the notice described in Exhibit A shall not
+of themselves be deemed to be modifications of this License.)
+
+7. Disclaimer of Warranty
+
+Covered code is provided under this license on an "as is" basis,
+without warranty of any kind, either expressed or implied,
+including, without limitation, warranties that the covered code
+is free of defects, merchantable, fit for a particular purpose or
+non-infringing. The entire risk as to the quality and performance
+of the covered code is with you. Should any covered code prove
+defective in any respect, you (not the initial developer or any
+other contributor) assume the cost of any necessary servicing,
+repair or correction. This disclaimer of warranty constitutes
+an essential part of this license. No use of any covered code is
+authorized hereunder except under this disclaimer.
+
+8. Termination
+
+8.1. This License and the rights granted hereunder will terminate
+     automatically if You fail to comply with terms herein and
+     fail to cure such breach within 30 days of becoming aware
+     of the breach. All sublicenses to the Covered Code which
+     are properly granted shall survive any termination of this
+     License. Provisions which, by their nature, must remain in
+     effect beyond the termination of this License shall survive.
+
+8.2. If You initiate litigation by asserting a patent infringement
+     claim (excluding declaratory judgment actions) against
+     Initial Developer or a Contributor (the Initial Developer or
+     Contributor against whom You file such action is referred to as
+     "Participant") alleging that:
+
+8.2.a. such Participant's Contributor Version directly or indirectly
+       infringes any patent, then any and all rights granted by
+       such Participant to You under Sections 2.1 and/or 2.2 of this
+       License shall, upon 60 days notice from Participant terminate
+       prospectively, unless if within 60 days after receipt of
+       notice You either: (i) agree in writing to pay Participant
+       a mutually agreeable reasonable royalty for Your past and
+       future use of Modifications made by such Participant, or (ii)
+       withdraw Your litigation claim with respect to the Contributor
+       Version against such Participant. If within 60 days of notice,
+       a reasonable royalty and payment arrangement are not mutually
+       agreed upon in writing by the parties or the litigation claim
+       is not withdrawn, the rights granted by Participant to You
+       under Sections 2.1 and/or 2.2 automatically terminate at
+       the expiration of the 60 day notice period specified above.
+
+8.2.b. any software, hardware, or device, other than such
+       Participant's Contributor Version, directly or indirectly
+       infringes any patent, then any rights granted to You by
+       such Participant under Sections 2.1(b) and 2.2(b) are
+       revoked effective as of the date You first made, used,
+       sold, distributed, or had made, Modifications made by that
+       Participant.
+
+8.3. If You assert a patent infringement claim against Participant
+     alleging that such Participant's Contributor Version directly
+     or indirectly infringes any patent where such claim is resolved
+     (such as by license or settlement) prior to the initiation of
+     patent infringement litigation, then the reasonable value of
+     the licenses granted by such Participant under Sections 2.1
+     or 2.2 shall be taken into account in determining the amount
+     or value of any payment or license.
+
+8.4. In the event of termination under Sections 8.1 or 8.2 above,
+     all end user license agreements (excluding distributors and
+     resellers) which have been validly granted by You or any
+     distributor hereunder prior to termination shall survive
+     termination.
+
+9. Limitation of Liability
+
+Under no circumstances and under no legal theory, whether tort
+(including negligence), contract, or otherwise, shall you, the
+initial developer, any other contributor, or any distributor of
+covered code, or any supplier of any of such parties, be liable to
+any person for any indirect, special, incidental, or consequential
+damages of any character including, without limitation, damages for
+loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses, even if such party
+shall have been informed of the possibility of such damages. This
+limitation of liability shall not apply to liability for death or
+personal injury resulting from such party's negligence to the extent
+applicable law prohibits such limitation. Some jurisdictions do not
+allow the exclusion or limitation of incidental or consequential
+damages, so this exclusion and limitation may not apply to you.
+
+10. United States Government End Users
+
+The Covered Code is a "commercial item", as that term is defined in
+48 C.F.R. 2.101 (October 1995), consisting of "commercial computer
+software" and "commercial computer software documentation", as such
+terms are used in 48 C.F.R. 12.212 (September 1995). Consistent
+with 48 C.F.R. 12.212 and 48 C.F.R. 227.7202-1 through 227.7202-4
+(June 1995), all U.S. Government End Users acquire Covered Code
+with only those rights set forth herein.
+
+11. Miscellaneous
+
+This License represents the complete agreement concerning subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. This License shall be governed
+by California law provisions (except to the extent applicable
+law, if any, provides otherwise), excluding its conflict-of-law
+provisions. With respect to disputes in which at least one party is
+a citizen of, or an entity chartered or registered to do business in
+United States of America, any litigation relating to this License
+shall be subject to the jurisdiction of the Federal Courts of the
+Northern District of California, with venue lying in Santa Clara
+County, California, with the losing party responsible for costs,
+including without limitation, court costs and reasonable attorneys'
+fees and expenses. The application of the United Nations Convention
+on Contracts for the International Sale of Goods is expressly
+excluded. Any law or regulation which provides that the language of
+a contract shall be construed against the drafter shall not apply
+to this License.
+
+12. Responsibility for Claims
+
+As between Initial Developer and the Contributors, each party is
+responsible for claims and damages arising, directly or indirectly,
+out of its utilization of rights under this License and You agree
+to work with Initial Developer and Contributors to distribute such
+responsibility on an equitable basis. Nothing herein is intended
+or shall be deemed to constitute any admission of liability.
+
+13. Multiple-Licensed Code
+
+Initial Developer may designate portions of the Covered Code as
+"Multiple-Licensed". "Multiple-Licensed" means that the Initial
+Developer permits you to utilize portions of the Covered Code under
+Your choice of this or the alternative licenses, if any, specified
+by the Initial Developer in the file described in Exhibit A.
+
+Exhibit A
+
+Multiple-Licensed under the H2 License, Version 1.0,
+and under the Eclipse Public License, Version 1.0
+(http://h2database.com/html/license.html).
+Initial Developer: H2 Group
+----
+
+----
+Eclipse Public License - v 1.0
+
+THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+a) in the case of the initial Contributor, the initial code and
+   documentation distributed under this Agreement, and
+b) in the case of each subsequent Contributor:
+
+i) changes to the Program, and
+
+ii) additions to the Program;
+
+where such changes and/or additions to the Program originate from
+and are distributed by that particular Contributor. A Contribution
+'originates' from a Contributor if it was added to the Program
+by such Contributor itself or anyone acting on such Contributor's
+behalf. Contributions do not include additions to the Program which:
+(i) are separate modules of software distributed in conjunction
+with the Program under their own license agreement, and (ii) are
+not derivative works of the Program.
+
+"Contributor" means any person or entity that distributes the Program.
+
+"Licensed Patents " mean patent claims licensable by a Contributor
+which are necessarily infringed by the use or sale of its
+Contribution alone or when combined with the Program.
+
+"Program" means the Contributions distributed in accordance with
+this Agreement.
+
+"Recipient" means anyone who receives the Program under this
+Agreement, including all Contributors.
+
+2. GRANT OF RIGHTS
+
+a) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free copyright
+   license to reproduce, prepare derivative works of, publicly display,
+   publicly perform, distribute and sublicense the Contribution of such
+   Contributor, if any, and such derivative works, in source code and
+   object code form.
+
+b) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free patent
+   license under Licensed Patents to make, use, sell, offer to sell,
+   import and otherwise transfer the Contribution of such Contributor,
+   if any, in source code and object code form. This patent license
+   shall apply to the combination of the Contribution and the Program
+   if, at the time the Contribution is added by the Contributor, such
+   addition of the Contribution causes such combination to be covered
+   by the Licensed Patents. The patent license shall not apply to any
+   other combinations which include the Contribution. No hardware per
+   se is licensed hereunder.
+
+c) Recipient understands that although each Contributor grants the
+   licenses to its Contributions set forth herein, no assurances are
+   provided by any Contributor that the Program does not infringe
+   the patent or other intellectual property rights of any other
+   entity. Each Contributor disclaims any liability to Recipient
+   for claims brought by any other entity based on infringement
+   of intellectual property rights or otherwise. As a condition to
+   exercising the rights and licenses granted hereunder, each Recipient
+   hereby assumes sole responsibility to secure any other intellectual
+   property rights needed, if any. For example, if a third party patent
+   license is required to allow Recipient to distribute the Program,
+   it is Recipient's responsibility to acquire that license before
+   distributing the Program.
+
+d) Each Contributor represents that to its knowledge it has
+   sufficient copyright rights in its Contribution, if any, to grant
+   the copyright license set forth in this Agreement.
+
+3. REQUIREMENTS
+
+A Contributor may choose to distribute the Program in object code
+  form under its own license agreement, provided that:
+
+a) it complies with the terms and conditions of this Agreement; and
+
+b) its license agreement:
+
+i) effectively disclaims on behalf of all Contributors all warranties
+   and conditions, express and implied, including warranties or
+   conditions of title and non-infringement, and implied warranties or
+   conditions of merchantability and fitness for a particular purpose;
+
+ii) effectively excludes on behalf of all Contributors all liability
+    for damages, including direct, indirect, special, incidental and
+    consequential damages, such as lost profits;
+
+iii) states that any provisions which differ from this Agreement
+     are offered by that Contributor alone and not by any other
+     party; and
+
+iv) states that source code for the Program is available from such
+    Contributor, and informs licensees how to obtain it in a reasonable
+    manner on or through a medium customarily used for software exchange.
+
+When the Program is made available in source code form:
+
+a) it must be made available under this Agreement; and
+
+b) a copy of this Agreement must be included with each copy of the Program.
+
+Contributors may not remove or alter any copyright notices contained
+within the Program.
+
+Each Contributor must identify itself as the originator of its
+Contribution, if any, in a manner that reasonably allows subsequent
+Recipients to identify the originator of the Contribution.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain
+responsibilities with respect to end users, business partners and the
+like. While this license is intended to facilitate the commercial
+use of the Program, the Contributor who includes the Program in a
+commercial product offering should do so in a manner which does not
+create potential liability for other Contributors. Therefore, if a
+Contributor includes the Program in a commercial product offering,
+such Contributor ("Commercial Contributor") hereby agrees to defend
+and indemnify every other Contributor ("Indemnified Contributor")
+against any losses, damages and costs (collectively "Losses") arising
+from claims, lawsuits and other legal actions brought by a third
+party against the Indemnified Contributor to the extent caused by
+the acts or omissions of such Commercial Contributor in connection
+with its distribution of the Program in a commercial product
+offering. The obligations in this section do not apply to any claims
+or Losses relating to any actual or alleged intellectual property
+infringement. In order to qualify, an Indemnified Contributor must:
+a) promptly notify the Commercial Contributor in writing of such
+claim, and b) allow the Commercial Contributor to control, and
+cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a
+commercial product offering, Product X. That Contributor is then a
+Commercial Contributor. If that Commercial Contributor then makes
+performance claims, or offers warranties related to Product X, those
+performance claims and warranties are such Commercial Contributor's
+responsibility alone. Under this section, the Commercial Contributor
+would have to defend claims against the other Contributors related
+to those performance claims and warranties, and if a court requires
+any other Contributor to pay any damages as a result, the Commercial
+Contributor must pay those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS
+PROVIDED 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. Each Recipient is solely
+responsible for determining the appropriateness of using and
+distributing the Program and assumes all risks associated with
+its exercise of rights under this Agreement , including but not
+limited to the risks and costs of program errors, compliance with
+applicable laws, damage to or loss of data, programs or equipment,
+and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT
+NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING WITHOUT LIMITATION LOST PROFITS), 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 OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY
+RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed
+to the minimum extent necessary to make such provision valid and
+enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging
+that the Program itself (excluding combinations of the Program with
+other software or hardware) infringes such Recipient's patent(s),
+then such Recipient's rights granted under Section 2(b) shall
+terminate as of the date such litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if
+it fails to comply with any of the material terms or conditions
+of this Agreement and does not cure such failure in a reasonable
+period of time after becoming aware of such noncompliance. If all
+Recipient's rights under this Agreement terminate, Recipient agrees
+to cease use and distribution of the Program as soon as reasonably
+practicable. However, Recipient's obligations under this Agreement
+and any licenses granted by Recipient relating to the Program shall
+continue and survive.
+
+Everyone is permitted to copy and distribute copies of this
+Agreement, but in order to avoid inconsistency the Agreement is
+copyrighted and may only be modified in the following manner. The
+Agreement Steward reserves the right to publish new versions
+(including revisions) of this Agreement from time to time. No
+one other than the Agreement Steward has the right to modify
+this Agreement. The Eclipse Foundation is the initial Agreement
+Steward. The Eclipse Foundation may assign the responsibility to
+serve as the Agreement Steward to a suitable separate entity. Each
+new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+distributed subject to the version of the Agreement under which it
+was received. In addition, after a new version of the Agreement is
+published, Contributor may elect to distribute the Program (including
+its Contributions) under the new version. Except as expressly stated
+in Sections 2(a) and 2(b) above, Recipient receives no rights or
+licenses to the intellectual property of any Contributor under
+this Agreement, whether expressly, by implication, estoppel or
+otherwise. All rights in the Program not expressly granted under
+this Agreement are reserved.
+
+This Agreement is governed by the laws of the State of New York and
+the intellectual property laws of the United States of America. No
+party to this Agreement will bring a legal action under this
+Agreement more than one year after the cause of action arose. Each
+party waives its rights to a jury trial in any resulting litigation.
+----
+
+----
+Export Control Classification Number (ECCN)
+
+As far as we know, the U.S. Export Control Classification Number
+(ECCN) for this software is 5D002. However, for legal reasons, we
+can make no warranty that this information is correct. For details,
+see also the Apache Software Foundation Export Classifications page.
+
+----
+
+
+[[icu4j]]
+icu4j
+
+* icu4j
+
+[[icu4j_license]]
+----
+COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later)
+
+Copyright © 1991-2016 Unicode, Inc. All rights reserved.
+Distributed under the Terms of Use in http://www.unicode.org/copyright.html
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Unicode data files and any associated documentation
+(the "Data Files") or Unicode software and any associated documentation
+(the "Software") to deal in the Data Files or Software
+without restriction, including without limitation the rights to use,
+copy, modify, merge, publish, distribute, and/or sell copies of
+the Data Files or Software, and to permit persons to whom the Data Files
+or Software are furnished to do so, provided that either
+(a) this copyright and permission notice appear with all copies
+of the Data Files or Software, or
+(b) this copyright and permission notice appear in associated
+Documentation.
+
+THE DATA FILES AND SOFTWARE ARE 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 OF THIRD PARTY RIGHTS.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS
+NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL 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 THE DATA FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale,
+use or other dealings in these Data Files or Software without prior
+written authorization of the copyright holder.
+
+---------------------
+
+Third-Party Software Licenses
+
+This section contains third-party software notices and/or additional
+terms for licensed third-party software components included within ICU
+libraries.
+
+1. ICU License - ICU 1.8.1 to ICU 57.1
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright (c) 1995-2016 International Business Machines Corporation and others
+All rights reserved.
+
+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, and/or sell copies of the Software, and to permit persons
+to whom the Software is furnished to do so, provided that the above
+copyright notice(s) and this permission notice appear in all copies of
+the Software and that both the above copyright notice(s) and this
+permission notice appear in supporting documentation.
+
+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
+OF THIRD PARTY RIGHTS. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+HOLDERS INCLUDED IN THIS NOTICE BE LIABLE FOR ANY CLAIM, OR ANY
+SPECIAL 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.
+
+Except as contained in this notice, the name of a copyright holder
+shall not be used in advertising or otherwise to promote the sale, use
+or other dealings in this Software without prior written authorization
+of the copyright holder.
+
+All trademarks and registered trademarks mentioned herein are the
+property of their respective owners.
+
+2. Chinese/Japanese Word Break Dictionary Data (cjdict.txt)
+
+ #     The Google Chrome software developed by Google is licensed under
+ # the BSD license. Other software included in this distribution is
+ # provided under other licenses, as set forth below.
+ #
+ #  The BSD License
+ #  http://opensource.org/licenses/bsd-license.php
+ #  Copyright (C) 2006-2008, Google Inc.
+ #
+ #  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  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.
+ #
+ #
+ #  The word list in cjdict.txt are generated by combining three word lists
+ # listed below with further processing for compound word breaking. The
+ # frequency is generated with an iterative training against Google web
+ # corpora.
+ #
+ #  * Libtabe (Chinese)
+ #    - https://sourceforge.net/project/?group_id=1519
+ #    - Its license terms and conditions are shown below.
+ #
+ #  * IPADIC (Japanese)
+ #    - http://chasen.aist-nara.ac.jp/chasen/distribution.html
+ #    - Its license terms and conditions are shown below.
+ #
+ #  ---------COPYING.libtabe ---- BEGIN--------------------
+ #
+ #  /*
+ #   * Copyrighy (c) 1999 TaBE Project.
+ #   * Copyright (c) 1999 Pai-Hsiang Hsiao.
+ #   * 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 TaBE Project 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
+ #   * REGENTS 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.
+ #   */
+ #
+ #  /*
+ #   * Copyright (c) 1999 Computer Systems and Communication Lab,
+ #   *                    Institute of Information Science, Academia
+ #       *                    Sinica. 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 Computer Systems and Communication Lab
+ #   *   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
+ #   * REGENTS 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.
+ #   */
+ #
+ #  Copyright 1996 Chih-Hao Tsai @ Beckman Institute,
+ #      University of Illinois
+ #  c-tsai4@uiuc.edu  http://casper.beckman.uiuc.edu/~c-tsai4
+ #
+ #  ---------------COPYING.libtabe-----END--------------------------------
+ #
+ #
+ #  ---------------COPYING.ipadic-----BEGIN-------------------------------
+ #
+ #  Copyright 2000, 2001, 2002, 2003 Nara Institute of Science
+ #  and Technology.  All Rights Reserved.
+ #
+ #  Use, reproduction, and distribution of this software is permitted.
+ #  Any copy of this software, whether in its original form or modified,
+ #  must include both the above copyright notice and the following
+ #  paragraphs.
+ #
+ #  Nara Institute of Science and Technology (NAIST),
+ #  the copyright holders, disclaims all warranties with regard to this
+ #  software, including all implied warranties of merchantability and
+ #  fitness, in no event shall NAIST be liable for
+ #  any special, 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 tortuous action, arising out
+ #  of or in connection with the use or performance of this software.
+ #
+ #  A large portion of the dictionary entries
+ #  originate from ICOT Free Software.  The following conditions for ICOT
+ #  Free Software applies to the current dictionary as well.
+ #
+ #  Each User may also freely distribute the Program, whether in its
+ #  original form or modified, to any third party or parties, PROVIDED
+ #  that the provisions of Section 3 ("NO WARRANTY") will ALWAYS appear
+ #  on, or be attached to, the Program, which is distributed substantially
+ #  in the same form as set out herein and that such intended
+ #  distribution, if actually made, will neither violate or otherwise
+ #  contravene any of the laws and regulations of the countries having
+ #  jurisdiction over the User or the intended distribution itself.
+ #
+ #  NO WARRANTY
+ #
+ #  The program was produced on an experimental basis in the course of the
+ #  research and development conducted during the project and is provided
+ #  to users as so produced on an experimental basis.  Accordingly, the
+ #  program is provided without any warranty whatsoever, whether express,
+ #  implied, statutory or otherwise.  The term "warranty" used herein
+ #  includes, but is not limited to, any warranty of the quality,
+ #  performance, merchantability and fitness for a particular purpose of
+ #  the program and the nonexistence of any infringement or violation of
+ #  any right of any third party.
+ #
+ #  Each user of the program will agree and understand, and be deemed to
+ #  have agreed and understood, that there is no warranty whatsoever for
+ #  the program and, accordingly, the entire risk arising from or
+ #  otherwise connected with the program is assumed by the user.
+ #
+ #  Therefore, neither ICOT, the copyright holder, or any other
+ #  organization that participated in or was otherwise related to the
+ #  development of the program and their respective officials, directors,
+ #  officers and other employees shall be held liable for any and all
+ #  damages, including, without limitation, general, special, incidental
+ #  and consequential damages, arising out of or otherwise in connection
+ #  with the use or inability to use the program or any product, material
+ #  or result produced or otherwise obtained by using the program,
+ #  regardless of whether they have been advised of, or otherwise had
+ #  knowledge of, the possibility of such damages at any time during the
+ #  project or thereafter.  Each user will be deemed to have agreed to the
+ #  foregoing by his or her commencement of use of the program.  The term
+ #  "use" as used herein includes, but is not limited to, the use,
+ #  modification, copying and distribution of the program and the
+ #  production of secondary products from the program.
+ #
+ #  In the case where the program, whether in its original form or
+ #  modified, was distributed or delivered to or received by a user from
+ #  any person, organization or entity other than ICOT, unless it makes or
+ #  grants independently of ICOT any specific warranty to the user in
+ #  writing, such person, organization or entity, will also be exempted
+ #  from and not be held liable to the user for any such damages as noted
+ #  above as far as the program is concerned.
+ #
+ #  ---------------COPYING.ipadic-----END----------------------------------
+
+3. Lao Word Break Dictionary Data (laodict.txt)
+
+ #  Copyright (c) 2013 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ # Project: http://code.google.com/p/lao-dictionary/
+ # Dictionary: http://lao-dictionary.googlecode.com/git/Lao-Dictionary.txt
+ # License: http://lao-dictionary.googlecode.com/git/Lao-Dictionary-LICENSE.txt
+ #              (copied below)
+ #
+ #  This file is derived from the above dictionary, with slight
+ #  modifications.
+ #  ----------------------------------------------------------------------
+ #  Copyright (C) 2013 Brian Eugene Wilson, Robert Martin Campbell.
+ #  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.
+ #
+ #
+ # 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.
+ #  --------------------------------------------------------------------------
+
+4. Burmese Word Break Dictionary Data (burmesedict.txt)
+
+ #  Copyright (c) 2014 International Business Machines Corporation
+ #  and others. All Rights Reserved.
+ #
+ #  This list is part of a project hosted at:
+ #    github.com/kanyawtech/myanmar-karen-word-lists
+ #
+ #  --------------------------------------------------------------------------
+ #  Copyright (c) 2013, LeRoy Benjamin Sharon
+ #  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 Myanmar Karen Word Lists, 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.
+ #  --------------------------------------------------------------------------
+
+5. Time Zone Database
+
+  ICU uses the public domain data and code derived from Time Zone
+Database for its time zone support. The ownership of the TZ database
+is explained in BCP 175: Procedure for Maintaining the Time Zone
+Database section 7.
+
+ # 7.  Database Ownership
+ #
+ #    The TZ database itself is not an IETF Contribution or an IETF
+ #    document.  Rather it is a pre-existing and regularly updated work
+ #    that is in the public domain, and is intended to remain in the
+ #    public domain.  Therefore, BCPs 78 [RFC5378] and 79 [RFC3979] do
+ #    not apply to the TZ Database or contributions that individuals make
+ #    to it.  Should any claims be made and substantiated against the TZ
+ #    Database, the organization that is providing the IANA
+ #    Considerations defined in this RFC, under the memorandum of
+ #    understanding with the IETF, currently ICANN, may act in accordance
+ #    with all competent court orders.  No ownership claims will be made
+ #    by ICANN or the IETF Trust on the database or the code.  Any person
+ #    making a contribution to the database or code waives all rights to
+ #    future claims in that contribution or in the TZ Database.
+
+----
+
+
+[[jgit]]
+jgit
+
+* jgit
+* jgit-archive
+* jgit-servlet
+* jgit-ssh-apache
+
+[[jgit_license]]
+----
+This program and the accompanying materials are made available
+under the terms of the Eclipse Distribution License v1.0 which
+accompanies this distribution, is reproduced below, and is
+available at http://www.eclipse.org/org/documents/edl-v10.php
+
+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 Eclipse Foundation, 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.
+
+----
+
+
+[[jsch]]
+jsch
+
+* jsch
+
+[[jsch_license]]
+----
+Copyright (c) 2002-2012 Atsuhiko Yamanaka, JCraft,Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+  1. Redistributions of source code must retain the above copyright notice,
+     this list of conditions and the following disclaimer.
+
+  2. 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.
+
+  3. The names of the authors may not be used to endorse or promote products
+     derived from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESSED 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 JCRAFT,
+INC. OR ANY CONTRIBUTORS TO THIS SOFTWARE 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.
+
+----
+
+
+[[jsoup]]
+jsoup
+
+* jsoup:jsoup
+
+[[jsoup_license]]
+----
+The MIT License
+
+© 2009-2016, Jonathan Hedley <jonathan@hedley.net>
+
+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.
+
+----
+
+
+[[ow2]]
+ow2
+
+* ow2:ow2-asm
+* ow2:ow2-asm-analysis
+* ow2:ow2-asm-commons
+* ow2:ow2-asm-tree
+* ow2:ow2-asm-util
+
+[[ow2_license]]
+----
+Copyright (c) 2000-2011 INRIA, France Telecom
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+
+1. Redistributions of source code must retain the above copyright
+   notice, this list of conditions and the following disclaimer.
+
+2. 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.
+
+3. Neither the name of the copyright holders 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.
+
+----
+
+
+[[prologcafe]]
+prologcafe
+
+* prolog:cafeteria
+* prolog:compiler
+* prolog:io
+* prolog:runtime
+
+[[prologcafe_license]]
+----
+Prolog Cafe (A Prolog to Java Translator System)
+Copyright (C) 1997-2009 by Mutsunori Banbara and Naoyuki Tamura
+
+Prolog Cafe is free software; you can redistribute it and/or modify
+it under the terms of either:
+
+  * the GNU General Public License as published by the Free Software
+    Foundation; either version 2 of the License, or (at your option)
+    any later version, or
+
+  * the Eclipse Public License
+----
+
+In the context of Gerrit Code Review, Prolog Cafe is consumed under
+the <<prologcafe_EPL,EPL>>. Gerrit Code Review uses a fork derived
+from the 1.2.5 release and offers the corresponding source code at
+link:https://gerrit.googlesource.com/prolog-cafe[].
+
+----
+                    GNU GENERAL PUBLIC LICENSE
+                       Version 2, June 1991
+
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+                            Preamble
+
+  The licenses for most software are designed to take away your
+freedom to share and change it.  By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users.  This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it.  (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.)  You can apply it to
+your programs, too.
+
+  When we speak of free software, we are referring to freedom, not
+price.  Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+  To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+  For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have.  You must make sure that they, too, receive or can get the
+source code.  And you must show them these terms so they know their
+rights.
+
+  We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+  Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software.  If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+  Finally, any free program is threatened constantly by software
+patents.  We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary.  To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+  The precise terms and conditions for copying, distribution and
+modification follow.
+
+                    GNU GENERAL PUBLIC LICENSE
+   TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+  0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License.  The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language.  (Hereinafter, translation is included without limitation in
+the term "modification".)  Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope.  The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+  1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+  2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+    a) You must cause the modified files to carry prominent notices
+    stating that you changed the files and the date of any change.
+
+    b) You must cause any work that you distribute or publish, that in
+    whole or in part contains or is derived from the Program or any
+    part thereof, to be licensed as a whole at no charge to all third
+    parties under the terms of this License.
+
+    c) If the modified program normally reads commands interactively
+    when run, you must cause it, when started running for such
+    interactive use in the most ordinary way, to print or display an
+    announcement including an appropriate copyright notice and a
+    notice that there is no warranty (or else, saying that you provide
+    a warranty) and that users may redistribute the program under
+    these conditions, and telling the user how to view a copy of this
+    License.  (Exception: if the Program itself is interactive but
+    does not normally print such an announcement, your work based on
+    the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole.  If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works.  But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+  3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+    a) Accompany it with the complete corresponding machine-readable
+    source code, which must be distributed under the terms of Sections
+    1 and 2 above on a medium customarily used for software interchange; or,
+
+    b) Accompany it with a written offer, valid for at least three
+    years, to give any third party, for a charge no more than your
+    cost of physically performing source distribution, a complete
+    machine-readable copy of the corresponding source code, to be
+    distributed under the terms of Sections 1 and 2 above on a medium
+    customarily used for software interchange; or,
+
+    c) Accompany it with the information you received as to the offer
+    to distribute corresponding source code.  (This alternative is
+    allowed only for noncommercial distribution and only if you
+    received the program in object code or executable form with such
+    an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it.  For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable.  However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+  4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License.  Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+  5. You are not required to accept this License, since you have not
+signed it.  However, nothing else grants you permission to modify or
+distribute the Program or its derivative works.  These actions are
+prohibited by law if you do not accept this License.  Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+  6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions.  You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+  7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License.  If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all.  For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices.  Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+  8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded.  In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+  9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time.  Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number.  If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation.  If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+  10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission.  For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this.  Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+                            NO WARRANTY
+
+  11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW.  EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.  THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU.  SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+  12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+                     END OF TERMS AND CONDITIONS
+
+            How to Apply These Terms to Your New Programs
+
+  If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+  To do so, attach the following notices to the program.  It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+    <one line to give the program's name and a brief idea of what it does.>
+    Copyright (C) <year>  <name of author>
+
+    This program is free software; you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation; either version 2 of the License, or
+    (at your option) any later version.
+
+    This program is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License along
+    with this program; if not, write to the Free Software Foundation, Inc.,
+    51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+    Gnomovision version 69, Copyright (C) year name of author
+    Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+    This is free software, and you are welcome to redistribute it
+    under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License.  Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary.  Here is a sample; alter the names:
+
+  Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+  `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+  <signature of Ty Coon>, 1 April 1989
+  Ty Coon, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs.  If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library.  If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
+----
+
+[[prologcafe_EPL]]
+----
+Eclipse Public License - v 1.0
+
+THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
+PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
+OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
+
+1. DEFINITIONS
+
+"Contribution" means:
+
+a) in the case of the initial Contributor, the initial code and
+   documentation distributed under this Agreement, and
+b) in the case of each subsequent Contributor:
+
+i) changes to the Program, and
+
+ii) additions to the Program;
+
+where such changes and/or additions to the Program originate from
+and are distributed by that particular Contributor. A Contribution
+'originates' from a Contributor if it was added to the Program
+by such Contributor itself or anyone acting on such Contributor's
+behalf. Contributions do not include additions to the Program which:
+(i) are separate modules of software distributed in conjunction
+with the Program under their own license agreement, and (ii) are
+not derivative works of the Program.
+
+"Contributor" means any person or entity that distributes the Program.
+
+"Licensed Patents " mean patent claims licensable by a Contributor
+which are necessarily infringed by the use or sale of its
+Contribution alone or when combined with the Program.
+
+"Program" means the Contributions distributed in accordance with
+this Agreement.
+
+"Recipient" means anyone who receives the Program under this
+Agreement, including all Contributors.
+
+2. GRANT OF RIGHTS
+
+a) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free copyright
+   license to reproduce, prepare derivative works of, publicly display,
+   publicly perform, distribute and sublicense the Contribution of such
+   Contributor, if any, and such derivative works, in source code and
+   object code form.
+
+b) Subject to the terms of this Agreement, each Contributor hereby
+   grants Recipient a non-exclusive, worldwide, royalty-free patent
+   license under Licensed Patents to make, use, sell, offer to sell,
+   import and otherwise transfer the Contribution of such Contributor,
+   if any, in source code and object code form. This patent license
+   shall apply to the combination of the Contribution and the Program
+   if, at the time the Contribution is added by the Contributor, such
+   addition of the Contribution causes such combination to be covered
+   by the Licensed Patents. The patent license shall not apply to any
+   other combinations which include the Contribution. No hardware per
+   se is licensed hereunder.
+
+c) Recipient understands that although each Contributor grants the
+   licenses to its Contributions set forth herein, no assurances are
+   provided by any Contributor that the Program does not infringe
+   the patent or other intellectual property rights of any other
+   entity. Each Contributor disclaims any liability to Recipient
+   for claims brought by any other entity based on infringement
+   of intellectual property rights or otherwise. As a condition to
+   exercising the rights and licenses granted hereunder, each Recipient
+   hereby assumes sole responsibility to secure any other intellectual
+   property rights needed, if any. For example, if a third party patent
+   license is required to allow Recipient to distribute the Program,
+   it is Recipient's responsibility to acquire that license before
+   distributing the Program.
+
+d) Each Contributor represents that to its knowledge it has
+   sufficient copyright rights in its Contribution, if any, to grant
+   the copyright license set forth in this Agreement.
+
+3. REQUIREMENTS
+
+A Contributor may choose to distribute the Program in object code
+  form under its own license agreement, provided that:
+
+a) it complies with the terms and conditions of this Agreement; and
+
+b) its license agreement:
+
+i) effectively disclaims on behalf of all Contributors all warranties
+   and conditions, express and implied, including warranties or
+   conditions of title and non-infringement, and implied warranties or
+   conditions of merchantability and fitness for a particular purpose;
+
+ii) effectively excludes on behalf of all Contributors all liability
+    for damages, including direct, indirect, special, incidental and
+    consequential damages, such as lost profits;
+
+iii) states that any provisions which differ from this Agreement
+     are offered by that Contributor alone and not by any other
+     party; and
+
+iv) states that source code for the Program is available from such
+    Contributor, and informs licensees how to obtain it in a reasonable
+    manner on or through a medium customarily used for software exchange.
+
+When the Program is made available in source code form:
+
+a) it must be made available under this Agreement; and
+
+b) a copy of this Agreement must be included with each copy of the Program.
+
+Contributors may not remove or alter any copyright notices contained
+within the Program.
+
+Each Contributor must identify itself as the originator of its
+Contribution, if any, in a manner that reasonably allows subsequent
+Recipients to identify the originator of the Contribution.
+
+4. COMMERCIAL DISTRIBUTION
+
+Commercial distributors of software may accept certain
+responsibilities with respect to end users, business partners and the
+like. While this license is intended to facilitate the commercial
+use of the Program, the Contributor who includes the Program in a
+commercial product offering should do so in a manner which does not
+create potential liability for other Contributors. Therefore, if a
+Contributor includes the Program in a commercial product offering,
+such Contributor ("Commercial Contributor") hereby agrees to defend
+and indemnify every other Contributor ("Indemnified Contributor")
+against any losses, damages and costs (collectively "Losses") arising
+from claims, lawsuits and other legal actions brought by a third
+party against the Indemnified Contributor to the extent caused by
+the acts or omissions of such Commercial Contributor in connection
+with its distribution of the Program in a commercial product
+offering. The obligations in this section do not apply to any claims
+or Losses relating to any actual or alleged intellectual property
+infringement. In order to qualify, an Indemnified Contributor must:
+a) promptly notify the Commercial Contributor in writing of such
+claim, and b) allow the Commercial Contributor to control, and
+cooperate with the Commercial Contributor in, the defense and any
+related settlement negotiations. The Indemnified Contributor may
+participate in any such claim at its own expense.
+
+For example, a Contributor might include the Program in a
+commercial product offering, Product X. That Contributor is then a
+Commercial Contributor. If that Commercial Contributor then makes
+performance claims, or offers warranties related to Product X, those
+performance claims and warranties are such Commercial Contributor's
+responsibility alone. Under this section, the Commercial Contributor
+would have to defend claims against the other Contributors related
+to those performance claims and warranties, and if a court requires
+any other Contributor to pay any damages as a result, the Commercial
+Contributor must pay those damages.
+
+5. NO WARRANTY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS
+PROVIDED 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. Each Recipient is solely
+responsible for determining the appropriateness of using and
+distributing the Program and assumes all risks associated with
+its exercise of rights under this Agreement , including but not
+limited to the risks and costs of program errors, compliance with
+applicable laws, damage to or loss of data, programs or equipment,
+and unavailability or interruption of operations.
+
+6. DISCLAIMER OF LIABILITY
+
+EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT
+NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING WITHOUT LIMITATION LOST PROFITS), 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 OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY
+RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+7. GENERAL
+
+If any provision of this Agreement is invalid or unenforceable under
+applicable law, it shall not affect the validity or enforceability of
+the remainder of the terms of this Agreement, and without further
+action by the parties hereto, such provision shall be reformed
+to the minimum extent necessary to make such provision valid and
+enforceable.
+
+If Recipient institutes patent litigation against any entity
+(including a cross-claim or counterclaim in a lawsuit) alleging
+that the Program itself (excluding combinations of the Program with
+other software or hardware) infringes such Recipient's patent(s),
+then such Recipient's rights granted under Section 2(b) shall
+terminate as of the date such litigation is filed.
+
+All Recipient's rights under this Agreement shall terminate if
+it fails to comply with any of the material terms or conditions
+of this Agreement and does not cure such failure in a reasonable
+period of time after becoming aware of such noncompliance. If all
+Recipient's rights under this Agreement terminate, Recipient agrees
+to cease use and distribution of the Program as soon as reasonably
+practicable. However, Recipient's obligations under this Agreement
+and any licenses granted by Recipient relating to the Program shall
+continue and survive.
+
+Everyone is permitted to copy and distribute copies of this
+Agreement, but in order to avoid inconsistency the Agreement is
+copyrighted and may only be modified in the following manner. The
+Agreement Steward reserves the right to publish new versions
+(including revisions) of this Agreement from time to time. No
+one other than the Agreement Steward has the right to modify
+this Agreement. The Eclipse Foundation is the initial Agreement
+Steward. The Eclipse Foundation may assign the responsibility to
+serve as the Agreement Steward to a suitable separate entity. Each
+new version of the Agreement will be given a distinguishing
+version number. The Program (including Contributions) may always be
+distributed subject to the version of the Agreement under which it
+was received. In addition, after a new version of the Agreement is
+published, Contributor may elect to distribute the Program (including
+its Contributions) under the new version. Except as expressly stated
+in Sections 2(a) and 2(b) above, Recipient receives no rights or
+licenses to the intellectual property of any Contributor under
+this Agreement, whether expressly, by implication, estoppel or
+otherwise. All rights in the Program not expressly granted under
+this Agreement are reserved.
+
+This Agreement is governed by the laws of the State of New York and
+the intellectual property laws of the United States of America. No
+party to this Agreement will bring a legal action under this
+Agreement more than one year after the cause of action arose. Each
+party waives its rights to a jury trial in any resulting litigation.
+
+----
+
+
+[[protobuf]]
+protobuf
+
+* protobuf
+
+[[protobuf_license]]
+----
+Copyright 2008, Google Inc.
+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 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.
+
+Code generated by the Protocol Buffer compiler is owned by the owner
+of the input file used when generating it.  This code is not
+standalone and requires a support library to be linked with it.  This
+support library is itself covered by the above license.
+
+----
+
+
+[[slf4j]]
+slf4j
+
+* log:api
+* log:jcl-over-slf4j
+
+[[slf4j_license]]
+----
+Copyright (c) 2004-2008 QOS.ch
+All rights reserved.
+
+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.
+
+----
+
+
+[[xz]]
+xz
+
+* tukaani-xz
+
+[[xz_license]]
+----
+All the files in this package have been written by Lasse Collin
+and/or Igor Pavlov. All these files have been put into the
+public domain. You can do whatever you want with these files.
+This software is provided "as is", without any warranty.
+
+----
+
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
diff --git a/Documentation/backup.txt b/Documentation/backup.txt
index dd47035..9139e71 100644
--- a/Documentation/backup.txt
+++ b/Documentation/backup.txt
@@ -45,10 +45,6 @@
 It can be recomputed from primary data in the git repositories but
 reindexing may take a long time hence backing up the index makes sense
 for production installations.
-+
-If you have chosen to use _Elastic Search_ for indexing,
-refer to its
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-snapshots.html[backup documentation,role=external,window=_blank].
 
 [#optional-backup-cache]
 Caches::
diff --git a/Documentation/cmd-index.txt b/Documentation/cmd-index.txt
index 99ff0db..ce3a024 100644
--- a/Documentation/cmd-index.txt
+++ b/Documentation/cmd-index.txt
@@ -157,6 +157,9 @@
 link:cmd-ls-user-refs.html[gerrit ls-user-refs]::
 	Lists refs visible for a specified user.
 
+link:cmd-migrate-externalids-to-insensitive.html[gerrit migrate-externalids-to-insensitive]::
+	Migrate external-ids to case insensitive.
+
 link:cmd-plugin-install.html[gerrit plugin add]::
 	Alias for 'gerrit plugin install'.
 
@@ -247,6 +250,18 @@
 Given the trace ID an administrator can find the corresponding logs and
 investigate issues more easily.
 
+[[deadline]]
+=== Setting a deadline
+
+When invoking an SSH command it's possible that the client sets a deadline
+after which the request should be aborted. To do this the
+`--deadline <deadline>` option must be set on the request. Values must be
+specified using standard time unit abbreviations ('ms', 'sec', 'min', etc.).
+
+----
+  $ ssh -p 29418 review.example.com gerrit create-project --deadline 5m foo/bar
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/cmd-migrate-externalids-to-insensitive.txt b/Documentation/cmd-migrate-externalids-to-insensitive.txt
new file mode 100644
index 0000000..b023089
--- /dev/null
+++ b/Documentation/cmd-migrate-externalids-to-insensitive.txt
@@ -0,0 +1,44 @@
+= gerrit migrate-externalids-to-insensitive
+
+== NAME
+gerrit migrate-externalids-to-insensitive - Migrate external-ids to case insensitive.
+
+== SYNOPSIS
+[verse]
+--
+_ssh_ -p <port> <host> _gerrit migrate-externalids-to-insensitive_
+--
+
+== DESCRIPTION
+This command allows to trigger online conversion of `username` and
+`gerrit` external IDs to be handled case insensitively. This is done by
+recomputing the name of the note from the sha1 sum of the all lowercase
+external ID key, instead of preserving the key capitalization.
+
+The command requires link:#auth.userNameCaseInsensitive[auth.userNameCaseInsensitive] and
+link:#auth.userNameCaseInsensitiveMigrationMode[auth.userNameCaseInsensitiveMigrationMode] to
+be set to true to perform the migration.
+
+After the successful migration
+link:#auth.userNameCaseInsensitiveMigrationMode[auth.userNameCaseInsensitiveMigrationMode] is
+set to false.
+
+== ACCESS
+Caller must be a member of the privileged 'Administrators' group.
+
+== SCRIPTING
+This command is intended to be used in scripts.
+
+== EXAMPLES
+Start the online external ids migration:
+
+----
+$ ssh -p 29418 review.example.com gerrit migrate-externalids-to-insensitive
+----
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/cmd-set-account.txt b/Documentation/cmd-set-account.txt
index 6808e017..02eaf83 100644
--- a/Documentation/cmd-set-account.txt
+++ b/Documentation/cmd-set-account.txt
@@ -14,7 +14,8 @@
   [--delete-ssh-key - | <KEY> | ALL]
   [--generate-http-password]
   [--http-password <PASSWORD>]
-  [--clear-http-password] <USER>
+  [--clear-http-password]
+  [--delete-external-id <EXTERNALID>] <USER>
 --
 
 == DESCRIPTION
@@ -106,6 +107,13 @@
 --clear-http-password::
     Clear the HTTP password for the user account.
 
+--delete-external-id::
+    Delete an external ID from a user's account if it exists.
+    If the external ID provided is 'ALL', all associated
+    external IDs are deleted from this account.
+    May be supplied more than once to remove multiple external
+    IDs from an account in a single command execution.
+
 == EXAMPLES
 Add an email and SSH key to `watcher`'s account:
 
diff --git a/Documentation/config-accounts.txt b/Documentation/config-accounts.txt
index 9b99960..aca9591 100644
--- a/Documentation/config-accounts.txt
+++ b/Documentation/config-accounts.txt
@@ -3,8 +3,7 @@
 
 == Overview
 
-Starting from 2.15 Gerrit accounts are fully stored in
-link:note-db.html[NoteDb].
+Gerrit accounts are stored in link:note-db.html[NoteDb].
 
 The account data consists of a sequence number (account ID), account
 properties (full name, display name, preferred email, registration
@@ -112,8 +111,8 @@
 Accessing the account data in Git is not fast enough for account
 queries, since it requires accessing all user branches and parsing
 all files in each of them. To overcome this Gerrit has a secondary
-index for accounts. The account index is either based on
-link:config-gerrit.html#index.type[Lucene or Elasticsearch].
+index for accounts. The account index is based on
+link:config-gerrit.html#index.type[Lucene].
 
 Via the link:rest-api-accounts.html#query-account[Query Account] REST
 endpoint link:user-search-accounts.html[generic account queries] are
@@ -298,6 +297,13 @@
 This ensures that an external ID is used only once (e.g. an external ID can
 never be assigned to multiple accounts at a point in time).
 
+By default, the SHA-1 sum is computed preserving the case of the external ID. If
+auth.userNameCaseInsensitive` is set to `true`, the SHA-1 sum of external IDs
+in the `gerrit:` and `username:` schemes are computed from the all lowercase
+external ID. This enables case insensitive username handling. The case of the
+external ID is however preserved by using the original capitalization in the
+note content.
+
 The following commands show how to find the SHA-1 of an external ID:
 
 ----
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index f088240..868d32c 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -652,6 +652,43 @@
 +
 By default this is set to false.
 
+[[auth.userNameCaseInsensitive]]auth.userNameCaseInsensitive::
++
+If set the username will be handled case insensitively but case preserving,
+i.e. a user can login with `johndoe` or `JohnDoe` for the same account
+created for `JohnDoe`. The form of the username used during account creation
+will be used wherever the username is displayed. Sandbox branches created
+for a user can also only be created for this original form.
++
+Note, that this does not work for all existing accounts, if they were
+not originally created with all lowercase, since the note keys of the
+external IDs will not match the new scheme. For more details refer to
+the link:config-accounts.html#external-ids[External ID documentation].
++
+Gerrit provides the
+link:pgm-ChangeExternalIdCaseSensitivity.html[offline]
+and the online link:externalid-case-insensitivity.html#online-migration[online]
+tools to migrate existing accounts to match the new scheme.
++
+Naturally, if there were two accounts only different in capitalization,
+e.g. `johndoe` and `JohnDoe`, the account `JohnDoe` will not be able
+to authenticate anymore after setting this option. If such duplicate
+accounts exist the migration tool will fail, since the newly computed
+note name would be identical and thus conflict. These duplicates thus
+have to be deleted manually by deleting the respective external ID.
++
+For newly initialized sites this option defaults to true.
++
+Default is false.
+
+[[auth.userNameCaseInsensitiveMigrationMode]]auth.userNameCaseInsensitiveMigrationMode::
++
+Setting migration mode to true allows to fallback to case sensitive
+behaviour if the migrated external ID cannot be found. This allows to
+trigger the migration while Gerrit process is running.
++
+Default is false.
+
 [[auth.enableRunAs]]auth.enableRunAs::
 +
 If true HTTP REST APIs will accept the `X-Gerrit-RunAs` HTTP request
@@ -808,18 +845,21 @@
 entry is relatively the same, memoryLimit is currently defined to be
 the number of entries held by the cache (each entry costs 1).
 +
-For caches where the size of an entry can vary significantly between
-individual entries (notably `"diff"`, `"diff_intraline"`), memoryLimit
-is an approximation of the total number of bytes stored by the cache.
-Larger entries that represent bigger patch sets or longer source files
-will consume a bigger portion of the memoryLimit. For these caches the
-memoryLimit should be set to roughly the amount of RAM (in bytes) the
-administrator can dedicate to the cache.
+For caches where the size of an entry can vary significantly between individual
+entries (notably `"git_modified_files"`, `"modified_files"`, `"git_file_diff"`,
+`"gerrit_file_diff"`, `"diff_intraline"`), memoryLimit is an approximation of
+the total number of bytes stored by the cache.  Larger entries that represent
+bigger patch sets or longer source files will consume a bigger portion of the
+memoryLimit. For these caches the memoryLimit should be set to roughly the
+amount of RAM (in bytes) the administrator can dedicate to the cache.
 +
 Default is 1024 for most caches, except:
 +
 * `"adv_bases"`: default is `4096`
-* `"diff"`: default is `10m` (10 MiB of memory)
+* `"git_modified_files"`: default is `10m` (10 MiB of memory)
+* `"modified_files"`: default is `10m` (10 MiB of memory)
+* `"git_file_diff"`: default is `10m` (10 MiB of memory)
+* `"gerrit_file_diff"`: default is `10m` (10 MiB of memory)
 * `"diff_intraline"`: default is `10m` (10 MiB of memory)
 * `"diff_summary"`: default is `10m` (10 MiB of memory)
 * `"external_ids_map"`: default is `2` and should not be changed
@@ -916,6 +956,12 @@
 +
 If direct updates are made to `All-Users`, this cache should be flushed.
 
+cache `"approvals"`::
++
+Cache entries contain approvals for a given patch set. This includes
+approvals granted on this patch set as well as approvals copied from
+earlier patch sets.
+
 cache `"adv_bases"`::
 +
 Used only for push over smart HTTP when branch level access controls
@@ -944,16 +990,38 @@
 The cache should be flushed whenever NoteDb change metadata in a repository is
 modified outside of Gerrit.
 
-cache `"diff"`::
+cache `"git_modified_files"`::
 +
-Each item caches the differences between two commits, at both the
-directory and file levels.  Gerrit uses this cache to accelerate
-the display of affected file names, as well as file contents.
+Each item caches the list of git modified files between two git trees
+corresponding to two different commits. This cache does not read the actual
+file contents nor does it include the edits (modified regions) of the files.
+
+cache `"modified_files"`::
++
+Each item caches the list of modified files between two commits. This cache
+is similar to the `git_modified_files` cache but performs extra logic including
+filtering out files that are untouched by both commits because they were purely
+modified between the parent commits.
+
+cache `"git_file_diff"`::
++
+Each item caches the pure git diff between two git trees for a specific file
+path. The diff includes all the file attributes (old/new paths, change/patch
+types) as well as the list of edits corresponding to the modified regions in
+the file.
+
+cache `"gerrit_file_diff"`::
++
+Each item caches the diff between two git commits for a specific file path.
+This cache is similar to the `git_file_diff` cache but performs extra logic
+including identifying the edits that are due to rebase. The diff for the
+"commit message" and "merge list" can also be requested from this cache.
 +
 Entries in this cache are relatively large, so memoryLimit is an
 estimate in bytes of memory used. Administrators should try to target
 cache.diff.memoryLimit to fit all changes users will view in a 1 or 2
-day span.
+day span. The same applies for other diff caches: `"git_modified_files"`,
+`"modified_files"` and `"git_file_diff"`.
 
 cache `"diff_intraline"`::
 +
@@ -1168,9 +1236,9 @@
 
 ==== [[cache_options]]Cache Options
 
-[[cache.diff.timeout]]cache.diff.timeout::
+[[cache.git_file_diff.timeout]]cache.git_file_diff.timeout::
 +
-Maximum number of milliseconds to wait for diff data before giving up and
+Maximum number of milliseconds to wait for git diff data before giving up and
 falling back on a simpler diff algorithm that will not be able to break down
 modified regions into smaller ones. This is a work around for an infinite loop
 bug in the default difference algorithm implementation.
@@ -1292,15 +1360,16 @@
 
 [[change.cacheAutomerge]]change.cacheAutomerge::
 +
-When reviewing merge commits, the left-hand side shows the output of the
-result of JGit's automatic merge algorithm. This option controls whether
-this output is cached in the change repository, or if only the diff is
-cached in the persistent `diff` cache.
+When reviewing merge commits, the left-hand side shows the output of the result
+of JGit's automatic merge algorithm. This option controls whether this output is
+cached in the change repository, or if only the diff is cached in the persistent
+diff caches (`"git_modified_files"`, `modified_files`, `"git_file_diff"`,
+`"file_diff"`).
 +
 If true, automerge results are stored in the repository under
 `refs/cache-automerge/*`; the results of diffing the change against its
-automerge base are stored in the diff cache. If false, no extra data is
-stored in the repository, only the diff cache. This can result in slight
+automerge base are stored in the diff caches. If false, no extra data is
+stored in the repository, only the diff caches. This can result in slight
 performance improvements by reducing the number of refs in the repo.
 +
 Default is true.
@@ -1409,8 +1478,24 @@
   query operator. Gerrit does not serve `mergeable` in
   link:rest-api-changes.html#change-info[ChangeInfo].
 
+NOTE: Gerrit would only render conflict changes section on change
+screen if `API_REF_UPDATED_AND_CHANGE_REINDEX` value is set.
+
 Default is `NEVER`.
 
+[[change.conflictsPredicateEnabled]]change.conflictsPredicateEnabled::
+
++
+This setting determines when Gerrit renders conflict changes section on change
+screen and also supports `conflicts` predicate. This computation is expensive,
+computing ConflictsPredicate has a runtime complexity of O(nˆ2) with n number
+of open changes on a branch. When set to false GUI will silently ignore the
+error message and leave the conflict changes section on change screen empty.
+See also implications on rendering of conflict changes section in configuration
+section:link:#change.mergeabilityComputationBehavior[change.mergeabilityComputationBehavior].
+
+Default is true.
+
 [[change.move]]change.move::
 +
 Whether the link:rest-api-changes.html#move-change[Move Change] REST
@@ -1428,21 +1513,6 @@
 +
 By default true.
 
-[[change.replyLabel]]change.replyLabel::
-+
-Label name for the reply button. In the user interface an ellipsis (…)
-is appended.
-+
-Default is "Reply". In the user interface it becomes "Reply…".
-
-[[change.replyTooltip]]change.replyTooltip::
-+
-Tooltip for the reply button. In the user interface a note about the
-keyboard shortcut is appended.
-+
-Default is "Reply and score". In the user interface it becomes "Reply
-and score (Shortcut: a)".
-
 [[change.robotCommentSizeLimit]]change.robotCommentSizeLimit::
 +
 Maximum allowed size in characters of a robot comment. Robot comments which
@@ -1971,7 +2041,7 @@
   scheme = http
   scheme = anon_http
   scheme = anon_git
-  scheme = repo_download
+  scheme = repo
 ----
 
 The download section configures the allowed download methods.
@@ -2028,12 +2098,13 @@
 necessary to set <<gerrit.canonicalGitUrl,gerrit.canonicalGitUrl>>
 variable.
 +
-* `repo_download`
+* `repo`
 +
 Gerrit advertises patch set downloads with the `repo download`
 command, assuming that all projects managed by this instance are
-generally worked on with the repo multi-repository tool.  This is
-not default, as not all instances will deploy repo.
+generally worked on with the
+https://gerrit.googlesource.com/git-repo[repo multi-repository tool].
+This is not default, as not all instances will deploy repo.
 
 +
 If `download.scheme` is not specified, SSH, HTTP and Anonymous HTTP
@@ -2143,6 +2214,9 @@
 other project managed by the running server. The name is
 relative to `gerrit.basePath`.
 +
+The link:#cache_names[persisted_projects cache] must be
+flushed after this setting is changed.
++
 Defaults to `All-Projects` if not set.
 
 [[gerrit.defaultBranch]]gerrit.defaultBranch::
@@ -2248,6 +2322,25 @@
 +
 By default unset.
 
+[[gerrit.installIndexModule]]gerrit.installIndexModule::
++
+Class name of the Guice modules to load as alternate implementation
+for the Gerrit indexes backend.
+Classes are resolved using the primary Gerrit class loader, hence the
+class needs to be either declared in Gerrit or an additional JAR
+located under the `/lib` directory.
++
+NOTE: The `gerrit.installIndexModule` has precedence over the
+`index.type`.
++
+By default unset.
++
+Example:
+----
+[gerrit]
+  installIndexModule = com.google.gerrit.elasticsearch.ElasticIndexModule
+----
++
 [[gerrit.installModule]]gerrit.installModule::
 +
 Repeatable list of class name of additional Guice modules to load at
@@ -3068,23 +3161,13 @@
 
 [[index.type]]index.type::
 +
-Type of secondary indexing employed by Gerrit.  The supported
-values are:
+*(DEPRECATED)* The only supported value is `LUCENE`, which is the default,
+that means a link:http://lucene.apache.org/[Lucene]
+index is used.
 +
-* `LUCENE`
+For using other indexing backends (e.g. ElasticSearch), refer to
+`gerrit.installIndexModule` setting.
 +
-A link:http://lucene.apache.org/[Lucene] index is used.
-+
-+
-* `ELASTICSEARCH` look into link:#elasticsearch[Elasticsearch section]
-+
-An link:https://www.elastic.co/products/elasticsearch[Elasticsearch,role=external,window=_blank] index is
-used. Refer to the link:#elasticsearch[Elasticsearch section] for further
-configuration details.
-
-+
-By default, `LUCENE`.
-
 [[index.threads]]index.threads::
 +
 Number of threads to use for indexing in normal interactive operations. Setting
@@ -3120,11 +3203,6 @@
 limit will truncate the list (but will still set `_more_changes` on
 result lists). Set to 0 for no limit.
 +
-When `index.type` is set to `ELASTICSEARCH`, this value should not exceed
-the `index.max_result_window` value configured on the Elasticsearch
-server. If a value is not configured during site initialization, defaults to
-10000, which is the default value of `index.max_result_window` in Elasticsearch.
-+
 When `index.type` is set to `LUCENE`, defaults to no limit.
 
 [[index.maxPages]]index.maxPages::
@@ -3193,7 +3271,7 @@
 +
 Whether the scheduled indexer is enabled. If the scheduled indexer is
 disabled you must implement other means to keep the group index for the
-replica up-to-date (e.g. by using ElasticSearch for the indexes).
+replica up-to-date.
 +
 Defaults to `true`.
 
@@ -3326,95 +3404,6 @@
 
 ----
 
-[[elasticsearch]]
-=== Section elasticsearch
-
-WARNING: Support for Elasticsearch is still experimental and is not recommended
-for production use. For compatibility information, please refer to the
-link:https://www.gerritcodereview.com/elasticsearch.html[project homepage,role=external,window=_blank].
-
-Note that when Gerrit is configured to use Elasticsearch, the Elasticsearch
-server(s) must be reachable during the site initialization.
-
-[[elasticsearch.prefix]]elasticsearch.prefix::
-+
-This setting can be used to prefix index names to allow multiple Gerrit
-instances in a single Elasticsearch cluster. Prefix `gerrit1_` would result in a
-change index named `gerrit1_changes_0001`.
-+
-Not set by default.
-
-[[elasticsearch.server]]elasticsearch.server::
-+
-Elasticsearch server URI in the form `http[s]://hostname:port`. The `port` is
-optional and defaults to `9200` if not specified.
-+
-At least one server must be specified. May be specified multiple times to
-configure multiple Elasticsearch servers.
-+
-Note that the site initialization program only allows to configure a single
-server. To configure multiple servers the `gerrit.config` file must be edited
-manually.
-
-[[elasticsearch.numberOfShards]]elasticsearch.numberOfShards::
-+
-Sets the number of shards to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#_static_index_settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 1.
-
-[[elasticsearch.numberOfReplicas]]elasticsearch.numberOfReplicas::
-+
-Sets the number of replicas to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-Defaults to 1.
-
-[[elasticsearch.maxResultWindow]]elasticsearch.maxResultWindow::
-+
-Sets the maximum value of `from + size` for searches to use per index. Refer to the
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#dynamic-index-settings[
-Elasticsearch documentation,role=external,window=_blank] for details.
-+
-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.
-Note that the same username and password are used for all servers.
-
-For further information about Elasticsearch security, please refer to
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/security-getting-started.html[the documentation,role=external,window=_blank].
-This is the current documentation link. Select another Elasticsearch version
-from the dropdown menu available on that page if need be.
-
-[[elasticsearch.username]]elasticsearch.username::
-+
-Username used to connect to Elasticsearch.
-+
-If a password is set, defaults to `elastic`, otherwise not set by default.
-
-[[elasticsearch.password]]elasticsearch.password::
-+
-Password used to connect to Elasticsearch.
-+
-Not set by default.
-
 [[event]]
 === Section event
 
@@ -4298,9 +4287,29 @@
 be specified using standard time unit abbreviations ('ms', 'sec',
 'min', etc.).
 +
+After the timeout is exceeded the task processing the receive gets a
+cancellation signal that allows the tast to finish gracefully.
+link:#receive.cancellationTimeout[receive.cancellationTimeout]
+defines how much time the task has to react to the cancellation signal
+before it is focefully cancelled.
++
+The receive timeout cannot be overriden by setting a higher
+link:user-upload.html#deadline[deadline] on the git push request.
++
 Default is 4 minutes. If no unit is specified, milliseconds
 is assumed.
 
+[[receive.cancellationTimeout]]receive.cancellationTimeout::
++
+Defines the time that a receive task has to react to a cancellation
+signal and finish gracefully after link:#receive.timeout[receive.timeout]
+is exceeded. If the receive task is still not terminated after the
+cancellation timeout is exceeded the task is forcefully cancelled.
+Values can be specified using standard time unit abbreviations ('ms',
+'sec', 'min', etc.).
++
+Default is 5 seconds. If no unit is specified, milliseconds is assumed.
+
 [[receive.trustedKey]]receive.trustedKey::
 +
 List of GPG key fingerprints that should be considered trust roots by
@@ -4421,8 +4430,7 @@
 [[retry.retryWithTraceOnFailure]]retry.retryWithTraceOnFailure::
 +
 Whether Gerrit should automatically retry operations on failure with tracing
-enabled. The automatically generated traces can help with debugging. Please
-note that only some of the REST endpoints support automatic retry.
+enabled. The automatically generated traces can help with debugging.
 +
 By default this is set to false.
 
@@ -5262,6 +5270,18 @@
 +
 By default, true.
 
+[[tracing.exportPerformanceMetrics]]tracing.exportPerformanceMetrics::
++
+Whether to export performance metrics.
++
+Performace logged when link:#tracing.performanceLogging[`performanceLogging`] is
+enabled, can be exported as metrics.
++
+NOTE: Since the payload returned could be of tens of thousands metrics,
+assess the latency of the metrics endpoint before enabling this option.
++
+By default, false.
+
 [[tracing.traceid]]
 ==== Subsection tracing.<trace-id>
 
@@ -5283,13 +5303,27 @@
 [[tracing.traceid.requestUriPattern]]tracing.<trace-id>.requestUriPattern::
 +
 Regular expression to match request URIs for which request tracing
-should be always enabled. Request URIs are only available for REST
-requests. Request URIs never include the '/a' prefix.
+should be enabled except if they match
+link:tracing.traceid.excludedRequestUriPattern[excludedRequestUriPattern].
+Request URIs are only available for REST requests. Request URIs never include
+the '/a' prefix.
 +
 May be specified multiple times.
 +
 By default, unset (all request URIs are matched).
 
+[[tracing.traceid.excludedRequestUriPattern]]tracing.<trace-id>.excludedRequestUriPattern::
++
+Regular expression to match request URIs for which request tracing
+should not be enabled even if they match
+link:#tracing.traceid.requestUriPattern[requestUriPattern].
+Request URIs are only available for REST requests. Request URIs never include
+the '/a' prefix.
++
+May be specified multiple times.
++
+By default, unset (no request URIs are excluded).
+
 [[tracing.traceid.account]]tracing.<trace-id>.account::
 +
 Account ID of an account for which request tracing should be always
@@ -5308,6 +5342,91 @@
 +
 By default, unset (all projects are matched).
 
+[[deadline.id]]
+==== Subsection deadline.<id>
+
+There can be multiple `deadline.<id>` subsections to configure deadlines for
+request executions. For a deadline to apply all conditions of the
+`deadline.<id>` subsection must match. The subsection name is the ID of the
+deadline configuration and allows to track back an applied deadline to its
+configuration.
+
+Clients can override the deadlines that are configured here by setting a
+deadline on the request.
+
+Deadlines are only supported for `REST`, `SSH` and `GIT_RECEIVE` requests, but
+not for `GIT_UPLOAD` requests.
+
+[[deadline.id.timeout]]deadline.<id>.timeout::
++
+Timeout after which matching requests should be cancelled.
++
+Values must be specified using standard time unit abbreviations ('ms', 'sec',
+'min', etc.).
++
+For some requests additional timeout configurations may apply, e.g.
+link:#receive.timeout[receive.timeout] for git pushes.
++
+By default, unset.
+
+[[deadline.id.isAdvisory]]deadline.<id>.isAdvisory::
++
+Whether this deadline is an advisory deadline. Advisory deadlines do not cause
+requests to be aborted when they are exceeded. Instead, if an advisory deadline
+is exceeded, only the `cancellation/advisory_deadline_count` metrics is
+incremented and a log is written. This is useful to test how many requests would
+be affected by a new deadline configuration.
++
+By default, `false`.
+
+[[deadline.id.requestType]]deadline.<id>.requestType::
++
+Type of request to which the deadline applies (can be `GIT_RECEIVE`, `REST` and
+`SSH`).
++
+May be specified multiple times.
++
+By default, unset (all request types are matched).
+
+[[deadline.id.requestUriPattern]]deadline.<id>.requestUriPattern::
++
+Regular expression to match request URIs to which the deadline applies except if
+they match
+link:#deadline.id.excludedRequestUriPattern[excludedRequestUriPattern]. Request
+URIs are only available for REST requests. Request URIs never include the '/a'
+prefix.
++
+May be specified multiple times.
++
+By default, unset (all request URIs are matched).
+
+[[deadline.id.excludedRequestUriPattern]]deadline.<id>.excludedRequestUriPattern::
++
+Regular expression to match request URIs to which the deadline should not be
+applied even if they match
+link:#deadline.id.requestUriPattern[requestUriPattern]. Request URIs are only
+available for REST requests. Request URIs never include the '/a' prefix.
++
+May be specified multiple times.
++
+By default, unset (no request URIs are excluded).
+
+[[deadline.id.account]]deadline.<id>.account::
++
+Account ID of an account to which the deadline applies.
++
+May be specified multiple times.
++
+By default, unset (all accounts are matched).
+
+[[deadline.id.projectPattern]]deadline.<id>.projectPattern::
++
+Regular expression to match project names to which the deadline applies.
++
+May be specified multiple times.
++
+By default, unset (all projects are matched).
+
 [[trackingid]]
 === Section trackingid
 
@@ -5481,7 +5600,7 @@
 Email address that Gerrit refers to itself as when it creates a
 new Git commit, such as a merge commit during change submission.
 +
-If not set, Gerrit generates this as "gerrit@`hostname`", where
+If not set, Gerrit generates this as "gerrit@``hostname``", where
 `hostname` is the hostname of the system Gerrit is running on.
 +
 By default, not set, generating the value at startup.
diff --git a/Documentation/config-labels.txt b/Documentation/config-labels.txt
index 9d3446e..5889c75 100644
--- a/Documentation/config-labels.txt
+++ b/Documentation/config-labels.txt
@@ -268,6 +268,58 @@
 If true, any score for the label is copied forward when a new patch
 set is uploaded. Defaults to false.
 
+[[label_copyCondition]]
+=== `label.Label-Name.copyCondition`
+
+If set, Gerrit matches patch set approvals against the provided query
+string. If the query matches, the approval is copied from one patch set
+to the next. The query language is the same as for
+link:user-search.html[other queries].
+
+This logic is triggered whenever a new patch set is uploaded.
+
+Gerrit currently supports the following predicates:
+
+==== changekind:{REWORK,TRIVIAL_REBASE,MERGE_FIRST_PARENT_UPDATE,NO_CODE_CHANGE,NO_CHANGE}
+
+Matches if the diff between two patch sets was of a certain change kind.
+
+==== is:{MIN,MAX,ANY}
+
+Matches votes that are equal to the minimal or maximal voting range. Or any votes.
+
+==== approverin:link:rest-api-groups.html#group-id[\{group-id\}]
+
+Matches votes granted by a user who is a member of
+link:rest-api-groups.html#group-id[\{group-id\}].
+
+Avoid using a group name with spaces (if it has spaces, use the group uuid).
+Although supported for convenience, it's better to use group uuid than group
+name since using names only works as long as the names are unique (and future
+groups with the same name will break the query).
+
+==== uploaderin:link:rest-api-groups.html#group-id[\{group-id\}]
+
+Matches votes where the new patch set was uploaded by a member of
+link:rest-api-groups.html#group-id[\{group-id\}].
+
+Avoid using a group name with spaces (if it has spaces, use the group uuid).
+Although supported for convenience, it's better to use group uuid than group
+name since using names only works as long as the names are unique (and future
+groups with the same name will break the query).
+
+==== has:unchanged-files
+
+Matches when the new patch-set includes the same files as the old patch-set.
+
+Only 'unchanged-files' is supported for 'has'.
+
+==== Example
+
+----
+copyCondition = is:MIN OR -change-kind:REWORK OR uploaderin:dead...beef
+----
+
 [[label_copyMinScore]]
 === `label.Label-Name.copyMinScore`
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index a01df50..4dff685 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -165,21 +165,21 @@
 a commit for review that doesn't contain a Change-Id in the commit
 message fails with link:error-missing-changeid.html[missing Change-Id
 in commit message footer].
-
++
 It is recommended to set this option and use a
 link:user-changeid.html#create[commit-msg hook] (or other client side
 tooling like EGit) to automatically generate Change-Id's for new
 commits. This way the Change-Id is automatically in place when changes
 are reworked or rebased and uploading new patch sets gets easy.
-
++
 If this option is not set, commits can be uploaded without a Change-Id,
 but then users have to remember to copy the assigned Change-Id from the
 change screen and insert it manually into the commit message when they
 want to upload a second patch set.
-
++
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. The global default for new hosts is `true`
-
++
 This option is deprecated and future releases will behave as if this
 is always `true`.
 
@@ -262,18 +262,18 @@
 
 [[receive.createNewChangeForAllNotInTarget]]receive.createNewChangeForAllNotInTarget::
 +
-The `create-new-change-for-all-not-in-target` option provides a
-convenience for selecting link:user-upload.html#base[the merge base]
-by setting it automatically to the target branch's tip so you can
-create new changes for all commits not in the target branch.
-
+This option provides a convenience for selecting
+link:user-upload.html#base[the merge base] by setting it automatically
+to the target branch's tip so you can create new changes for all
+commits not in the target branch.
++
 This option is disabled if the tip of the push is a merge commit.
-
++
 This option also only works if there are no merge commits in the
 commit chain, in such cases it fails warning the user that such
 pushes can only be performed by manually specifying
 link:user-upload.html#base[bases]
-
++
 This option is useful if you want to push a change to your personal
 branch first and for review to another branch for example. Or in cases
 where a commit is already merged into a branch and you want to create
@@ -494,9 +494,9 @@
 names in this section defines the branch order. The topmost is considered to be
 the least stable branch (typically the master branch) and the last one the
 most stable (typically the last maintained release branch).
-
++
 Example:
-
++
 ----
 [branchOrder]
   branch = master
@@ -504,13 +504,13 @@
   branch = stable-2.8
   branch = stable-2.7
 ----
-
++
 The `branchOrder` section is inheritable. This is useful when multiple or all
 projects follow the same branch rules. A `branchOrder` section in a child
 project completely overrides any `branchOrder` section from a parent i.e. there
 is no merging of `branchOrder` sections. A present but empty `branchOrder`
 section removes all inherited branch order.
-
++
 Branches not listed in this section will not be included in the mergeability
 check. If the `branchOrder` section is not defined then the mergeability of a
 change into other branches will not be done.
@@ -525,9 +525,9 @@
 +
 A boolean indicating if reviewers and CCs that do not currently have a Gerrit
 account can be added to a change by providing their email address.
-
++
 This setting only takes affect for changes that are readable by anonymous users.
-
++
 Default is `INHERIT`, which means that this property is inherited from
 the parent project. If the property is not set in any parent project, the
 default value is `FALSE`.
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
index f5185a4..04309e5 100644
--- a/Documentation/config-robot-comments.txt
+++ b/Documentation/config-robot-comments.txt
@@ -13,9 +13,8 @@
 It is planned to visualize robot comments differently in the web UI so
 that they can be easily distinguished from human comments. Users should
 also be able to use filtering on robot comments, so that only part of
-the robot comments or no robot comments are shown. In addition it is
-planned that robot comments can contain fixes, that users can apply by
-a single click.
+the robot comments or no robot comments are shown. In addition robot
+comments can contain fixes, that users can apply by a single click.
 
 == REST endpoints
 
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index c05d3f4..e6a118e 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -348,12 +348,6 @@
   bazel test --test_tag_filters=-flaky //...
 ----
 
-To exclude tests that require a Docker host:
-
-----
-  bazel test --test_tag_filters=-docker //...
-----
-
 To exclude tests that require very recent git client version:
 
 ----
@@ -372,13 +366,16 @@
   bazel test --test_tag_filters=api,git //...
 ----
 
+To run the tests against a specific index backend (LUCENE, FAKE):
+----
+  bazel test --test_env=GERRIT_INDEX_TYPE=LUCENE //...
+----
+
 The following values are currently supported for the group name:
 
 * annotation
 * api
-* docker
 * edit
-* elastic
 * git
 * git-protocol-v2
 * git-upload-archive
@@ -411,23 +408,6 @@
 Now attach with a debugger to the port `5005`. For example use "Remote Java Application" launch
 configuration in Eclipe and specify the port `5005`.
 
-[[elasticsearch]]
-=== Elasticsearch
-
-Successfully running the Elasticsearch tests requires Docker, and
-may require setting the local virtual memory on
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/vm-max-map-count.html[linux,role=external,window=_blank] and
-link:https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#_set_vm_max_map_count_to_at_least_262144[macOS,role=external,window=_blank].
-
-On macOS, if using link:https://docs.docker.com/docker-for-mac/[Docker Desktop,role=external,window=_blank],
-the effective memory value can be set in the Preferences, under the Advanced tab.
-The default value usually does not suffice and is causing premature container exits.
-That default is currently 2 GB and should be set to at least 5 (GB).
-
-If Docker is not available, the Elasticsearch tests will be skipped.
-Note that Bazel currently does not show
-link:https://github.com/bazelbuild/bazel/issues/3476[the skipped tests,role=external,window=_blank].
-
 [[logging]]
 === Controlling logging level
 
diff --git a/Documentation/dev-contributing.txt b/Documentation/dev-contributing.txt
index 01857da..fcc8b7e 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -174,7 +174,8 @@
 While the design doc is still in review, contributors may already start
 with the implementation (e.g. do some prototyping to demonstrate parts
 of the proposed design), but those changes should not be submitted
-while the design wasn't approved yet.
+while the design wasn't approved yet. Another way to demonstrate the
+design can be to add screenshots or the like, early enough in the doc.
 
 By approving a design, the steering committee commits to:
 
diff --git a/Documentation/dev-crafting-changes.txt b/Documentation/dev-crafting-changes.txt
index 15bf785..ac0780d 100644
--- a/Documentation/dev-crafting-changes.txt
+++ b/Documentation/dev-crafting-changes.txt
@@ -26,7 +26,9 @@
 * Improvements of existing features should also generally go into
   `master`. But we understand that if you cannot run `master`, it
   might take a while until you could benefit from it. In that case,
-  start on the newest `stable-*` branch that you can run.
+  implement the feature on master and, if you really need it on an
+  earlier `stable-*` branch, cherry-pick the change and build
+  Gerrit on your own environent.
 * Bug-fixes should generally at least cover the oldest affected and
   still supported version. If you're affected and run an even older
   version, you're welcome to upload to that older version, even if
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3d0d6f9..eb94ef7 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2188,6 +2188,8 @@
 DiffWebLinks will appear in the side-by-side and unified diff screen in
 the header next to the navigation icons.
 
+EditWebLinks will appear in the top-right part of the file diff page.
+
 ProjectWebLinks will appear in the project list in the
 `Repository Browser` column.
 
@@ -2266,8 +2268,7 @@
 DropWizard Metrics,role=external,window=_blank].
 
 Metric reporting plugin implementations are provided for
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank],
-link:https://gerrit.googlesource.com/plugins/metrics-reporter-elasticsearch/[Elastic Search,role=external,window=_blank],
+link:https://gerrit.googlesource.com/plugins/metrics-reporter-jmx/[JMX,role=external,window=_blank]
 and link:https://gerrit.googlesource.com/plugins/metrics-reporter-graphite/[Graphite,role=external,window=_blank].
 
 There is also a working example of reporting metrics to the console in the
@@ -2418,38 +2419,15 @@
 
 If neither resource `Documentation/index.html` or
 `Documentation/index.md` exists in the plugin JAR, Gerrit will
-automatically generate an index page for the plugin's documentation
-tree by scanning every `*.md` and `*.html` file in the Documentation/
-directory.
+automatically generate an index page.
 
-For any discovered Markdown (`*.md`) file, Gerrit will parse the
-header of the file and extract the first level one title. This
-title text will be used as display text for a link to the HTML
-version of the page.
+The generated index page contains 3 sections:
 
-For any discovered HTML (`*.html`) file, Gerrit will use the name
-of the file, minus the `*.html` extension, as the link text. Any
-hyphens in the file name will be replaced with spaces.
-
-If a discovered file is named `about.md` or `about.html`, its
-content will be inserted in an 'About' section at the top of the
-auto-generated index page.  If both `about.md` and `about.html`
-exist, only the first discovered file will be used.
-
-If a discovered file name beings with `cmd-` it will be clustered
-into a 'Commands' section of the generated index page.
-
-If a discovered file name beings with `servlet-` it will be clustered
-into a 'Servlets' section of the generated index page.
-
-If a discovered file name beings with `rest-api-` it will be clustered
-into a 'REST APIs' section of the generated index page.
-
-All other files are clustered under a 'Documentation' section.
-
+1. Manifest section
++
 Some optional information from the manifest is extracted and
 displayed as part of the index page, if present in the manifest:
-
++
 [width="40%",options="header"]
 |===================================================
 |Field       | Source Attribute
@@ -2460,6 +2438,49 @@
 |API Version | Gerrit-ApiVersion
 |===================================================
 
+2. About section
++
+If an `about.md` or `about.html` file exists, its content will be inserted in an
+'About' section.
++
+If both `about.md` and `about.html` exist, only the first discovered file will
+be used.
+
+3. TOC section
++
+If a `toc.md` or `toc.html` file exists, its content will be inserted in a
+'Documentation' section.
++
+`toc.md` or `toc.html` is a manually maintained index of the documentation pages
+that exist in the plugin. Having a manually maintained index has the advantage
+that you can group the documentation pages by topic and sort them by importance.
++
+If both `toc.md` and `toc.html` exist, only the first discovered file will
+be used.
++
+If no `toc` file is present the TOC section is automatically generated by
+scanning every `\*.md` and `*.html` file in the `Documentation/` directory.
++
+For any discovered Markdown (`*.md`) file, Gerrit will parse the
+header of the file and extract the first level one title. This
+title text will be used as display text for a link to the HTML
+version of the page.
++
+For any discovered HTML (`\*.html`) file, Gerrit will use the name
+of the file, minus the `*.html` extension, as the link text. Any
+hyphens in the file name will be replaced with spaces.
++
+If a discovered file name beings with `cmd-` it will be clustered
+into a 'Commands' section of the generated index page.
++
+If a discovered file name beings with `servlet-` it will be clustered
+into a 'Servlets' section of the generated index page.
++
+If a discovered file name beings with `rest-api-` it will be clustered
+into a 'REST APIs' section of the generated index page.
++
+All other files are clustered under a 'Documentation' section.
+
 [[deployment]]
 == Deployment
 
@@ -2553,6 +2574,26 @@
 }
 ----
 
+
+[[account-tag]]
+== Account Tag Plugins
+
+Gerrit provides an extension point that enables Plugins to supply additional
+tags on an account.
+
+[source, java]
+----
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.AccountTagProvider;
+import java.util.List;
+
+public class MyPlugin implements AccountTagProvider {
+  public List<String> getTags(Account.Id id) {
+    // Implement your logic here
+  }
+}
+----
+
 [[ssh-command-creation-interception]]
 == SSH Command Creation Interception
 
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 2748413..f045ab8 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -13,12 +13,21 @@
 
 ----
   git clone --recurse-submodules https://gerrit.googlesource.com/gerrit
-  cd gerrit
 ----
 
 The `--recurse-submodules` option is needed on `git clone` to ensure that the
 core plugins, which are included as git submodules, are also cloned.
 
+Next setup the commit-hook. This is necessary to ensure that each commit has a
+`Change-Id`.
+
+----
+  cd gerrit && (
+    cd .git/hooks
+    ln -s ../../resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+  )
+----
+
 === Switching between branches
 
 When using `git checkout` without `--recurse-submodules` to switch between
diff --git a/Documentation/dev-release.txt b/Documentation/dev-release.txt
index a7240e2..0849c56 100644
--- a/Documentation/dev-release.txt
+++ b/Documentation/dev-release.txt
@@ -153,7 +153,7 @@
 Tag the plugins:
 
 ----
-  git submodule foreach '[ "$path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
+  git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git tag -s -m "v$version" "v$version"'
 ----
 
 [[build-gerrit]]
@@ -324,7 +324,7 @@
 Push the new Release Tag on the plugins:
 
 ----
-  git submodule foreach git push gerrit-review tag v$version
+  git submodule foreach '[ "$sm_path" == "modules/jgit" ] || git push gerrit-review tag "v$version"'
 ----
 
 [[upload-documentation]]
diff --git a/Documentation/dev-stars.txt b/Documentation/dev-stars.txt
index a83ad44..764e326 100644
--- a/Documentation/dev-stars.txt
+++ b/Documentation/dev-stars.txt
@@ -29,9 +29,6 @@
 There are link:rest-api-accounts.html#default-star-endpoints[
 additional REST endpoints] for the link:#default-star[default star].
 
-Only the link:#default-star[default star] is shown in the WebUI and
-can be updated from there. Other stars do not show up in the WebUI.
-
 [[default-star]]
 == Default Star
 
@@ -61,36 +58,11 @@
 
 The ignore star is represented by the special star label 'ignore'.
 
-[[reviewed-star]]
-== Reviewed Star
-
-If the "reviewed/<patchset_id>"-star is set by a user, and <patchset_id>
-matches the current patch set, the change is always reported as "reviewed"
-in the ChangeInfo.
-
-This allows users to "de-highlight" changes in a dashboard until a new
-patchset has been uploaded.
-
-[[unreviewed-star]]
-== Unreviewed Star
-
-If the "unreviewed/<patchset_id>"-star is set by a user, and <patchset_id>
-matches the current patch set, the change is always reported as "unreviewed"
-in the ChangeInfo.
-
-This allows users to "highlight" changes in a dashboard.
-
 [[query-stars]]
 == Query Stars
 
 There are several query operators to find changes with stars:
 
-* link:user-search.html#star[star:<LABEL>]:
-  Matches any change that was starred by the current user with the
-  label `<LABEL>`.
-* link:user-search.html#has-stars[has:stars]:
-  Matches any change that was starred by the current user with any
-  label.
 * link:user-search.html#is-starred[is:starred] /
   link:user-search.html#has-star[has:star]:
   Matches any change that was starred by the current user with the
diff --git a/Documentation/externalid-case-insensitivity.txt b/Documentation/externalid-case-insensitivity.txt
new file mode 100644
index 0000000..b4e8140
--- /dev/null
+++ b/Documentation/externalid-case-insensitivity.txt
@@ -0,0 +1,128 @@
+:linkattrs:
+= Gerrit Code Review - ExternalId case insensitivity
+
+Gerrit usernames are case insensitive by default: e.g. johndoe and JohnDoe
+represents the same account. However, for installations older than v3.5.x,
+the usernames were case sensitive, e.g. johndoe and JohnDoe can both exist
+as separate accounts. This could lead to issues when migrating an account
+from LDAP to an internal account, if ldap.localUsernameToLowerCase was set.
+Such usernames can also be rather confusing for users, if they try to identify
+authors of comments or changes.
+
+When Gerrit handles case insensitive usernames (external IDs using the
+`gerrit:` or `username:` scheme, their external IDs SHA-1 is always computed
+using the lowercase external ID, hence there cannot be any account differing
+only in the capitalization of their usernames.
+
+Gerrit installations older than v3.5.x that are switching to the case-insensitive
+username need to migrating all their existing accounts SHA-1s.
+
+[[migration]]
+== Migration
+
+Migrating external ID notes can take several minutes for large sites(for example
+migration ~45000 accounts can take up to five minutes), so administrators choose
+whether to do the migration offline or online, depending on their available
+resources and tolerance for downtime.
+
+NOTE: Migration is required only on Gerrit primary instances.
+
+[[offline-migration]]
+=== Offline
+
+To run the offline migration execute following steps:
+* Stop all Gerrit primary instances
+* Set the `auth.userNameCaseInsensitive` to false
+----
+[auth]
+  userNameCaseInsensitive = false
+----
+
+* Run:
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+--
+
+See: link:pgm-ChangeExternalIdCaseSensitivity.html
+
+* During the migration `auth.userNameCaseInsensitive` will be set to true
+on a node which is executing the migration. When the migration is finished,
+on all other primary nodes set `auth.userNameCaseInsensitive` to true
+* Start all Gerrit primary instances
+
+[[online-migration]]
+=== Online
+
+To start the online migration, set the `auth.userNameCaseInsensitive` and
+`auth.userNameCaseInsensitiveMigrationMode` options in `gerrit.config` and
+restart Gerrit:
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = true
+----
+* Trigger online migration:
+----
+$ ssh -p <port> <host> gerrit migrate-externalids-to-insensitive
+----
+
+See: link:cmd-migrate-externalids-to-insensitive.html
+
+[online-ha-migration]
+== Online migration for high-availability setup
+
+To start the online migration with a setup containing multiple primary
+instances execute following steps:
+* On all Gerrit primary instances set `auth.userNameCaseInsensitive` and
+`auth.userNameCaseInsensitiveMigrationMode` and perform a rolling restart
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = true
+----
+* Trigger online migration:
+----
+$ ssh -p <port> <host> gerrit migrate-externalids-to-insensitive
+----
+
+See: link:cmd-migrate-externalids-to-insensitive.html
+
+* When the migration is finished, on all other primary nodes set
+`auth.userNameCaseInsensitiveMigrationMode` to false and perform a
+rolling restart
+----
+[auth]
+  userNameCaseInsensitive = true
+  userNameCaseInsensitiveMigrationMode = false
+----
+
+== External ID case insensitivity rollback
+
+The offline migration tool allows to calculate external ID notes named with the SHA-1
+from the case sensitive external ID.
+
+To rollback external ID notes migration execute following steps:
+* Stop all Gerrit primary instances
+* Set the `auth.userNameCaseInsensitive` to true
+----
+[auth]
+  userNameCaseInsensitive = true
+----
+
+* Run:
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+--
+
+See: link:pgm-ChangeExternalIdCaseSensitivity.html
+
+* During the migration `auth.userNameCaseInsensitive` will be set to false
+on a node which is executing the migration. When the migration is finished,
+on all other primary nodes set `auth.userNameCaseInsensitive` to false
+* Start all Gerrit primary instances
diff --git a/Documentation/glossary.txt b/Documentation/glossary.txt
index 2b40b5b..83362ab 100644
--- a/Documentation/glossary.txt
+++ b/Documentation/glossary.txt
@@ -1,6 +1,12 @@
 :linkattrs:
 = Glossary
 
+[[cluster]]
+== Cluster
+A Gerrit Cluster is a set of Gerrit processes sharing the same
+link:config-gerrit.html#gerrit.serverId[ServerId] and associated to the same
+set of repositories, accounts, and groups.
+
 [[event]]
 == Event
 
@@ -32,6 +38,45 @@
 API for listening to Gerrit events from plugins, without having any
 visibility restrictions.
 
+[[multi-primary]]
+== Multi-primary
+Multi-primary typically refers to configurations where multiple Gerrit primary
+processes are running in one or more xref:cluster[clusters] together.
+
+=== Single cluster multi-primary with shared storage
+A variation of multi-primary (a.k.a. HA or high-availability) that shares a file
+storage volume for the git repositories. These configurations can use the
+link:https://gerrit.googlesource.com/plugins/high-availability[high-availability plugin]
+to synchronize or share caches, indexes, events, and web sessions. The
+replication plugin also
+link:https://gerrit.googlesource.com/plugins/replication/+/refs/heads/master/src/main/resources/Documentation/config.md#configuring-cluster-replication[supports]
+synchronizing events using a shared file storage volume.
+
+[[multi-cluster-multi-primary]]
+=== Multiple clusters multi-primary
+Multi-cluster (aka multi-site) primaries typically refers to configurations
+where multiple Gerrit primary processes are running in different (likely
+geographically distributed) clusters (sites). This also typically makes use of
+a multi-primary configuration within each cluster. Synchronization across sites
+is necessary to detect and prevent split-brain scenarios. These configurations
+can use the link:https://gerrit.googlesource.com/plugins/multi-site[multi-site plugin]
+to facilitate synchronization.
+
+[[primary]]
+== Primary
+A Gerrit primary is the link:pgm-daemon.html[main Gerrit process] permitting
+write operations by clients. Most installations of Gerrit have only a single
+Gerrit primary running at a time for their service.
+
+[[replica]]
+== Replica
+A Gerrit process running with the link:pgm-daemon.html[--replica switch]
+provided. This permits read-only git operations by clients. There is no REST
+API, WebUI, or search operation available. Replicas can be run in
+the same cluster with primaries (likely sharing the storage volume) or in other
+clusters/sites (likely facilitated by the
+link:https://gerrit.googlesource.com/plugins/replication[replication plugin]).
+
 [[stream-events]]
 == Stream events
 
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
deleted file mode 100644
index 69a28ec..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-info-cannot-merge.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-info.png b/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
deleted file mode 100644
index e92b49d..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-info.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-change-update.png b/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
deleted file mode 100644
index 227db40..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-change-update.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
deleted file mode 100644
index 097637e..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-commit-info-merge-commit.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png b/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
deleted file mode 100644
index fe0c1d1..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-commit-info.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-history.png b/Documentation/images/gwt-user-review-ui-change-screen-history.png
deleted file mode 100644
index 3fe71d8..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-history.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png b/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
deleted file mode 100644
index ad30fe2..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-included-in-list.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
deleted file mode 100644
index a10f40a..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-inline-comments.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-not-current.png b/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
deleted file mode 100644
index 9a87c67..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-not-current.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-permalink.png b/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
deleted file mode 100644
index a1aede9..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-permalink.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
deleted file mode 100644
index 120b99c..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-plugin-extensions.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png b/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
deleted file mode 100644
index 07bd8a2..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-reply-to-comment.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-reply.png b/Documentation/images/gwt-user-review-ui-change-screen-reply.png
deleted file mode 100644
index 20837ea..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-reply.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-change-screen-replying.png b/Documentation/images/gwt-user-review-ui-change-screen-replying.png
deleted file mode 100644
index 0ae85ab..0000000
--- a/Documentation/images/gwt-user-review-ui-change-screen-replying.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
deleted file mode 100644
index 6de9e75..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
deleted file mode 100644
index b349d0d..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
deleted file mode 100644
index 011f986..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
deleted file mode 100644
index 2ecc47e..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-comment.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
deleted file mode 100644
index 598d18d..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-commented.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png
deleted file mode 100644
index 36f1360..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
deleted file mode 100644
index 6f63f0e4..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-keyboard-shortcuts.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
deleted file mode 100644
index 8146b76..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
deleted file mode 100644
index 5d721a6..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png
deleted file mode 100644
index 9bdd4a9..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
deleted file mode 100644
index 836964b..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
deleted file mode 100644
index b4d83ba..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-rename.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
deleted file mode 100644
index 918cdee..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png b/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
deleted file mode 100644
index d76ecef..0000000
--- a/Documentation/images/gwt-user-review-ui-side-by-side-diff-screen.png
+++ /dev/null
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-change-update.png b/Documentation/images/user-review-ui-change-screen-change-update.png
new file mode 100644
index 0000000..fe07ef9
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-change-update.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-plugin-extensions.png b/Documentation/images/user-review-ui-change-screen-plugin-extensions.png
new file mode 100644
index 0000000..5d6fee7
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-plugin-extensions.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-change-screen-reply.png b/Documentation/images/user-review-ui-change-screen-reply.png
new file mode 100644
index 0000000..1c50fc5
--- /dev/null
+++ b/Documentation/images/user-review-ui-change-screen-reply.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
new file mode 100644
index 0000000..047034c
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-inline-comments.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png
new file mode 100644
index 0000000..edbbccb
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-patch-sets.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png
new file mode 100644
index 0000000..0281362e
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-rename.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png b/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png
new file mode 100644
index 0000000..a72011b
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen-replied-done.png
Binary files differ
diff --git a/Documentation/images/user-review-ui-side-by-side-diff-screen.png b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
new file mode 100644
index 0000000..74d02e3
--- /dev/null
+++ b/Documentation/images/user-review-ui-side-by-side-diff-screen.png
Binary files differ
diff --git a/Documentation/index.txt b/Documentation/index.txt
index dc94b14..782a6a9 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -77,6 +77,7 @@
 . link:pgm-index.html[Server Side Administrative Tools]
 . link:repository-maintenance.html[Repository Maintenance]
 . link:user-request-tracing.html[Request Tracing]
+. link:user-request-cancellation-and-deadlines.html[Request Cancellation and Deadlines]
 . link:note-db.html[NoteDb]
 . link:config-accounts.html[Accounts on NoteDb]
 . link:config-groups.html[Groups on NoteDb]
diff --git a/Documentation/intro-gerrit-walkthrough-github.txt b/Documentation/intro-gerrit-walkthrough-github.txt
index 8f3ff88..173f709 100644
--- a/Documentation/intro-gerrit-walkthrough-github.txt
+++ b/Documentation/intro-gerrit-walkthrough-github.txt
@@ -25,7 +25,7 @@
 Here’s how getting code reviewed and submitted with Gerrit is different from
 doing the same with GitHub:
 
-* You need the add a commit-msg hook script when you clone a repo for the first
+* You need to add a commit-msg hook script when you clone a repo for the first
 time using a snippet you can find e.g. https://gerrit-review.googlesource.com/admin/repos/gerrit[here,role=external,window=_blank];
 * Your review will be on a single commit instead of a branch. You use
 `git commit --amend` to modify a code change.
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index dac1c6b..3f23385 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -686,6 +686,49 @@
 It is also possible to link:user-inline-edit.html#create-change[create
 new changes inline].
 
+[[roles]]
+== Roles
+
+Making and reviewing changes usually involves multiple users that
+assume different roles:
+
+- Author:
++
+The person who wrote the code change. Recorded as author in the Git
+commit.
+
+- Committer:
++
+The person who created the Git commit, e.g. the person that executed
+the `git commit` command. Recorded as committer in the Git commit.
+
+- Uploader:
++
+The user that uploaded the commit as a patch set to Gerrit, e.g. the
+user that executed the `git push` command. For commits that are created through
+an action in the web UI the uploader is the user that triggered the action (e.g.
+if a commit is created by clicking on the `REBASE` button, the user clicking on
+the button becomes the uploader of the newly created commit).
++
+The uploader of the first patch set is the change owner.
++
+The uploader of the latest patch set, the user that uploaded the
+current patch set, is relevant when
+link:config-labels.html#label_ignoreSelfApproval[self approvals on labels are
+ignored], as in this case approvals from the uploader of the latest patch set
+are ignored.
+
+- Change Owner:
++
+The user that created the change, e.g. uploaded the first patch set.
+
+- Reviewer:
++
+A user that has reviewed the change or has been asked to review the change.
+
+Often one user assumes several of these roles, but it's possible that each role
+is assumed by a different user.
+
 [[project-administration]]
 == Project Administration
 
diff --git a/Documentation/js_licenses.txt b/Documentation/js_licenses.txt
index e611ff8..ab79c8f 100644
--- a/Documentation/js_licenses.txt
+++ b/Documentation/js_licenses.txt
@@ -250,7 +250,9 @@
 [[DefinitelyTyped]]
 DefinitelyTyped
 
+* @types/resemblejs
 * @types/resize-observer-browser
+* @types/trusted-types
 
 [[DefinitelyTyped_license]]
 ----
@@ -279,6 +281,48 @@
 ----
 
 
+[[Lit]]
+Lit
+
+* @lit/reactive-element
+* lit
+* lit-element
+* lit-html
+
+[[Lit_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017 Google LLC. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. 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.
+
+3. 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.
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
@@ -367,6 +411,7 @@
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
 * @polymer/paper-dropdown-menu
+* @polymer/paper-fab
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
@@ -563,32 +608,65 @@
 
 [[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
+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.
 
 ----
 
 
+[[codemirror-minified]]
+codemirror-minified
+
+* codemirror-minified
+
+[[codemirror-minified_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
+Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -1042,6 +1120,38 @@
 ----
 
 
+[[immer]]
+immer
+
+* immer
+
+[[immer_license]]
+----
+MIT License
+
+Copyright (c) 2017 Michel Weststrate
+
+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.
+
+----
+
+
 [[isarray]]
 isarray
 
@@ -1074,84 +1184,6 @@
 ----
 
 
-[[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.
-
-----
-
-
 [[page]]
 page
 
@@ -1217,6 +1249,35 @@
 ----
 
 
+[[resemblejs]]
+resemblejs
+
+* resemblejs
+
+[[resemblejs_license]]
+----
+The MIT License (MIT) Copyright © 2013 Huddle

+

+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
 
diff --git a/Documentation/licenses.txt b/Documentation/licenses.txt
index 0df8415..685e73b 100644
--- a/Documentation/licenses.txt
+++ b/Documentation/licenses.txt
@@ -62,11 +62,8 @@
 * guice:guice-library
 * guice:guice-servlet
 * guice:javax_inject
-* httpcomponents:httpasyncclient
 * httpcomponents:httpclient
 * httpcomponents:httpcore
-* httpcomponents:httpcore-nio
-* jackson:jackson-core
 * jetty:http
 * jetty:io
 * jetty:jmx
@@ -1114,22 +1111,6 @@
 ----
 
 
-[[elasticsearch]]
-elasticsearch
-
-* elasticsearch-rest-client:elasticsearch-rest-client
-
-[[elasticsearch_license]]
-----
-Elasticsearch
-Copyright 2009-2015 Elasticsearch
-
-This product includes software developed by The Apache Software
-Foundation (http://www.apache.org/).
-
-----
-
-
 [[flexmark]]
 flexmark
 
@@ -3209,7 +3190,9 @@
 [[DefinitelyTyped]]
 DefinitelyTyped
 
+* @types/resemblejs
 * @types/resize-observer-browser
+* @types/trusted-types
 
 [[DefinitelyTyped_license]]
 ----
@@ -3238,6 +3221,48 @@
 ----
 
 
+[[Lit]]
+Lit
+
+* @lit/reactive-element
+* lit
+* lit-element
+* lit-html
+
+[[Lit_license]]
+----
+BSD 3-Clause License
+
+Copyright (c) 2017 Google LLC. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. 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.
+
+3. 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.
+
+----
+
+
 [[Polymer-2014]]
 Polymer-2014
 
@@ -3326,6 +3351,7 @@
 * @polymer/paper-dialog-behavior
 * @polymer/paper-dialog-scrollable
 * @polymer/paper-dropdown-menu
+* @polymer/paper-fab
 * @polymer/paper-icon-button
 * @polymer/paper-input
 * @polymer/paper-item
@@ -3522,32 +3548,65 @@
 
 [[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
+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.
 
 ----
 
 
+[[codemirror-minified]]
+codemirror-minified
+
+* codemirror-minified
+
+[[codemirror-minified_license]]
+----
+The MIT License (MIT)
+
+Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
+Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.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.
+
+----
+
+
 [[font-roboto-local-fonts-roboto]]
 font-roboto-local-fonts-roboto
 
@@ -4001,6 +4060,38 @@
 ----
 
 
+[[immer]]
+immer
+
+* immer
+
+[[immer_license]]
+----
+MIT License
+
+Copyright (c) 2017 Michel Weststrate
+
+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.
+
+----
+
+
 [[isarray]]
 isarray
 
@@ -4033,84 +4124,6 @@
 ----
 
 
-[[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.
-
-----
-
-
 [[page]]
 page
 
@@ -4176,6 +4189,35 @@
 ----
 
 
+[[resemblejs]]
+resemblejs
+
+* resemblejs
+
+[[resemblejs_license]]
+----
+The MIT License (MIT) Copyright © 2013 Huddle

+

+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
 
diff --git a/Documentation/linux-quickstart.txt b/Documentation/linux-quickstart.txt
index e34071f..c45de05 100644
--- a/Documentation/linux-quickstart.txt
+++ b/Documentation/linux-quickstart.txt
@@ -104,7 +104,7 @@
 Now that you have a simple version of Gerrit running, use the installation to
 explore the user interface and learn about Gerrit. For more detailed
 installation instructions, see
-link:[Standalone Daemon Installation Guide](install.html).
+link:install.html[Standalone Daemon Installation Guide].
 
 GERRIT
 ------
diff --git a/Documentation/metrics.txt b/Documentation/metrics.txt
index 7ac804c..49ac84c 100644
--- a/Documentation/metrics.txt
+++ b/Documentation/metrics.txt
@@ -12,26 +12,116 @@
 
 * `build/label`: Version of Gerrit server software.
 * `events`: Triggered events.
+** `type`:
+   The type of the event.
 
 === Actions
 
 * `action/retry_attempt_count`: Number of retry attempts made
-by RetryHelper to execute an action (0 == single attempt, no retry)
+  by RetryHelper to execute an action (0 == single attempt, no retry)
+** `action_type`:
+   The type of the action that was retried.
+** `operation_name`:
+   The name of the operation that was retried.
+** `cause`:
+   The original cause that triggered the retry.
 * `action/retry_timeout_count`: Number of action executions of RetryHelper
-that ultimately timed out
+  that ultimately timed out
+** `action_type`:
+   The type of the action that was retried.
+** `operation_name`:
+   The name of the operation that was retried.
+** `cause`:
+   The original cause that triggered the retry.
 * `action/auto_retry_count`: Number of automatic retries with tracing
+** `action_type`:
+   The type of the action that was retried.
+** `operation_name`:
+   The name of the operation that was retried.
+** `cause`:
+   The cause for the retry.
 * `action/failures_on_auto_retry_count`: Number of failures on auto retry
+** `action_type`:
+   The type of the action that was retried.
+** `operation_name`:
+   The name of the operation that was retried.
+** `cause`:
+   The cause for the retry.
+
+[[cancellations]]
+=== Cancellations
+
+* `cancellation/advisory_deadline_count`: Exceeded advisory deadlines by request
+** `request_type`:
+   The type of the request to which the advisory deadline applied.
+** `request_uri`:
+   The redacted URI of the request to which the advisory deadline applied (only
+   set for request_type = REST).
+** `deadline_id`:
+   The ID of the advisory deadline.
+* `cancellation/cancelled_requests_count`: Number of request cancellations by
+  request
+** `request_type`:
+   The type of the request that was cancelled.
+** `request_uri`:
+   The redacted URI of the request that was cancelled (only set for
+   request_type = REST).
+** `cancellation_reason`:
+   The reason why the request was cancelled.
+* `cancellation/receive_timeout_count`: Number of requests that are cancelled
+  because link:config.html#receive.timeout[receive.timout] is exceeded
+** `cancellation_type`:
+   The cancellation type (graceful or forceful).
+
+[[performance]]
+=== Performance
+
+* `performance/operations`: Latency of performing operations
+** `operation_name`:
+   The operation that was performed.
+** `request`:
+   The request for which the operation was performed (format = '<request-type>
+   <redacted-request-uri>').
+** `plugin`:
+   The name of the plugin that performed the operation.
+* `performance/operations_count`: Number of performed operations
+** `operation_name`:
+   The operation that was performed.
+** `request`:
+   The request for which the operation was performed (format = '<request-type>
+   <redacted-request-uri>').
+** `plugin`:
+   The name of the plugin that performed the operation.
+
+Performance metrics can be enabled via the
+link:config.gerrit.html#tracing.exportPerformanceMetrics[`tracing.exportPerformanceMetrics`]
+setting.
 
 === Pushes
 
-* `receivecommits/changes`: histogram of number of changes processed
-in a single upload, split up by update type (change created/updated,
-change autoclosed).
-* `receivecommits/latency`: latency per change for processing a push,
-split up by update type (create+replace, and autoclose)
-* `receivecommits/push_latency`: total latency for processing a push,
-split up by update type (create+replace, autoclose, normal)
-* `receivecommits/timeout`: number of timeouts during push processing.
+* `receivecommits/changes`: histogram of number of changes processed in a single
+   upload
+** `type`:
+   type of push (create/replace, autoclose)
+* `receivecommits/latency_per_push`: processing delay for a processing single
+  push
+** `type`:
+   type of push (create/replace, autoclose, normal)
+* `receivecommits/latency_per_push_per_change`: Processing delay per push
+  divided by the number of changes in said push. (Only includes pushes which
+  contain changes.)
+** `type`:
+   type of push (create/replace, autoclose, normal)
+* `receivecommits/timeout`: rate of push timeouts
+* `receivecommits/ps_revision_missing`: errors due to patch set revision missing
+* `receivecommits/push_count`: number of pushes
+** `kind`:
+   The push kind (direct vs. magic).
+** `project`:
+   The name of the project for which the push is done.
+** `type`:
+   The type of the update (CREATE, UPDATE, CREATE/UPDATE, UPDATE_NONFASTFORWARD,
+   DELETE).
 
 === Process
 
@@ -49,25 +139,58 @@
 * `proc/jvm/memory/object_pending_finalization_count`: Approximate number of
 objects needing finalization.
 * `proc/jvm/gc/count`: Number of GCs.
+** `gc_name`:
+   The name of the garbage collector.
 * `proc/jvm/gc/time`: Approximate accumulated GC elapsed time.
-* `proc/jvm/memory/pool/committed/<pool name>`: Committed amount of memory for pool.
-* `proc/jvm/memory/pool/max/<pool name>`: Maximum amount of memory for pool.
-* `proc/jvm/memory/pool/used/<pool name>`: Used amount of memory for pool.
+** `gc_name`:
+   The name of the garbage collector.
+* `proc/jvm/memory/pool/committed`: Committed amount of memory for pool.
+** `pool_name`:
+   The name of the memory pool.
+* `proc/jvm/memory/pool/max`: Maximum amount of memory for pool.
+** `pool_name`:
+   The name of the memory pool.
+* `proc/jvm/memory/pool/used`: Used amount of memory for pool.
+** `pool_name`:
+   The name of the memory pool.
 * `proc/jvm/thread/num_live`: Current live thread count.
 * `proc/jvm/thread/num_daemon_live`: Current live daemon threads count.
-* `proc/jvm/thread/num_peak_live`: Peak live thread count since the Java virtual machine started or peak was reset.
-* `proc/jvm/thread/num_total_started`: Total number of threads created and also started since the Java virtual machine started.
-* `proc/jvm/thread/num_deadlocked_threads`: Number of threads that are deadlocked waiting for object monitors or ownable synchronizers.
-   If deadlocks waiting for ownable synchronizers can be monitored depends on the capabilities of the used JVM.
+* `proc/jvm/thread/num_peak_live`: Peak live thread count since the Java virtual
+  machine started or peak was reset.
+* `proc/jvm/thread/num_total_started`: Total number of threads created and also
+  started since the Java virtual machine started.
+* `proc/jvm/thread/num_deadlocked_threads`: Number of threads that are
+  deadlocked waiting for object monitors or ownable synchronizers.
+  If deadlocks waiting for ownable synchronizers can be monitored depends on the
+  capabilities of the used JVM.
 
 === Caches
 
 * `caches/memory_cached`: Memory entries.
+** `cache_name`:
+   The name of the cache.
 * `caches/memory_hit_ratio`: Memory hit ratio.
+** `cache_name`:
+   The name of the cache.
 * `caches/memory_eviction_count`: Memory eviction count.
+** `cache_name`:
+   The name of the cache.
 * `caches/disk_cached`: Disk entries used by persistent cache.
+** `cache_name`:
+   The name of the cache.
 * `caches/disk_hit_ratio`: Disk hit ratio for persistent cache.
-* `caches/refresh_count`: The number of refreshes per cache with an indicator if a reload was necessary.
+** `cache_name`:
+   The name of the cache.
+* `caches/refresh_count`: The number of refreshes per cache with an indicator if
+  a reload was necessary.
+** `cache`:
+   The name of the cache.
+** `outdated`:
+   Whether the cache entry was outdated on reload.
+* `caches/diff/timeouts`: The number of git file diff computations that resulted
+  in timeouts.
+* `caches/diff/legacy/timeouts`: The number of git file diff computations (using
+  the legacy cache) that resulted in timeouts.
 
 Cache disk metrics are expensive to compute on larger installations and are not
 computed by default. They can be enabled via the
@@ -76,65 +199,110 @@
 
 === Change
 
-* `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.
+* `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.
+* `change/post_review/draft_handling`: Total number of draft handling option
+  (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) selected by users while posting a
+  review.
+** `type`:
+  The type of the draft handling option (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS).
 
 === 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.
+* `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
 
-* `http/server/jetty/connections/connections`: The current number of open connections
-* `http/server/jetty/connections/connections_total`: The total number of connections opened
-* `http/server/jetty/connections/connections_duration_max`: The max duration of a connection in ms
-* `http/server/jetty/connections/connections_duration_mean`: The mean duration of a connection in ms
-* `http/server/jetty/connections/connections_duration_stdev`: The standard deviation of the duration of a connection in ms
-* `http/server/jetty/connections/received_messages`: The total number of messages received
-* `http/server/jetty/connections/sent_messages`: The total number of messages sent
-* `http/server/jetty/connections/received_bytes`: Total number of bytes received by tracked connections
-* `http/server/jetty/connections/sent_bytes`: Total number of bytes sent by tracked connections"
+* `http/server/jetty/connections/connections`: The current number of open
+  connections
+* `http/server/jetty/connections/connections_total`: The total number of
+  connections opened
+* `http/server/jetty/connections/connections_duration_max`: The max duration of
+  a connection in ms
+* `http/server/jetty/connections/connections_duration_mean`: The mean duration
+  of a connection in ms
+* `http/server/jetty/connections/connections_duration_stdev`: The standard
+  deviation of the duration of a connection in ms
+* `http/server/jetty/connections/received_messages`: The total number of
+  messages received
+* `http/server/jetty/connections/sent_messages`: The total number of messages
+  sent
+* `http/server/jetty/connections/received_bytes`: Total number of bytes received
+  by tracked connections
+* `http/server/jetty/connections/sent_bytes`: Total number of bytes sent by
+  tracked connections
 * `http/server/jetty/threadpool/active_threads`: Active threads
 * `http/server/jetty/threadpool/idle_threads`: Idle threads
 * `http/server/jetty/threadpool/reserved_threads`: Reserved threads
 * `http/server/jetty/threadpool/max_pool_size`: Maximum thread pool size
 * `http/server/jetty/threadpool/min_pool_size`: Minimum thread pool size
 * `http/server/jetty/threadpool/pool_size`: Current thread pool size
-* `http/server/jetty/threadpool/queue_size`: Queued requests waiting for a thread
+* `http/server/jetty/threadpool/queue_size`: Queued requests waiting for a
+  thread
+* `http/server/jetty/threadpool/is_low_on_threads`: Whether thread pool is low
+  on threads
 
 ==== LDAP
 
 * `ldap/login_latency`: Latency of logins.
 * `ldap/user_search_latency`: Latency for searching the user account.
-* `ldap/group_search_latency`: Latency for querying the group memberships of an account.
+* `ldap/group_search_latency`: Latency for querying the group memberships of an
+  account.
 * `ldap/group_expansion_latency`: Latency for expanding nested groups.
 
 ==== REST API
 
 * `http/server/error_count`: Rate of REST API error responses.
+** `status`:
+   HTTP status code
 * `http/server/success_count`: Rate of REST API success responses.
+** `status`:
+   HTTP status code
 * `http/server/rest_api/count`: Rate of REST API calls by view.
+** `view`:
+   view implementation class
 * `http/server/rest_api/change_id_type`: Rate of REST API calls by change ID type.
+** `change_id_type`:
+   The type of the change identifier.
 * `http/server/rest_api/error_count`: Rate of REST API calls by view.
+** `view`:
+   view implementation class
+** `error_code`:
+   HTTP status code
+** `cause`:
+   The cause of the error.
 * `http/server/rest_api/server_latency`: REST API call latency by view.
+** `view`:
+   view implementation class
 * `http/server/rest_api/response_bytes`: Size of REST API response on network
-(may be gzip compressed) by view.
+  (may be gzip compressed) by view.
+** `view`:
+   view implementation class
 * `http/server/rest_api/change_json/to_change_info_latency`: Latency for
-toChangeInfo invocations in ChangeJson.
+  toChangeInfo invocations in ChangeJson.
 * `http/server/rest_api/change_json/to_change_infos_latency`: Latency for
-toChangeInfos invocations in ChangeJson.
+  toChangeInfos invocations in ChangeJson.
 * `http/server/rest_api/change_json/format_query_results_latency`: Latency for
-formatQueryResults invocations in ChangeJson.
-* `http/server/rest_api/ui_actions/latency`: Latency for RestView#getDescription calls.
+  formatQueryResults invocations in ChangeJson.
+* `http/server/rest_api/ui_actions/latency`: Latency for RestView#getDescription
+  calls.
+** `view`:
+   view implementation class
 
 === Query
 
 * `query/query_latency`: Successful query latency, accumulated over the life
-of the process.
+  of the process.
+** `index`: index name
 
 === Core Queues
 
@@ -153,11 +321,15 @@
 Each queue provides the following metrics:
 
 * `queue/<queue_name>/pool_size`: Current number of threads in the pool
-* `queue/<queue_name>/max_pool_size`: Maximum allowed number of threads in the pool
-* `queue/<queue_name>/active_threads`: Number of threads that are actively executing tasks
+* `queue/<queue_name>/max_pool_size`: Maximum allowed number of threads in the
+  pool
+* `queue/<queue_name>/active_threads`: Number of threads that are actively
+  executing tasks
 * `queue/<queue_name>/scheduled_tasks`: Number of scheduled tasks in the queue
-* `queue/<queue_name>/total_scheduled_tasks_count`: Total number of tasks that have been scheduled
-* `queue/<queue_name>/total_completed_tasks_count`: Total number of tasks that have completed execution
+* `queue/<queue_name>/total_scheduled_tasks_count`: Total number of tasks that
+  have been scheduled
+* `queue/<queue_name>/total_completed_tasks_count`: Total number of tasks that
+  have completed execution
 
 === SSH sessions
 
@@ -169,7 +341,7 @@
 
 * `topic/cross_project_submit`: number of cross-project topic submissions.
 * `topic/cross_project_submit_completed`: number of cross-project
-topic submissions that concluded successfully.
+  topic submissions that concluded successfully.
 
 === JGit
 
@@ -186,23 +358,34 @@
 * `load_success_count` : Successful cache loads for JGit block cache.
 * `miss_count` : Cache misses for JGit block cache.
 * `miss_ratio` : Cache miss ratio for JGit block cache.
-* `cache_used_per_repository` : Bytes of memory retained per repository for the top N repositories
-having most data in the cache. The number N of reported repositories is limited to 1000.
+* `cache_used_per_repository` : Bytes of memory retained per repository for the
+  top N repositories having most data in the cache. The number N of reported
+  repositories is limited to 1000.
+** `repository_name`: The name of the repository.
 
 === Git
 
 * `git/upload-pack/request_count`: Total number of git-upload-pack requests.
+** `operation`:
+   The name of the operation (CLONE, FETCH).
 * `git/upload-pack/phase_counting`: Time spent in the 'Counting...' phase.
+** `operation`:
+   The name of the operation (CLONE, FETCH).
 * `git/upload-pack/phase_compressing`: Time spent in the 'Compressing...' phase.
+** `operation`:
+   The name of the operation (CLONE, FETCH).
 * `git/upload-pack/phase_writing`: Time spent transferring bytes to client.
+** `operation`:
+   The name of the operation (CLONE, FETCH).
 * `git/upload-pack/pack_bytes`: Distribution of sizes of packs sent to clients.
+** `operation`:
+   The name of the operation (CLONE, FETCH).
 * `git/auto-merge/num_operations`: Number of auto merge operations and context.
+** `operation`:
+   The type of the operation (CACHE_LOAD, IN_MEMORY_WRITE, ON_DISK_WRITE).
 * `git/auto-merge/latency`: Latency of auto merge operations and context.
-
-=== BatchUpdate
-
-* `batch_update/execute_change_ops`: BatchUpdate change update latency,
-excluding reindexing
+** `operation`:
+   The type of the operation (CACHE_LOAD, IN_MEMORY_WRITE, ON_DISK_WRITE).
 
 === NoteDb
 
@@ -212,43 +395,63 @@
 * `notedb/parse_latency`: NoteDb parse latency for changes.
 * `notedb/external_id_cache_load_count`: Total number of times the external ID
   cache loader was called.
-* `notedb/external_id_partial_read_latency`: Latency for generating a new external ID
-  cache state from a prior state.
+** `partial`:
+   Whether the reload was partial.
+* `notedb/external_id_partial_read_latency`: Latency for generating a new
+  external ID cache state from a prior state.
 * `notedb/external_id_update_count`: Total number of external ID updates.
 * `notedb/read_all_external_ids_latency`: Latency for reading all
-external ID's from NoteDb.
+  external ID's from NoteDb.
 * `notedb/read_single_account_config_latency`: Latency for reading a single
-account config from NoteDb.
+  account config from NoteDb.
 * `notedb/read_single_external_id_latency`: Latency for reading a single
-external ID from NoteDb.
+  external ID from NoteDb.
 
 === Permissions
 
-* `permissions/permission_collection/filter_latency`: Latency to filter access sections
-by user and ref.
+* `permissions/permission_collection/filter_latency`: Latency for access filter
+  computations in PermissionCollection
 * `permissions/ref_filter/full_filter_count`: Rate of full ref filter operations
-* `permissions/ref_filter/skip_filter_count`: Rate of ref filter operations where
-we skip full evaluation because the user can read all refs
+* `permissions/ref_filter/skip_filter_count`: Rate of ref filter operations
+  where we skip full evaluation because the user can read all refs
 
 === Reviewer Suggestion
 
 * `reviewer_suggestion/query_accounts`: Latency for querying accounts for
-reviewer suggestion.
+  reviewer suggestion.
 * `reviewer_suggestion/recommend_accounts`: Latency for recommending accounts
-for reviewer suggestion.
+  for reviewer suggestion.
 * `reviewer_suggestion/load_accounts`: Latency for loading accounts for
-reviewer suggestion.
+  reviewer suggestion.
 * `reviewer_suggestion/query_groups`: Latency for querying groups for reviewer
-suggestion.
+  suggestion.
+* `reviewer_suggestion/filter_visibility`: Latency for removing users that can't
+  see the change
 
 === Repo Sequences
 
 * `sequence/next_id_latency`: Latency of requesting IDs from repo sequences.
+** `sequence`:
+   The sequence from which IDs were retrieved.
+** `multiple`:
+   Whether more than one ID was retrieved.
 
 === Plugin
 
 * `plugin/latency`: Latency for plugin invocation.
+** `plugin_name`"
+   The name of the plugin.
+** `class`:
+   The class of the plugin that was invoked.
+** `export_value`:
+   The export name under which the invoked class is registered.
 * `plugin/error_count`: Number of plugin errors.
+** `plugin_name`"
+   The name of the plugin.
+** `class`:
+   The class of the plugin that was invoked.
+** `export_value`:
+   The export name under which the invoked class is registered.
 
 === Group
 
@@ -257,11 +460,19 @@
 === Replication Plugin
 
 * `plugins/replication/replication_latency`: Time spent pushing to remote
-destination.
+  destination.
+** `destination`: The destination of the replication.
 * `plugins/replication/replication_delay`: Time spent waiting before pushing to
-remote destination.
+  remote destination.
+** `destination`: The destination of the replication.
 * `plugins/replication/replication_retries`: Number of retries when pushing to
-remote destination.
+  remote destination.
+** `destination`: The destination of the replication.
+* `plugins/replication/latency_slower_than_threshold`: latency for project to
+  destination, where latency was slower than threshold
+** `slow_threshold`: The threshold.
+** `project`: The name of the project.
+** `destination`: The destination of the replication.
 
 === License
 
diff --git a/Documentation/note-db.txt b/Documentation/note-db.txt
index 7b436a9..0e1dfd0 100644
--- a/Documentation/note-db.txt
+++ b/Documentation/note-db.txt
@@ -36,6 +36,67 @@
 not available in 3.0, so any upgrade from Gerrit 2.x to 3.x must go through
 2.16 to effect the NoteDb upgrade.
 
+== Format
+
+Each review ("change") in Gerrit is numbered. The different revisions
+("patchsets") of a change 12345 are stored under
+----
+  refs/changes/45/12345/${PATCHSET_NUMBER}
+----
+
+The revisions are stored as commits to the main project, ie. if you
+fetch this ref, you can check out the proposed change.
+
+A change 12345 has its review metadata under
+----
+  refs/changes/45/12345/meta
+----
+The metadata is a notes branch. The commit messages on the branch hold
+modifications to global data of the change (votes, global comments). The inline
+comments are in a
+link:https://git.eclipse.org/r/plugins/gitiles/jgit/jgit/\+/master/org.eclipse.jgit/src/org/eclipse/jgit/notes/NoteMap.java[NoteMap],
+where the key is the commit SHA-1 of the patchset
+that the comment refers to, and the value is JSON data. The format of the
+JSON is in the
+link:https://gerrit.googlesource.com/gerrit/\+/master/java/com/google/gerrit/server/notedb/RevisionNoteData.java[RevisionNoteData]
+which contains 
+link:https://gerrit.googlesource.com/gerrit/\+/master/java/com/google/gerrit/entities/Comment.java[Comment] entities.
+
+For example:
+----
+   {
+      "key": {
+        "uuid": "c7be1334_47885e36",
+        "filename":
+"java/com/google/gerrit/server/restapi/project/CommitsCollection.java",
+        "patchSetId": 7
+      },
+      "lineNbr": 158,
+      "author": {
+        "id": 1026112
+      },
+      "writtenOn": "2019-11-06T09:00:50Z",
+      "side": 1,
+      "message": "nit: factor this out in a variable, use
+toImmutableList as collector",
+      "range": {
+        "startLine": 156,
+        "startChar": 32,
+        "endLine": 158,
+        "endChar": 66
+      },
+      "revId": "071c601d6ee1a2a9f520415fd9efef8e00f9cf60",
+      "serverId": "173816e5-2b9a-37c3-8a2e-48639d4f1153",
+      "unresolved": true
+    },
+----
+
+Automated systems may post "robot comments" instead of normal
+comments, which are an extension of the previous comment, defined in
+the
+link:https://gerrit.googlesource.com/gerrit/\+/master/java/com/google/gerrit/entities/RobotComment.java[RobotComment]
+class.
+
 [[migration]]
 == Migration
 
diff --git a/Documentation/pgm-ChangeExternalIdCaseSensitivity.txt b/Documentation/pgm-ChangeExternalIdCaseSensitivity.txt
new file mode 100644
index 0000000..1fb4b97
--- /dev/null
+++ b/Documentation/pgm-ChangeExternalIdCaseSensitivity.txt
@@ -0,0 +1,71 @@
+= ChangeExternalIdCaseSensitivity
+
+== NAME
+ChangeExternalIdCaseSensitivity - Convert `username` and `gerrit`
+external IDs to be handled case insensitively
+
+== SYNOPSIS
+[verse]
+--
+_java_ -jar gerrit.war _ChangeExternalIdCaseSensitivity_
+  -d <SITE_PATH>
+  [--batch]
+  [--dryrun]
+--
+
+== DESCRIPTION
+Convert `username` and `gerrit` external IDs to be handled case
+insensitively or case sensitively. This is done by recomputing
+the name of the note from the sha1 sum of the all lowercase
+external ID key or of the key with its original capitalization
+respectively.
+
+The tool uses the `auth.userNameCaseInsensitive` option to determine,
+whether the migration should be performed to case insensitive or case sensitive
+usernames, i.e. if the option is set to `false`, migration will be performed to
+make external IDs case insensitive and if set to `true` to case sensitive.
+
+== OPTIONS
+
+-d::
+--site-path::
+	Path of the Gerrit site
+
+--batch::
+    No user interaction is required. The tool won't ask for confirmation before migrating.
+
+--dryrun::
+    Whether to perform the conversion without persisting it.
+
+== CONTEXT
+This command can only be run offline with direct access to the server's
+site.
+
+== EXAMPLES
+To convert the external IDs to be case insensitive:
+
+----
+    $ git config -f $SITE/etc/gerrit.config --get auth.userNameCaseInsensitive
+    > false
+    $ java -jar gerrit.war ChangeExternalIdCaseSensitivity -d site_path
+----
+
+To convert the external IDs to be case sensitive again:
+
+----
+    $ git config -f $SITE/etc/gerrit.config --get auth.userNameCaseInsensitive
+    > true
+    $ java -jar gerrit.war ChangeExternalIdCaseSensitivity -d site_path
+----
+
+
+== SEE ALSO
+
+* Configuration parameter link:config-gerrit.html#auth.userNameCaseInsensitive[auth.userNameCaseInsensitive]
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/pgm-index.txt b/Documentation/pgm-index.txt
index dde0231..8f4cbda 100644
--- a/Documentation/pgm-index.txt
+++ b/Documentation/pgm-index.txt
@@ -38,6 +38,9 @@
 link:pgm-LocalUsernamesToLowerCase.html[LocalUsernamesToLowerCase]::
 	Convert the local username of every account to lower case.
 
+link:pgm-ChangeExternalIdCaseSensitivity.html[ChangeExternalIdCaseSensitivity]::
+    Convert external IDs to be case insensitive.
+
 link:pgm-MigrateAccountPatchReviewDb.html[MigrateAccountPatchReviewDb]::
 	Migrates AccountPatchReviewDb from one database backend to another.
 
diff --git a/Documentation/prolog-cookbook.txt b/Documentation/prolog-cookbook.txt
index 189ccfc..c083f28 100644
--- a/Documentation/prolog-cookbook.txt
+++ b/Documentation/prolog-cookbook.txt
@@ -23,6 +23,11 @@
 `rules.enable=false` in the Gerrit config file (see
 link:config-gerrit.html#_a_id_rules_a_section_rules[rules section])
 
+[NOTE]
+Gerrit's default submit rule is skipped if a project contains prolog rules.
+The prolog submit rules are responsible for returning the necessary labels in
+this case.
+
 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.
diff --git a/Documentation/repository-maintenance.txt b/Documentation/repository-maintenance.txt
index 1672436..4bf84b5 100644
--- a/Documentation/repository-maintenance.txt
+++ b/Documentation/repository-maintenance.txt
@@ -28,7 +28,7 @@
 
 Unlike a typical server database, access to Git repositories is not
 marshalled through a single process or a set of inter communicating
-processes. Unfortuntatlely the design of the on-disk layout of a Git
+processes. Unfortunately the design of the on-disk layout of a Git
 repository does not allow for 100% race free operations when accessed by
 multiple actors concurrently. These design shortcomings are more likely
 to impact the operations of busy repositories since racy conditions are
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 8aa3173..ae0c0a6 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -1342,6 +1342,8 @@
     "date_format": "STD",
     "time_format": "HHMM_12",
     "size_bar_in_change_table": true,
+    "disable_keyboard_shortcuts": true,
+    "disable_token_highlighting": true,
     "diff_view": "SIDE_BY_SIDE",
     "mute_common_path_prefixes": true,
     "my": [
@@ -1392,6 +1394,8 @@
     "size_bar_in_change_table": true,
     "diff_view": "SIDE_BY_SIDE",
     "publish_comments_on_push": true,
+    "disable_keyboard_shortcuts": true,
+    "disable_token_highlighting": true,
     "work_in_progress_by_default": true,
     "mute_common_path_prefixes": true,
     "my": [
@@ -1760,7 +1764,7 @@
   [
     {
       "identity": "username:john",
-      "email": "john.doe@example.com",
+      "email_address": "john.doe@example.com",
       "trusted": true
     }
   ]
@@ -1777,7 +1781,16 @@
 
 Only external ids belonging to the caller may be deleted. Users that have
 link:access-control.html#capability_modifyAccount[Modify Account] can delete
-external ids that belong to other accounts.
+external ids that belong to other accounts. External ids in the 'username:'
+scheme can only be deleted by users that have
+link:access-control.html#capability_administrateServer[Administrate Server]
+or both
+link:access-control.html#capability_maintainServer[Maintain Server] and
+link:access-control.html#capability__modifyAccount[Modify Account]
+since the user may not be able to login anymore, after the removal of the
+external id with scheme 'username:'. Users cannot delete their own external id
+with scheme 'username:' in order to prevent they can lock themselves out
+since they may not be able to login anymore.
 
 .Request
 ----
@@ -1999,7 +2012,7 @@
 --
 
 Star a change with the default label. Changes starred with the default
-label are returned for the search query `is:starred` or `starredby:USER`
+label are returned for the search query `is:starred` or `has:star`
 and automatically notify the user whenever updates are made to the
 change.
 
@@ -2031,131 +2044,6 @@
   HTTP/1.1 204 No Content
 ----
 
-[[star-endpoints]]
-== Star Endpoints
-
-[[get-starred-changes]]
-=== Get Starred Changes
---
-'GET /accounts/link:#account-id[\{account-id\}]/stars.changes'
---
-
-Gets the changes that were starred with any label by the identified
-user account. This URL endpoint is functionally identical to the
-changes query `GET /changes/?q=has:stars`. The result is a list of
-link:rest-api-changes.html#change-info[ChangeInfo] entities.
-
-.Request
-----
-  GET /a/accounts/self/stars.changes
-----
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    {
-      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
-      "project": "myProject",
-      "branch": "master",
-      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
-      "subject": "Implementing Feature X",
-      "status": "NEW",
-      "created": "2013-02-01 09:59:32.126000000",
-      "updated": "2013-02-21 11:16:36.775000000",
-      "stars": [
-        "ignore",
-        "risky"
-      ],
-      "mergeable": true,
-      "submittable": false,
-      "insertions": 145,
-      "deletions": 12,
-      "_number": 3965,
-      "owner": {
-        "name": "John Doe"
-      }
-    }
-  ]
-----
-
-[[get-stars]]
-=== Get Star Labels From Change
---
-'GET /accounts/link:#account-id[\{account-id\}]/stars.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
---
-
-Get star labels from a change.
-
-.Request
-----
-  GET /a/accounts/self/stars.changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
-----
-
-As response the star labels that the user applied on the change are
-returned. The labels are lexicographically sorted.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    "blue",
-    "green",
-    "red"
-  ]
-----
-
-[[set-stars]]
-=== Update Star Labels On Change
---
-'POST /accounts/link:#account-id[\{account-id\}]/stars.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
---
-
-Update star labels on a change. The star labels to be added/removed
-must be specified in the request body as link:#stars-input[StarsInput]
-entity. Starred changes are returned for the search query `has:stars`.
-
-.Request
-----
-  POST /a/accounts/self/stars.changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
-  Content-Type: application/json; charset=UTF-8
-
-  {
-    "add": [
-      "blue",
-      "red"
-    ],
-    "remove": [
-      "yellow"
-    ]
-  }
-----
-
-As response the star labels that the user applied on the change are
-returned. The labels are lexicographically sorted.
-
-.Response
-----
-  HTTP/1.1 200 OK
-  Content-Disposition: attachment
-  Content-Type: application/json; charset=UTF-8
-
-  )]}'
-  [
-    "blue",
-    "green",
-    "red"
-  ]
-----
-
 [[ids]]
 == IDs
 
@@ -2250,7 +2138,7 @@
 |============================
 |Field Name        ||Description
 |`identity`        ||The account external id.
-|`email`           |optional|The email address for the external id.
+|`email_address`   |optional|The email address for the external id.
 |`trusted`         |not set if `false`|
 Whether the external id is trusted.
 |`can_delete`      |not set if `false`|
@@ -2818,6 +2706,8 @@
 Allowed values are `AUTO_MERGE` and `FIRST_PARENT`.
 |`disable_keyboard_shortcuts`     |not set if `false`|
 Whether to disable all keyboard shortcuts.
+|`disable_token_highlighting`     [not set if `false`]
+Whether to disable token highlighting on hover.
 |`publish_comments_on_push`     |not set if `false`|
 Whether to link:user-upload.html#publish-comments[publish draft comments] on
 push by default.
@@ -2883,6 +2773,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.
 |============================================
 
 [[query-limit-info]]
@@ -2913,18 +2805,6 @@
 |`valid`         ||Whether the SSH key is valid.
 |=============================
 
-[[stars-input]]
-=== StarsInput
-The `StarsInput` entity contains star labels that should be added to
-or removed from a change.
-
-[options="header",cols="1,^1,5"]
-|========================
-|Field Name ||Description
-|`add`      |optional|List of labels to add to the change.
-|`remove`   |optional|List of labels to remove from the change.
-|========================
-
 [[username-input]]
 === UsernameInput
 The `UsernameInput` entity contains information for setting the
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 8da7a9d..da3018b 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -239,6 +239,12 @@
   current user.
 --
 
+[[submit-requirements]]
+--
+* `SUBMIT_REQUIREMENTS`: detailed result of the evaluated submit requirements
+  for this change.
+--
+
 [[current-revision]]
 --
 * `CURRENT_REVISION`: describe the current revision (patch set)
@@ -594,8 +600,9 @@
 ----
 
 As a response, two link:#change-info[ChangeInfo] entities are returned
-that describe information added and removed from the `old` change state.
-Only fields that differ between the change's two states are returned.
+that describe information added and removed from the `old` change state, and
+the two link:#change-info[ChangeInfo] entities that generated the diff are
+returned. Only fields that differ between the change's two states are returned.
 
 .Response
 ----
@@ -619,9 +626,55 @@
       "topic": "new-topic"
     },
     "removed": {
-      "updated": "2013-02-20 12:05:34.111000000",
+      "updated": "2013-02-01 09:59:32.126000000",
       "topic": "old-topic"
-    }
+    },
+    "old_change_info": {
+      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "project": "myProject",
+      "branch": "master",
+      "attention_set": [],
+      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "subject": "Implementing Feature X",
+      "status": "NEW",
+      "topic": "old-topic",
+      "created": "2013-02-01 09:59:32.126000000",
+      "updated": "2013-02-01 09:59:32.126000000",
+      "mergeable": true,
+      "insertions": 34,
+      "deletions": 101,
+      "_number": 3965,
+      "owner": {
+        "name": "John Doe"
+      }
+    },
+    "new_change_info": {
+      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "project": "myProject",
+      "branch": "master",
+      "attention_set": [
+        {
+          "account": {
+            "name": "John Doe"
+          },
+         "last_update": "2013-02-21 11:16:36.775000000",
+         "reason": "reviewer or cc replied"
+        }
+      ],
+      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "subject": "Implementing Feature X",
+      "status": "NEW",
+      "topic": "new-topic",
+      "created": "2013-02-01 09:59:32.126000000",
+      "updated": "2013-02-21 11:16:36.775000000",
+      "mergeable": true,
+      "insertions": 34,
+      "deletions": 101,
+      "_number": 3965,
+      "owner": {
+        "name": "John Doe"
+      }
+    },
   }
 ----
 
@@ -1741,6 +1794,18 @@
 As response a link:#change-info[ChangeInfo] entity is returned that
 describes the submitted/merged change.
 
+Submission may submit multiple changes, but we still only return one ChangeInfo
+object. To query for all submitted changes, please use the submission_id that is
+part of the response.
+
+Changes that will also be submitted:
+1. All changes of the same topic if
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+configuration is set to true.
+2. All dependent changes.
+3. The closure of the above (e.g if a dependent change has a topic, all changes
+of *that* topic will also be submitted).
+
 .Response
 ----
   HTTP/1.1 200 OK
@@ -2543,42 +2608,6 @@
   PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unignore HTTP/1.0
 ----
 
-[[mark-as-reviewed]]
-=== Mark as Reviewed
---
-'PUT /changes/link:#change-id[\{change-id\}]/reviewed'
---
-
-Marks a change as reviewed.
-
-This allows users to "de-highlight" changes in their dashboard until a new
-patch set is uploaded.
-
-This differs from the link:#ignore[ignore] endpoint, which will mute
-emails and hide the change from dashboard completely until it is
-link:#unignore[unignored] again.
-
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewed HTTP/1.0
-----
-
-[[mark-as-unreviewed]]
-=== Mark as Unreviewed
---
-'PUT /changes/link:#change-id[\{change-id\}]/unreviewed'
---
-
-Marks a change as unreviewed.
-
-This allows users to "highlight" changes in their dashboard
-
-.Request
-----
-  PUT /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/unreviewed HTTP/1.0
-----
-
 [[get-hashtags]]
 === Get Hashtags
 --
@@ -2789,6 +2818,53 @@
   }
 ----
 
+[[check-submit-requirement]]
+=== Check Submit Requirement
+--
+'POST /changes/link:#change-id[\{change-id\}]/check.submit_requirement'
+--
+
+Tests a submit requirement and returns the result as a
+link:#submit-requirement-result-info[SubmitRequirementResultInfo]. The request
+body must contain a link:#submit-requirement-input[SubmitRequirementInput].
+
+Note that this endpoint does not modify the change resource.
+
+.Request
+----
+  POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/check.submit_requirement HTTP/1.0
+  Content-Type: application/json; charset=UTF-8
+
+    {
+      "name": "Code-Review",
+      "submittability_expression": "label:Code-Review=+2"
+    }
+----
+
+As response a link:#submit-requirement-result-info[SubmitRequirementResultInfo]
+entity is returned that describes the submit requirement result.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "name": "Code-Review",
+    "status": "SATISFIED",
+    "submittability_expression_result": {
+      "expression": "label:Code-Review=+2",
+      "fulfilled": true,
+      "passingAtoms": [
+        "label:Code-Review=+2"
+      ]
+    },
+    "is_legacy": false
+  }
+----
+
 [[edit-endpoints]]
 == Change Edit Endpoints
 
@@ -3246,6 +3322,8 @@
 * are visible to the calling user
 * are not already reviewer on the change
 * don't own the change
+* are not service users (unless
+  link:config.html#suggest.skipServiceUsers[skipServiceUsers] is set to `false`)
 
 Groups can be excluded from the results by specifying the 'exclude-groups'
 request parameter:
@@ -3360,7 +3438,7 @@
   }
 ----
 
-As response an link:#add-reviewer-result[AddReviewerResult] entity is
+As response an link:#reviewer-result[ReviewerResult] entity is
 returned that describes the newly added reviewers.
 
 .Response
@@ -3573,6 +3651,9 @@
 Deletes a single vote from a change. Note, that even when the last vote of
 a reviewer is removed the reviewer itself is still listed on the change.
 
+If another user removed a user's vote, the user with the deleted vote will be
+added to the attention set.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -3804,8 +3885,8 @@
 }
 ----
 
-The response is a flat map of possible revision actions mapped to their
-link:#action-info[ActionInfo].
+The response is a flat map of possible revision REST endpoint names
+mapped to their link:#action-info[ActionInfo].
 
 [[get-review]]
 === Get Review
@@ -4163,7 +4244,7 @@
 Each element of the `reviewers` list is an instance of
 link:#reviewer-input[ReviewerInput]. The corresponding result of
 adding each reviewer will be returned in a map of inputs to
-link:#add-reviewer-result[AddReviewerResult]s.
+link:#reviewer-result[ReviewerResult]s.
 
 .Response
 ----
@@ -4403,14 +4484,27 @@
 --
 
 Submits a revision.
+Submitting a change also removes all users from the link:#attention-set[attention set].
 
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/submit HTTP/1.0
 ----
 
-As response a link:#submit-info[SubmitInfo] entity is returned that
-describes the status of the submitted change.
+As response a link:#change-info[ChangeInfo] entity is returned that
+describes the submitted/merged change.
+
+Submission may submit multiple changes, but we still only return one ChangeInfo
+object. To query for all submitted changes, please use the submission_id that is
+part of the response.
+
+Changes that will also be submitted:
+1. All changes of the same topic if
+link:config-gerrit.html#change.submitWholeTopic[`change.submitWholeTopic`]
+configuration is set to true.
+2. All dependent changes.
+3. The closure of the above (e.g if a dependent change has a topic, all changes
+of *that* topic will also be submitted).
 
 .Response
 ----
@@ -4420,7 +4514,19 @@
 
   )]}'
   {
-    "status": "MERGED"
+    "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "project": "myProject",
+    "branch": "master",
+    "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+    "subject": "Implementing Feature X",
+    "status": "MERGED",
+    "created": "2013-02-01 09:59:32.126000000",
+    "updated": "2013-02-21 11:16:36.775000000",
+    "submitted": "2013-02-21 11:16:36.615000000",
+    "_number": 3965,
+    "owner": {
+      "name": "John Doe"
+    }
   }
 ----
 
@@ -5800,6 +5906,11 @@
 link:#cherrypick-input[CherryPickInput] entity.  If the commit message
 does not specify a Change-Id, a new one is picked for the destination change.
 
+When cherry-picking a change into a branch that already contains the Change-Id
+that we want to cherry-pick, the cherry-pick will create a new patch-set on the
+destination's branch's appropriate Change-Id. If the change is closed on the
+destination branch, the cherry-pick will fail.
+
 .Request
 ----
   POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/cherrypick HTTP/1.0
@@ -5937,6 +6048,9 @@
 Note, that even when the last vote of a reviewer is removed the reviewer itself
 is still listed on the change.
 
+If another user removed a user's vote, the user with the deleted vote will be
+added to the attention set.
+
 .Request
 ----
   DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/reviewers/John%20Doe/votes/Code-Review HTTP/1.0
@@ -6115,6 +6229,9 @@
 * The change is marked ready for review.
 * As an owner/uploader, when someone replies on your change.
 * As a reviewer, when the owner/uploader replies.
+* When the user's vote is deleted by another user.
+* The rules above (except manually adding to the attention set) don't apply
+ for changes that are work in progress.
 
 Users are removed from the attention set if one the following apply:
 
@@ -6258,33 +6375,6 @@
 at the server or permissions are modified. Not present if false.
 |====================================
 
-[[add-reviewer-result]]
-=== AddReviewerResult
-The `AddReviewerResult` entity describes the result of adding a
-reviewer to a change.
-
-[options="header",cols="1,^1,5"]
-|===========================
-|Field Name    ||Description
-|`input`    ||
-Value of the `reviewer` field from link:#reviewer-input[ReviewerInput]
-set while adding the reviewer.
-|`reviewers`   |optional|
-The newly added reviewers as a list of link:#reviewer-info[
-ReviewerInfo] entities.
-|`ccs`         |optional|
-The newly CCed accounts as a list of link:#reviewer-info[
-ReviewerInfo] entities. This field will only appear if the requested
-`state` for the reviewer was `CC` *and* NoteDb is enabled on the
-server.
-|`error`       |optional|
-Error message explaining why the reviewer could not be added. +
-If a group was specified in the input and an error is returned, it
-means that none of the members were added as reviewer.
-|`confirm`     |`false` if not set|
-Whether adding the reviewer requires confirmation.
-|===========================
-
 [[approval-info]]
 === ApprovalInfo
 The `ApprovalInfo` entity contains information about an approval from a
@@ -6340,7 +6430,13 @@
 |Field Name    ||Description
 |`account`     || link:rest-api-accounts.html#account-info[AccountInfo] entity.
 |`last_update` || The link:rest-api.html#timestamp[timestamp] of the last update.
-|`reason`      || The reason of for adding or removing the user.
+|`reason`      ||
+The reason for adding or removing the user.
+If the update was caused by another user, that account is represented by
+account ID in reason as `<GERRIT_ACCOUNT_18419>` and the corresponding
+link:rest-api-accounts.html#account-info[AccountInfo] can be found in `reason_account` field.
+|`reason_account`      ||
+link:rest-api-accounts.html#account-info[AccountInfo] of the user who caused the update.
 
 |===========================
 [[attention-set-input]]
@@ -6429,8 +6525,7 @@
 The assignee of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
 |`hashtags`           |optional|
-List of hashtags that are set on the change (only populated when NoteDb
-is enabled).
+List of hashtags that are set on the change.
 |`change_id`          ||The Change-Id of the change.
 |`subject`            ||
 The subject of the change (header line of the commit message).
@@ -6477,7 +6572,9 @@
 |`unresolved_comment_count`  |optional|
 Number of unresolved inline comment threads across all patch sets. Not set if
 the current change index doesn't have the data.
-|`_number`            ||The legacy numeric ID of the change.
+|`_number`            ||
+The numeric ID of the change. (The underscore is just a relict of a prior
+attempt to deprecate the numeric ID.)
 |`owner`              ||
 The owner of the change as an link:rest-api-accounts.html#account-info[
 AccountInfo] entity.
@@ -6485,9 +6582,16 @@
 Actions the caller might be able to perform on this revision. The
 information is a map of view name to link:#action-info[ActionInfo]
 entities.
+|`submit_records`             ||
+List of the link:rest-api-changes.html#submit-record-info[SubmitRecordInfo]
+containing the submit records for the change at the latest patchset.
 |`requirements`             |optional|
 List of the link:rest-api-changes.html#requirement[requirements] to be met before this change
-can be submitted.
+can be submitted. This field is deprecated in favour of `submit_requirements`.
+|`submit_requirements`      |optional|
+List of the link:#submit-requirement-result-info[SubmitRequirementResultInfo]
+containing the evaluated submit requirements for the change.
+Only set if link:#submit-requirements[`SUBMIT_REQUIREMENTS`] is requested.
 |`labels`             |optional|
 The labels of the change as a map that maps the label names to
 link:#label-info[LabelInfo] entries. +
@@ -6622,6 +6726,14 @@
 |`new_branch`         |optional, default to `false`|
 Allow creating a new branch when set to `true`. Using this option is
 only possible for non-merge commits (if the `merge` field is not set).
+|`validation_options` |optional|
+Map with key-value pairs that are forwarded as options to the commit validation
+listeners (e.g. can be used to skip certain validations). Which validation
+options are supported depends on the installed commit validation listeners.
+Gerrit core doesn't support any validation options, but commit validation
+listeners that are implemented in plugins may. Please refer to the
+documentation of the installed plugins to learn whether they support validation
+options. Unknown validation options are silently ignored.
 |`merge`              |optional|
 The detail of a merge commit as a link:#merge-input[MergeInput] entity.
 If set, the target branch (see  `branch` field) must exist (it is not
@@ -6664,7 +6776,12 @@
 Set if the message was posted on behalf of another user.
 |`date`            ||
 The link:rest-api.html#timestamp[timestamp] this message was posted.
-|`message`            ||The text left by the user.
+|`message`            ||
+The text left by the user or Gerrit system. Accounts are served as account IDs
+inlined in the text as `<GERRIT_ACCOUNT_18419>`.
+All accounts, used in message, can be found in `accountsInMessage`
+field.
+|`accountsInMessage`            ||Accounts, used in `message`.
 |`tag`                 |optional|
 Value of the `tag` field from link:#review-input[ReviewInput] set
 while posting the review. Votes/comments that contain `tag` with
@@ -6893,7 +7010,10 @@
 The subject of the commit (header line of the commit message).
 |`message`     ||The commit message.
 |`web_links`   |optional|
-Links to the commit in external sites as a list of
+Links to the patch set in external sites as a list of
+link:#web-link-info[WebLinkInfo] entities.
+|`resolve_conflicts_web_links`   |optional|
+Links to the commit in external sites for resolving conflicts as a list of
 link:#web-link-info[WebLinkInfo] entities.
 |===========================
 
@@ -7069,6 +7189,9 @@
 |`web_links`       |optional|
 Links to the file diff in external sites as a list of
 link:rest-api-changes.html#diff-web-link-info[DiffWebLinkInfo] entries.
+|`edit_web_links`   |optional|
+Links to edit the file in external sites as a list of
+link:rest-api-changes.html#web-link-info[WebLinkInfo] entries.
 |`binary`          |not set if `false`|Whether the file is binary.
 |==========================
 
@@ -7278,7 +7401,8 @@
 [[included-in-info]]
 === IncludedInInfo
 The `IncludedInInfo` entity contains information about the branches a
-change was merged into and tags it was tagged with.
+change was merged into and tags it was tagged with. The branch or tag
+must have 'refs/head/' or 'refs/tags/' prefix respectively.
 
 [options="header",cols="1,^1,5"]
 |=======================
@@ -7670,8 +7794,8 @@
 RevertSubmission endpoint is `revert-{submission_id}-{timestamp.now}`.
 Topic can't contain quotation marks.
 |`work_in_progress` |optional|
-When present, change is marked as Work In Progress. This will also override
-the notify value to `OWNER`. +
+When present, change is marked as Work In Progress. The `notify` input is
+used if it's present, otherwise it will be overridden to `OWNER`. +
 If not set, the default is false.
 |=============================
 
@@ -7756,6 +7880,13 @@
 Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
 If not set, the default is `KEEP`. If `on_behalf_of` is set, then no other value
 besides `KEEP` is allowed.
+|`draft_ids_to_publish`                |optional|
+List of draft IDs to be published. The draft IDs should belong to the current
+user and be valid. If `drafts` is set to `PUBLISH`, then draft IDs should
+contain drafts for the same revision that is requested for review. If `drafts`
+is set to `KEEP`, then `draft_ids_to_publish` will be ignored and no draft
+comments will be published. +
+If not set, the default is to publish all drafts according to the `drafts` field.
 |`notify`                              |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
@@ -7773,7 +7904,7 @@
 have been granted `labelAs-NAME` permission for all keys of labels.
 |`reviewers`                           |optional|
 A list of link:rest-api-changes.html#reviewer-input[ReviewerInput]
-representing reviewers that should be added to the change.
+representing reviewers that should be added/removed to/from the change.
 |`ready`                               |optional|
 If true, and if the change is work in progress, then start review.
 It is an error for both `ready` and `work_in_progress` to be true.
@@ -7806,8 +7937,8 @@
 additions were rejected.
 |`reviewers`              |optional|
 Map of account or group identifier to
-link:rest-api-changes.html#add-reviewer-result[AddReviewerResult]
-representing the outcome of adding as a reviewer.
+link:rest-api-changes.html#reviewer-result[ReviewerResult]
+representing the outcome of adding/removing a reviewer.
 Absent if no reviewer additions were requested.
 |`ready`                  |optional|
 If true, the change was moved from WIP to ready for review as a result of this
@@ -7838,24 +7969,25 @@
 
 [[reviewer-input]]
 === ReviewerInput
-The `ReviewerInput` entity contains information for adding a reviewer
-to a change.
+The `ReviewerInput` entity contains information for adding or removing
+reviewers to/from the change.
 
 [options="header",cols="1,^1,5"]
 |=============================
 |Field Name      ||Description
 |`reviewer`      ||
 The link:rest-api-accounts.html#account-id[ID] of one account that
-should be added as reviewer or the link:rest-api-groups.html#group-id[
+should be added/removed as reviewer or the link:rest-api-groups.html#group-id[
 ID] of one internal group for which all members should be added as reviewers. +
 If an ID identifies both an account and a group, only the account is
 added as reviewer to the change.
 External groups, such as LDAP groups, will be silently omitted from a
 link:#set-review[set-review] or
-link:rest-api-changes.html#add-reviewer[add-reviewer] call.
+link:rest-api-changes.html#add-reviewer[add-reviewer] call. A group can only be
+specified for adding reviewers, not for removing them.
 |`state`         |optional|
-Add reviewer in this state. Possible reviewer states are `REVIEWER`
-and `CC`. If not given, defaults to `REVIEWER`.
+Add reviewer in this state. Possible reviewer states are `REVIEWER`,
+`CC` and `REMOVED`. If not given, defaults to `REVIEWER`.
 |`confirmed`     |optional|
 Whether adding the reviewer is confirmed. +
 The Gerrit server may be configured to
@@ -7872,6 +8004,37 @@
 link:#notify-info[NotifyInfo] entity.
 |=============================
 
+[[reviewer-result]]
+=== ReviewerResult
+The `ReviewerResult` entity describes the result of modifying reviewers of
+a change.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name    ||Description
+|`input`    ||
+Value of the `reviewer` field from link:#reviewer-input[ReviewerInput]
+set while adding the reviewer.
+|`reviewers`   |optional|
+The newly added reviewers as a list of link:#reviewer-info[
+ReviewerInfo] entities.
+|`ccs`         |optional|
+The newly CCed accounts as a list of
+link:rest-api-accounts.html#account-info[AccountInfo] entities. This field will
+only appear if the requested `state` for the reviewer was `CC`.
+|`removed`      |optional|
+The newly removed accounts as a list of
+link:rest-api-accounts.html#account-info[AccountInfo] entities.
+This field will only appear if the requested `state` for the reviewer was
+`REMOVED`.
+|`error`       |optional|
+Error message explaining why the reviewer could not be added. +
+If a group was specified in the input and an error is returned, it
+means that none of the members were added as reviewer.
+|`confirm`     |`false` if not set|
+Whether adding the reviewer requires confirmation.
+|===========================
+
 [[revision-info]]
 === RevisionInfo
 The `RevisionInfo` entity contains information about a patch set.
@@ -8003,27 +8166,6 @@
 to return results from the input rule.
 |===========================
 
-[[submit-info]]
-=== SubmitInfo
-The `SubmitInfo` entity contains information about the change status
-after submitting.
-
-[options="header",cols="1,^1,5"]
-|==========================
-|Field Name    ||Description
-|`status`      ||
-The status of the change after submitting is `MERGED`.
-|`on_behalf_of`|optional|
-The link:rest-api-accounts.html#account-id[\{account-id\}] of the user on
-whose behalf the action should be done. To use this option the caller must
-have been granted both `Submit` and `Submit (On Behalf Of)` permissions.
-The user named by `on_behalf_of` does not need to be granted the `Submit`
-permission. This feature is aimed for CI solutions: the CI account can be
-granted both permissions, so individual users don't need `Submit` permission
-themselves. Still the changes can be submitted on behalf of real users and
-not with the identity of the CI account.
-|==========================
-
 [[submit-input]]
 === SubmitInput
 The `SubmitInput` entity contains information for submitting a change.
@@ -8085,6 +8227,117 @@
 the failure of the rule predicate.
 |===========================
 
+[[submit-record-info]]
+=== SubmitRecordInfo
+The `SubmitRecordInfo` entity describes results from a submit_rule.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`rule_name`||
+The name of the submit rule that created this submit record. The submit rule is
+specified in the form of "$plugin~$rule" where `$plugin` is the plugin name
+and `$rule` is the name of the class that implemented the submit rule.
+|`status`||
+`OK`, the change can be submitted. +
+`NOT_READY`, additional labels are required before submit. +
+`CLOSED`, closed changes cannot be submitted. +
+`FORCED`, the change was submitted bypassing the submit rule. +
+`RULE_ERROR`, rule code failed with an error.
+|`labels`|optional|
+A list of labels, each containing the following fields. +
+  * `label`: the label name. +
+  * `status`: the label status: {`OK`, `REJECT`, `MAY`, `NEED`, `IMPOSSIBLE`}. +
+  * `appliedBy`: the link:rest-api-accounts.html#account-info[AccountInfo]
+  that applied the vote to the label.
+|`requirements`|optional|
+List of the link:rest-api-changes.html#requirement[requirements] to be met
+before this change can be submitted.
+|`error_message`|optional|
+When status is RULE_ERROR this message provides some text describing
+the failure of the rule predicate.
+|===========================
+
+[[submit-requirement-expression-info]]
+=== SubmitRequirementExpressionInfo
+The `SubmitRequirementExpressionInfo` describes the result of evaluating a
+single submit requirement expression, for example `label:code-review=+2`.
+
+[options="header",cols="1,6"]
+|===========================
+|Field Name      |Description
+|`expression`|
+The submit requirement expression as a string, for example
+`branch:refs/heads/foo and label:verified=+1`.
+|`fulfilled`|
+True if the submit requirement is fulfilled for the change.
+|`passing_atoms`|
+A list of passing atoms as strings. For the above expression,
+`passing_atoms` can contain ["branch:refs/heads/foo"] if the branch predicate is
+fulfilled for the change.
+|`failing_atoms`|
+A list of failing atoms. This is similar to `passing_atoms` except that it
+contains the list of predicates that are not fulfilled for the change.
+|===========================
+
+[[submit-requirement-input]]
+=== SubmitRequirementInput
+The `SubmitRequirementInput` entity describes a submit requirement.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`applicability_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is then applicable for this change.
+If not specified, the submit requirement is applicable for all changes.
+|`submittability_expression`||
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is fulfilled and not blocking change submission.
+|`override_expression`|optional|
+Query expression that can be evaluated on any change. If evaluated to true on a
+change, the submit requirement is overridden and not blocking change submission.
+|`allow_override_in_child_projects`|optional|
+Whether this submit requirement can be overridden in child projects. Default is
+`true`.
+|===========================
+
+[[submit-requirement-result-info]]
+=== SubmitRequirementResultInfo
+The `SubmitRequirementResultInfo` describes the result of evaluating a
+submit requirement on a change.
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name      ||Description
+|`name`||
+The submit requirement name.
+|`description`|optional|
+Description of the submit requirement.
+|`status`||
+Status describing the result of evaluating the submit requirement. The status
+is one of (`SATISFIED`, `UNSATISFIED`, `OVERRIDDEN`, `NOT_APPLICABLE`, `ERROR`).
+|`is_legacy`||
+If true, this submit requirement result was created from a legacy
+link:#submit-record[SubmitRecord]. Otherwise, it was created by evaluating a
+submit requirement.
+|`applicability_expression_result`|optional|
+A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
+containing the result of evaluating the applicability expression. Not set if the
+submit requirement did not define an applicability expression.
+|`submittability_expression_result`||
+A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
+containing the result of evaluating the submittability expression.
+|`override_expression_result`|optional|
+A link:#submit-requirement-expression-info[SubmitRequirementExpressionInfo]
+containing the result of evaluating the override expression. Not set if the
+submit requirement did not define an override expression.
+|===========================
+
 [[submitted-together-info]]
 === SubmittedTogetherInfo
 The `SubmittedTogetherInfo` entity contains information about a
diff --git a/Documentation/rest-api-config.txt b/Documentation/rest-api-config.txt
index 41a8729..bd93b8b 100644
--- a/Documentation/rest-api-config.txt
+++ b/Documentation/rest-api-config.txt
@@ -1552,12 +1552,6 @@
 |`allow_blame`        |not set if `false`|
 link:config-gerrit.html#change.allowBlame[Whether blame on side by side diff is
 allowed].
-|`reply_label`        ||
-link:config-gerrit.html#change.replyTooltip[Label name for the reply
-button].
-|`reply_tooltip`      ||
-link:config-gerrit.html#change.replyTooltip[Tooltip for the reply
-button].
 |`update_delay`       ||
 link:config-gerrit.html#change.updateDelay[How often in seconds the web
 interface should poll for updates to the currently open change].
diff --git a/Documentation/rest-api-projects.txt b/Documentation/rest-api-projects.txt
index eee3d3e..6ddc8bd 100644
--- a/Documentation/rest-api-projects.txt
+++ b/Documentation/rest-api-projects.txt
@@ -1541,6 +1541,47 @@
   }
 ----
 
+[[commits-included-in]]
+=== Get Commits Included In Refs
+--
+'GET /projects/link:#project-name[\{project-name\}]/commits:in'
+--
+
+Gets refs in which the specified commits were merged into. Returns a map of commits
+to sets of full ref names.
+
+One or more `commit` query parameters are required and each specifies a
+commit-id (SHA-1 in 40 digit hex representation). Commits will not be contained
+in the map if they do not exist or are not reachable from visible, specified refs.
+
+One or more `ref` query parameters are required and each specifies a full ref name.
+Refs which are not visible to the calling user according to the project's read
+permissions and refs which do not exist will be filtered out from the result.
+
+.Request
+----
+  GET /projects/work%2Fmy-project/commits:in?commit=a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96&commit=6d2a3adb10e844c33617fc948dbeb88e868d396e&ref=refs/heads/master&ref=refs/heads/branch1 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  {
+    "a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96": [
+      "refs/heads/master"
+    ],
+    "6d2a3adb10e844c33617fc948dbeb88e868d396e": [
+      "refs/heads/branch1",
+      "refs/heads/master"
+    ]
+  }
+
+----
+
 [[branch-endpoints]]
 == Branch Endpoints
 
@@ -2624,6 +2665,11 @@
 If the commit message is not set, the commit message of the source
 commit will be used.
 
+When cherry-picking a commit into a branch that already contains the Change-Id
+that we want to cherry-pick, the cherry-pick will create a new patch-set on the
+destination's branch's appropriate Change-Id. If the change is closed on the
+destination branch, the cherry-pick will fail.
+
 .Request
 ----
   POST /projects/work%2Fmy-project/commits/a8a477efffbbf3b44169bb9a1d3a334cbbd9aa96/cherrypick HTTP/1.0
@@ -3962,6 +4008,8 @@
 |`copy_any_score`|`false` if not set|
 Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
 label.
+|`copy_condition`|optional|
+See link:config-labels.html#label_copyCondition[copyCondition].
 |`copy_min_score`|`false` if not set|
 Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
 label.
@@ -4032,6 +4080,10 @@
 |`copy_any_score`|optional|
 Whether link:config-labels.html#label_copyAnyScore[copyAnyScore] is set on the
 label.
+|`copy_condition`|optional|
+See link:config-labels.html#label_copyCondition[copyCondition].
+|`unset_copy_condition`|optional|
+If true, clears the value stored in `copy_condition`.
 |`copy_min_score`|optional|
 Whether link:config-labels.html#label_copyMinScore[copyMinScore] is set on the
 label.
diff --git a/Documentation/rest-api.txt b/Documentation/rest-api.txt
index eabcaa9..469bee5 100644
--- a/Documentation/rest-api.txt
+++ b/Documentation/rest-api.txt
@@ -244,6 +244,60 @@
 Given the trace ID an administrator can find the corresponding logs and
 investigate issues more easily.
 
+[[deadline]]
+=== Setting a deadline
+
+When invoking a REST endpoint it's possible that the client sets a deadline
+after which the request should be aborted. To do this the `X-Gerrit-Deadline`
+header must be set on the request. Values must be specified using standard time
+unit abbreviations ('ms', 'sec', 'min', etc.).
+
+.Example Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/suggest_reviewers?q=J
+  X-Gerrit-Deadline: 5m
+----
+
+
+Setting a deadline on the request overrides any
+link:config-gerrit.html#deadline.id[server-side deadline] that has been
+configured on the host.
+
+[[updated-refs]]
+=== X-Gerrit-UpdatedRef
+This is only enabled when "X-Gerrit-UpdatedRef-Enabled" is set to "true" in the
+request header.
+
+For each write REST request, we return X-Gerrit-UpdatedRef headers as the refs
+that were updated in the current request (involved in a ref transaction in the
+current request).
+
+The format of those headers is `PROJECT_NAME\~REF_NAME\~OLD_SHA-1\~NEW_SHA-1`.
+The project and ref names are URL-encoded, and must use %7E for '~'.
+
+A new SHA-1 of `0000000000000000000000000000000000000000` is treated as a
+deleted ref.
+If the new SHA-1 is not `0000000000000000000000000000000000000000`, the ref was
+either updated or created.
+If the old SHA-1 is `0000000000000000000000000000000000000000`, the ref was
+created.
+
+.Example Request
+----
+  DELETE /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940
+----
+
+.Example Response
+----
+HTTP/1.1 204 NO CONTENT
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+  X-Gerrit-UpdatedRef: myProject~refs%2Fchanges%2F01%2F1%2F1~deadbeefdeadbeefdeadbeefdeadbeefdeadbeef~0000000000000000000000000000000000000000
+  X-Gerrit-UpdatedRef: myProject~refs%2Fchanges%2F01%2F1%2Fmeta~deadbeefdeadbeefdeadbeefdeadbeefdeadbeef~0000000000000000000000000000000000000000
+
+  )]}'
+----
+
 GERRIT
 ------
 Part of link:index.html[Gerrit Code Review]
diff --git a/Documentation/user-attention-set.txt b/Documentation/user-attention-set.txt
index 95e1258..1f67fc7 100644
--- a/Documentation/user-attention-set.txt
+++ b/Documentation/user-attention-set.txt
@@ -47,8 +47,11 @@
   attention set.
 * For merged and abandoned changes the owner is added only when a human creates
   an unresolved comment.
+* If another user removed a user's vote, the user with the deleted vote will be
+  added to the attention set.
 * Only owner, uploader, reviewers and ccs can be in the attention set.
 * The rules for service accounts are different, see link:#bots[Bots].
+* Users are not added by automatic rules when the change is work in progress.
 
 *!IMPORTANT!* These rules are not meant to be super smart and to always do the
 right thing, e.g. if the change owner sends a reply, then they are often
diff --git a/Documentation/user-notify.txt b/Documentation/user-notify.txt
index 5ee3136..128bae6 100644
--- a/Documentation/user-notify.txt
+++ b/Documentation/user-notify.txt
@@ -10,7 +10,7 @@
 == Recipient Type
 
 Those are the available recipient types:
-+
+
 * `to`: The standard To field is used; addresses are visible to all.
 * `cc`: The standard CC field is used; addresses are visible to all.
 * `bcc`: SMTP RCPT TO is used to hide the address.
diff --git a/Documentation/user-request-cancellation-and-deadlines.txt b/Documentation/user-request-cancellation-and-deadlines.txt
new file mode 100644
index 0000000..7c39f16
--- /dev/null
+++ b/Documentation/user-request-cancellation-and-deadlines.txt
@@ -0,0 +1,186 @@
+:linkattrs:
+= Request Cancellation and Deadlines
+
+[[motivation]]
+== Motivation
+
+Protect the Gerrit service by aborting requests that were cancelled or for which
+the deadline has exceeded. If these requests are not aborted, it can happen that
+too many of these requests are accumulated so that the server runs out of
+resources (e.g. threads).
+
+[[request-cancellation]]
+== Request Cancellation
+
+If a user cancels a request by disconnecting, ideally Gerrit should detect this
+and abort the request execution to avoid doing unnecessary work. If nobody is
+waiting for the response, Gerrit shouldn't spend resources to compute it.
+
+Detecting cancelled requests is not easily possible with all protocols that a
+client may use. At the moment Gerrit only detects request cancellations for git
+pushes, but not for other request types (in particular cancelled requests are
+not detected for REST calls over HTTP, SSH commands and git clone/fetch).
+
+[[server-side-deadlines]]
+== Server-side deadlines
+
+To limit the maximal execution time for requests, administrators can
+link:config-gerrit.html#deadline.id[configure server-side deadlines]. If a
+server-side deadline is exceeded by a matching request, the request is
+automatically aborted. In this case the client gets a proper error message
+informing the user about the exceeded deadline.
+
+Clients may override server-side deadlines by setting a
+link:#client-provided-deadlines[deadline] on the request. This means, if a
+request fails due to an exceeded server-side deadline, the client may repeat the
+request with a higher deadline or no deadline (deadline = 0) to get unblocked.
+
+Server-side deadlines are meant to protect the Gerrit service against resource
+exhaustion due to performence issues with a particular request. E.g. imagine a
+situation where requests for a certain REST endpoint are very slow. If more and
+more of such requests get stuck and are not being aborted, the Gerrit service
+may run out of threads, causing an outage for the entire Gerrit service.
+Server-side deadlines may prevent this because the slow requests get aborted
+after the deadline is exceeded, and hence the server resources are freed up.
+
+In some cases server-side deadlines may also lead to a better user experience,
+as it's better to tell the user that there is a performance issue, that prevents
+the execution of the request, than letting them wait indefinitely.
+
+Finally server-side deadlines can help ops engineers to detect performance
+issues more reliably and more quicky. For this alerts may be setup that are
+based on the link:metrics.html#cancellations[cancellation metrics].
+
+[[receive-timeout]]
+=== Receive Timeout
+
+For git pushes it is possible to configure a
+link:config-gerrit.html#receive.timeout[hard timeout]. In contrast to
+server-side deadlines, this timeout is not overridable by
+link:#client-provided-deadlines[client-provided deadlines].
+
+[[client-provided-deadlines]]
+== Client-provided deadlines
+
+Clients can set a deadline on requests to limit the maximal execution time that
+they are willing to wait for a response. If the request doesn't finish within
+this deadline the request is aborted and the client receives an error, with a
+message telling them that the deadline has been exceeded.
+
+How to set a deadline on a request depends on the request type:
+
+[options="header",cols="1,6"]
+|=======================
+|Request Type   |How to set a deadline?
+|REST over HTTP |Set the link:rest-api.html#deadline[X-Gerrit-Deadline header].
+|SSH command    |Set the link:cmd-index.html#deadline[deadline option].
+|git push       |Set the link:user-upload.html#deadline[deadline push option].
+|git clone/fetch|Not supported.
+|=======================
+
+[[override-server-side-deadline]]
+=== Override server-side deadline
+
+By setting a deadline on a request it is possible to override any
+link:#server-side-deadlines[server-side deadline], e.g. in order to increase it.
+Setting the deadline to `0` disables any server-side deadline. This allows
+clients to get unblocked if a request has previously failed due to an exceeded
+deadline.
+
+[NOTE]
+It is stronly discouraged for clients to permanently override
+link:#server-side-deadlines[server-side deadlines] with a higher deadline or to
+permanently disable them by always setting the deadline to `0`. If this becomes
+necessary the caller should get in touch with the Gerrit administrators to
+increase the server-side deadlines or resolve the performance issue in another
+way.
+
+[NOTE]
+It's not possible for clients to override the link:#receive-timeout[receive
+timeout] that is enforced on git push.
+
+[[faqs]]
+== FAQs
+
+[[deadline-exceeded-what-to-do]]
+=== My request failed due to an execeeded deadline, what can I do?
+
+To get unblocked, you may repeat the request with deadlines disabled. To do this
+set the deadline to `0` on the request as explained
+link:#override-server-side-deadline[above].
+
+If doing this becomes required frequently, please get in touch with the Gerrit
+administrators in order to investigate the performance issue and increase the
+server-side deadline if necessary.
+
+[NOTE]
+Setting deadlines for requests that are done from the Gerrit web UI is not
+possible. If exceeded deadlines occur frequently here, please get in touch with
+the Gerrit administrators in order to investigate the performance issue.
+
+[[push-fails-due-to-exceeded-deadline-but-cannot-be-overridden]]
+=== My git push fails due to an exceeded deadline and I cannot override the deadline, what can I do?
+
+As explained link:#receive-timeout[above] a configured receive timeout cannot be
+overridden by clients. If pushes fail due to this timeout, get in touch with the
+Gerrit administrators in order to investigate the performance issue and increase
+the receive timeout if necessary.
+
+[[when-are-requests-aborted]]
+=== How quickly does a request get aborted when it is cancelled or a deadline is exceeded?
+
+In order to know if a request should be aborted, Gerrit needs to explicitly
+check whether the request is cancelled or whether a deadline is exceeded.
+Gerrit does this check at the beginning and end of all performance critical
+steps and sub-steps. This means, the request is only aborted the next time such
+a step starts or finishes, which can also be never (e.g. if the request is stuck
+inside of a step).
+
+[NOTE]
+Technically the check whether a request should be aborted is done whenever the
+execution time of an operation or sub-step is captured, either by a timer
+metric or a `TraceTimer` ('TraceTimer` is the class that logs the execution time
+when the request is being link:user-request-tracing.html[traced]).
+
+[[how-are-requests-aborted]]
+=== How does Gerrit abort requests?
+
+The exact response that is returned to the client depends on the request type
+and the cancellation reason:
+
+[options="header",cols="1,3,3"]
+|=======================
+|Request Type   |Cancellation Reason|Response
+|REST over HTTP |Client Disconnected|The response is '499 Client Closed Request'.
+|               |Server-side deadline exceeded|The response is '408 Server Deadline Exceeded'.
+|               |Client-provided deadline exceeded|The response is '408 Client Provided Deadline Exceeded'.
+|SSH command    |Client Disconnected|The error message is 'Client Closed Request'.
+|               |Server-side deadline exceeded|The error message is 'Server Deadline Exceeded'.
+|               |Client-provided deadline exceeded|The error message is 'Client Provided Deadline Exceeded'.
+|git push       |Client Disconnected|The error status is 'Client Closed Request'.
+|               |Server-side deadline exceeded|The error status is 'Server Deadline Exceeded'.
+|               |Client-provided deadline exceeded|The error status is 'Client Provided Deadline Exceeded'.
+|git clone/fetch|Not supported.
+|=======================
+
+This means clients always get a proper error message telling the user why the
+request has been aborted.
+
+Errors due to aborted requests are usually not counted as internal server errors,
+but the link:metrics.html#cancellations[cancellation metrics] may be used to
+setup alerting for performance issues.
+
+[NOTE]
+During a request, cancellations can occur at any time. This means for non-atomic
+operations, it can happen that the operation is cancelled after some steps have
+already been successfully performed and before all steps have been executed,
+potentially leaving behind an inconsistent state (same as when a request fails
+due to an error). However for important steps, such a NoteDb updates that span
+multiple repositories, Gerrit ensures that they are not torn by cancellations.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-request-tracing.txt b/Documentation/user-request-tracing.txt
index e684b85..303242df 100644
--- a/Documentation/user-request-tracing.txt
+++ b/Documentation/user-request-tracing.txt
@@ -148,3 +148,10 @@
 * permission checks (e.g. which rule is responsible for a deny)
 * timer metrics
 * all other logs
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/user-review-ui.txt b/Documentation/user-review-ui.txt
index b33bea8..6f5f7297 100644
--- a/Documentation/user-review-ui.txt
+++ b/Documentation/user-review-ui.txt
@@ -218,7 +218,7 @@
 ** [[plugin-actions]]Further actions may be available if plugins are installed.
 
 +
-image::images/user-review-ui-change-screen-change-info-actions.png[width=600, link="images/user-review-ui-change-screen-change-info-actions.png"]
+image::images/user-review-ui-change-screen-change-info-actions.png[width=400, link="images/user-review-ui-change-screen-change-info-actions.png"]
 
 - [[labels]]Labels & Votes:
 +
@@ -251,6 +251,7 @@
 The list of commits that are being integrated into the destination
 branch by submitting the merge commit.
 
+
 [[patch-sets]]
 === Patch Sets
 
@@ -258,7 +259,7 @@
 set is currently viewed can be seen from the `Patch Sets` drop-down
 panel in the change header.
 
-image::images/user-review-ui-change-screen-patch-sets.png[width=487, link="images/user-review-ui-change-screen-patch-sets.png"]
+image::images/user-review-ui-change-screen-patch-sets.png[width=300, link="images/user-review-ui-change-screen-patch-sets.png"]
 
 
 [[download]]
@@ -417,7 +418,7 @@
 currently viewed patch set; one can add a summary comment, publish
 inline draft comments, and vote on the labels.
 
-image::images/gwt-user-review-ui-change-screen-reply.png[width=800, link="images/gwt-user-review-ui-change-screen-reply.png"]
+image::images/user-review-ui-change-screen-reply.png[width=800, link="images/user-review-ui-change-screen-reply.png"]
 
 Clicking on the `Reply...` button opens a popup panel.
 
@@ -428,11 +429,8 @@
 items, and lines starting with "> " as block quotes (also see replying to
 link:#reply-to-message[messages] and link:#reply-inline-comment[inline comments]).
 
-Note that you can set the text and tooltip of the button in
-link:config-gerrit.html#change.replyLabel[gerrit.config].
-
 [[vote]]
-If the current patch set is viewed, radio buttons are displayed for
+If the current patch set is viewed, buttons are displayed for
 each label on which the user is allowed to vote. Voting on non-current
 patch sets is not possible.
 
@@ -443,8 +441,6 @@
 
 The `Post` button publishes the comments and the votes.
 
-image::images/gwt-user-review-ui-change-screen-replying.png[width=800, link="images/gwt-user-review-ui-change-screen-replying.png"]
-
 [[quick-approve]]
 If a user can approve a label that is still required, a quick approve
 button appears in the change header that allows to add this missing
@@ -462,7 +458,7 @@
 comments; a summary comment is only added if the reply popup panel is
 open when the quick approve button is clicked.
 
-image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/gwt-user-review-ui-change-screen-quick-approve.png"]
+image::images/user-review-ui-change-screen-quick-approve.png[width=800, link="images/user-review-ui-change-screen-quick-approve.png"]
 
 [[history]]
 === History
@@ -473,32 +469,6 @@
 message is added when a new patch set is uploaded or when a review was
 done.
 
-Messages with new comments from other users, that were published after
-the current user last reviewed this change, are automatically expanded.
-
-image::images/gwt-user-review-ui-change-screen-history.png[width=800, link="images/gwt-user-review-ui-change-screen-history.png"]
-
-[[reply-to-message]]
-It is possible to directly reply to a change message by clicking on the
-reply icon in the right upper corner of a change message. This opens
-the reply popup panel and prefills the text box with the quoted comment.
-Then the reply can be written below the quoted comment or inserted
-inline. Lines starting with "> " will be rendered as a block quote.
-Please note that for a correct rendering it is important to leave a blank
-line between a quoted block and the reply to it.
-
-image::images/gwt-user-review-ui-change-screen-reply-to-comment.png[width=800, link="images/gwt-user-review-ui-change-screen-reply-to-comment.png"]
-
-[[inline-comments-in-history]]
-Inline comments are directly displayed in the change history and there
-are links to navigate to the inline comments.
-
-image::images/gwt-user-review-ui-change-screen-inline-comments.png[width=800, link="images/gwt-user-review-ui-change-screen-inline-comments.png"]
-
-[[expand-all]]
-The `Expand All` button expands all messages; the `Collapse All` button
-collapses all messages.
-
 [[update-notification]]
 === Update Notification
 
@@ -510,16 +480,16 @@
 it is 30 seconds. Polling may also be completely disabled by the
 administrator.
 
-image::images/gwt-user-review-ui-change-screen-change-update.png[width=800, link="images/gwt-user-review-ui-change-screen-change-update.png"]
+image::images/user-review-ui-change-screen-change-update.png[width=400, link="images/user-review-ui-change-screen-change-update.png"]
 
 [[plugin-extensions]]
 === Plugin Extensions
 
-Gerrit plugins may extend the change screen; they can add buttons for
-additional actions to the change info block and display arbitrary UI
-controls below the change info block.
+Gerrit plugins may extend the change screen. Java plugins in the
+backend can add additional actions to the triple-dot menu block.
+Frontend plugins can change the UI controls in arbitrary ways.
 
-image::images/gwt-user-review-ui-change-screen-plugin-extensions.png[width=800, link="images/gwt-user-review-ui-change-screen-plugin-extensions.png"]
+image::images/user-review-ui-change-screen-plugin-extensions.png[width=300, link="images/user-review-ui-change-screen-plugin-extensions.png"]
 
 [[side-by-side]]
 == Side-by-Side Diff Screen
@@ -530,17 +500,8 @@
 
 This screen allows to review a patch and to comment on it.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen.png"]
+image::images/user-review-ui-side-by-side-diff-screen.png[width=800, link="images/user-review-ui-side-by-side-diff-screen.png"]
 
-[[side-by-side-header]]
-In the screen header the project name and the name of the viewed patch
-file are shown.
-
-If a Git web browser is configured on the server, the project name and
-the file path are displayed as links to the project and the folder in
-the Git web browser.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-project-and-file.png"]
 
 [[side-by-side-mark-reviewed]]
 The checkbox in front of the file name allows the
@@ -548,7 +509,7 @@
 diff preference allows to control whether the files should be
 automatically marked as reviewed when they are viewed.
 
-image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-reviewed.png"]
+image::images/user-review-ui-side-by-side-diff-screen-reviewed.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-reviewed.png"]
 
 [[patch-set-selection]]
 In the header, on each side, the list of patch sets is shown. Clicking
@@ -568,34 +529,20 @@
 version before, may see what has changed since that version by
 comparing the old patch against the current patch.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-patch-sets.png"]
+image::images/user-review-ui-side-by-side-diff-screen-patch-sets.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-patch-sets.png"]
 
 [[download-file]]
 The download icon next to the patch set list allows to download the
 patch. Unless the mime type of the file is configured as safe, the
 download file is a zip archive that contains the patch file.
 
-[[no-differences]]
-If the compared patches are identical, this is highlighted by a red
-`No Differences` label in the screen header.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-no-differences.png"]
 
 [[side-by-side-rename]]
 If a file was renamed, the old and new file paths are shown in the
 header together with a similarity index that shows how much of the file
 content is unmodified.
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen-rename.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-rename.png"]
-
-[[navigation]]
-For navigating between the patches in a patch set there are navigation
-buttons on the right side of the screen header. The left arrow button
-navigates to the previous patch; the right arrow button navigates to
-the next patch. The arrow up button leads back to the change screen. In
-all cases the selection for the patch set comparison is kept.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-navigation.png"]
+image::images/user-review-ui-side-by-side-diff-screen-rename.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-rename.png"]
 
 [[inline-comments]]
 === Inline Comments
@@ -611,26 +558,9 @@
 attach several comments to the same code.
 
 [[line-links]]
-The lines of the patch file are linkable. To link to a certain line in
-the patch file, '@<line-number>' must be appended to the patch link,
-e.g. `http://host:8080/#/c/56857/2/Documentation/user-review-ui.txt@665`.
-To link to a line in the old file version, '@a<line-number>' must be
-appended to the patch link. These links can be used to directly link to
-certain inline comments.
-
-If the diff preference link:#expand-all-comments[Expand All Comments]
-is set to `Expand`, all inline comments will be automatically expanded.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-inline-comments.png"]
-
-[[comment]]
-In the header of the comment box, the name of the comment author and
-the timestamp of the comment are shown. If avatars are configured on
-the server, the avatar image of the comment author is displayed in the
-top left corner. Below the actual comment there are buttons to reply to
-the comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-box.png"]
+The lines of the patch file are linkable: simply append
+'#<linenumber>' to the URL, or click on the line-number. This not only
+opens a draft comment box, but also sets the URL fragment.
 
 [[reply-inline-comment]]
 Clicking on the `Reply` button opens an editor to type the reply.
@@ -640,38 +570,25 @@
 note that for a correct rendering it is important to leave a blank line
 between a quoted block and the reply to it.
 
-Clicking on the `Save` button saves the comment as a draft. To make it
-visible to other users it must be published from the change screen by
-link:#reply[replying] to the change.
+image::images/user-review-ui-side-by-side-diff-screen-inline-comments.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-inline-comments.png"]
 
-The `Cancel` button cancels the editing and discards any changes to the
-draft comment.
-
-Clicking on the `Discard` button deletes the inline draft comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-reply.png"]
-
-[[draft-inline-comment]]
-Draft comments are marked by the text "Draft" in the header in the
-place of the comment author.
-
-A draft comment can be edited by clicking on the `Edit` button, or
-deleted by clicking on the `Discard` button.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment-edit.png"]
+Comments are first saved as drafts, and you can revisit the drafts as
+you read through code review. Finally, they should be published by
+clicking the "Reply".
 
 [[done]]
-Clicking on the `Done` button is a quick way to reply with "Done" to a
-comment. This is used to mark a comment as addressed by a follow-up
-patch set.
+Comments can be unresolved (something should be changed) or resolved
+(informational). If you have addressed an unresolved comment in a next
+patchset, you can quickly resolve the comment by clicking "Done" (if it was
+resolved in a next patchset) or "Ack" (if you acknowledge the comment,
+but don't want to make changes).
 
-image::images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-replied-done.png"]
+image::images/user-review-ui-side-by-side-diff-screen-replied-done.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-replied-done.png"]
 
 [[add-inline-comment]]
 To add a new inline comment there are several possibilities:
 
 - select a code block and press 'c'
-- select a code block and click on the popup comment icon
 - go to a line, by clicking on it or by link:#key-navigation[key
   navigation], and press 'c'
 - click on a line number
@@ -686,23 +603,12 @@
 ** triple-click on a line to select it
 ** triple-click and drag with the mouse to select a code block line-wise
 
-- by keys (the same keys that are used for visual selection in Vim):
-** press 'v' + arrow keys (or 'h', 'j', 'k', 'l') to select a block
-** press 'V' + arrow keys (or 'j', 'k') to select a code block line-wise
-** type 'bvw' to select a word
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-comment.png"]
-
 For typing the new comment, a new comment box is shown under the code
 that is commented.
 
-Clicking on the `Save` button saves the new comment as a draft. To make
-it visible to other users it must be published from the change screen
-by link:#reply[replying] to the change.
-
-Clicking on the `Discard` button deletes the new comment.
-
-image::images/gwt-user-review-ui-side-by-side-diff-screen-commented.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-commented.png"]
+Clicking on the `Save` button saves the new comment as a draft. To
+make it visible to other users it must be published from the change
+screen by link:#reply[replying] to the change.
 
 [[file-level-comments]]
 === File Level Comments
@@ -710,7 +616,7 @@
 File level comments are added by clicking the 'File' header at the top
 of the file.
 
-image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
+image::images/user-review-ui-side-by-side-diff-screen-file-level-comment.png[width=400, link="images/user-review-ui-side-by-side-diff-screen-file-level-comment.png"]
 
 [[diff-preferences]]
 === Diff Preferences
@@ -720,7 +626,7 @@
 preferences. The diff preferences can be accessed by clicking on the
 settings icon in the screen header.
 
-image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-preferences.png"]
+image::images/user-review-ui-side-by-side-diff-screen-preferences.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-preferences.png"]
 
 The following diff preferences can be configured:
 
@@ -767,7 +673,7 @@
 If many lines are skipped there are additional links to expand the
 context by ten lines before and after the skipped block.
 +
-image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/gwt-user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
+image::images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png[width=800, link="images/user-review-ui-side-by-side-diff-screen-expand-skipped-lines.png"]
 
 - [[syntax-highlighting]]`Syntax Highlighting`:
 +
@@ -797,7 +703,7 @@
 a popup that shows a list of available keyboard shortcuts.
 
 
-image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/gwt-user-review-ui-change-screen-keyboard-shortcuts.png"]
+image::images/user-review-ui-change-screen-keyboard-shortcuts.png[width=800, link="images/user-review-ui-change-screen-keyboard-shortcuts.png"]
 
 
 In addition, Vim-like commands can be used to link:#key-navigation[
diff --git a/Documentation/user-search.txt b/Documentation/user-search.txt
index ff1376b..cc8d813 100644
--- a/Documentation/user-search.txt
+++ b/Documentation/user-search.txt
@@ -136,6 +136,19 @@
 +
 Changes originally submitted by a user in 'GROUP'.
 
+[[uploader]]
+uploader:'USER'::
++
+Changes where the latest patch set was uploaded by 'USER'.
+The special case of `uploader:self` will find changes uploaded
+by the caller.
+
+[[uploaderin]]
+uploaderin:'GROUP'::
++
+Changes where the latest patch set was uploaded by a user in
+'GROUP'.
+
 [[query]]
 query:'[name=]NAME[,user=USER]'::
 +
@@ -251,6 +264,16 @@
 often combined with 'branch:' and 'project:' operators to select
 all related changes in a series.
 
+[[inhashtag]]
+inhashtag:'HASHTAG'::
++
+Changes where any hashtag contains 'HASHTAG', using a full-text search.
++
+If 'HASHTAG' starts with `^` it matches hashtag names by regular
+expression patterns.  The
+link:http://www.brics.dk/automaton/[dk.brics.automaton
+library,role=external,window=_blank] is used for evaluation of such patterns.
+
 [[hashtag]]
 hashtag:'HASHTAG'::
 +
@@ -311,7 +334,7 @@
 +
 Matches any change touching file at 'PATH'. By default exact path
 matching is used, but regular expressions can be enabled by starting
-with `^`.  For example, to match all XML files use `file:^.*\.xml$`.
+with `^`.  For example, to match all XML files use `file:"^.*\.xml$"`.
 The link:http://www.brics.dk/automaton/[dk.brics.automaton library,role=external,window=_blank]
 is used for the evaluation of such patterns.
 +
@@ -321,8 +344,7 @@
 files, use `file:^.*\.java`.
 +
 The entire regular expression pattern, including the `^` character,
-should be double quoted when using more complex construction (like
-ones using a bracket expression). For example, to match all XML
+should be double quoted. For example, to match all XML
 files named like 'name1.xml', 'name2.xml', and 'name3.xml' use
 `file:"^name[1-3].xml"`.
 +
@@ -397,6 +419,8 @@
 +
 'star:star' is the same as 'has:star' and 'is:starred'.
 
+Only "ignore" and "star" are supported labels.
+
 [[has]]
 has:draft::
 +
@@ -408,11 +432,6 @@
 Same as 'is:starred' and 'star:star', true if the change has been
 starred by the current user with the default label.
 
-[[has-stars]]
-has:stars::
-+
-True if the change has been starred by the current user with any label.
-
 has:edit::
 +
 True if the change has inline edit created by the current user.
@@ -421,6 +440,11 @@
 +
 True if the change has unresolved comments.
 
+has:attention::
++
+True if the change has attention by the current user.
+
+
 [[is]]
 is:assigned::
 +
@@ -436,6 +460,10 @@
 +
 True if the change does not have an assignee.
 
+is:attention::
++
+True if the change has attention by the current user.
+
 is:watched::
 +
 True if this change matches one of the current user's watch filters,
@@ -451,6 +479,12 @@
 True on any change where the current user is the change owner.
 Same as `owner:self`.
 
+is:uploader::
++
+True on any change where the current user is the uploader of
+the latest patch set.
+Same as `uploader:self`.
+
 is:reviewer::
 +
 True on any change where the current user is a reviewer.
@@ -521,6 +555,16 @@
 +
 True if the change is a merge commit.
 
+[[cherrypick]]
+is:cherrypick::
++
+True if the change is a cherrypick of an another change.
+
+This is limited to changes which are cherrypicked using REST API
+or WebUI only. It is not able to identify changes which are
+cherry-picked locally using the git cherry-pick command and then
+pushed to Gerrit.
+
 [[status]]
 status:open, status:pending, status:new::
 +
@@ -600,6 +644,22 @@
 only applies to the top-level status; individual label statuses can be
 searched link:#labels[by label].
 
+[[rule]]
+rule:'SUBMIT_RULE_NAME'::
++
+Changes where 'SUBMIT_RULE_NAME' returns a submit record with status in {OK,
+FORCED}. This means that the submit rule has passed and is not blocking the
+change submission. 'SUBMIT_RULE_NAME' should be in the form of
+'$plugin_name~$rule_name'. For gerrit core rules, 'SUBMIT_RULE_NAME' should
+be in the form of '$rule_name' (example: `DefaultSubmitRule`), or
+'gerrit~$rule_name' (example: `gerrit~DefaultSubmitRule`).
+
+rule:'SUBMIT_RULE_NAME'='STATUS'::
++
+Changes where 'SUBMIT_RULE_NAME' returns a submit record with status equals to
+'STATUS'. The status can be any of the statuses that are documented for the
+`status` field of link:rest-api-changes.html#submit-record[SubmitRecord].
+
 [[unresolved]]
 unresolved:'RELATION''NUMBER'::
 +
@@ -696,6 +756,22 @@
 to one of the fields in the
 link:rest-api-changes.html#submit-record[SubmitRecord] REST API entity.
 
+`label:Code-Review\<=-1`::
++
+Matches changes with either a -1, -2, or any lower score.
+
+`label:Code-Review=MAX`::
++
+Matches changes with label voted with the highest possible score.
+
+`label:Code-Review=MIN`::
++
+Matches changes with label voted with the lowest possible score.
+
+`label:Code-Review=ANY`::
++
+Matches changes with label voted with any score.
+
 `label:Non-Author-Code-Review=need`::
 +
 Matches changes where the submit rules indicate that a label named
@@ -720,15 +796,20 @@
 The special "owner" parameter corresponds to the change owner.  Matches
 all changes that have a +2 vote from the change owner.
 
+`label:Code-Review=+2,user=non_uploader`::
+`label:Code-Review=ok,user=non_uploader`::
+`label:Code-Review=+2,non_uploader`::
+`label:Code-Review=ok,non_uploader`::
++
+The special "non_uploader" parameter corresponds to any user who's not the
+uploader of the latest patchset. Matches all changes that have a +2 vote from
+a non upoader.
+
 `label:Code-Review=+1,group=ldap/linux.workflow`::
 +
 Matches changes with a +1 code review where the reviewer is in the
 ldap/linux.workflow group.
 
-`label:Code-Review\<=-1`::
-+
-Matches changes with either a -1, -2, or any lower score.
-
 `is:open label:Code-Review+2 label:Verified+1 NOT label:Verified-1 NOT label:Code-Review-2`::
 `is:open label:Code-Review=ok label:Verified=ok`::
 +
@@ -767,24 +848,6 @@
 Magical internal flag to prove the current user has access to read
 the change.  This flag is always added to any query.
 
-starredby:'USER'::
-+
-Matches changes that have been starred by 'USER' with the default label.
-The special case `starredby:self` applies to the caller.
-
-watchedby:'USER'::
-+
-Matches changes that 'USER' has configured watch filters for.
-The special case `watchedby:self` applies to the caller.
-
-draftby:'USER'::
-+
-Matches changes that 'USER' has left unpublished draft comments on.
-Since the drafts are unpublished, it is not possible to see the
-draft text, or even how many drafts there are. The special case
-of `draftby:self` will find changes where the caller has created
-a draft comment.
-
 [[limit]]
 limit:'CNT'::
 +
diff --git a/Documentation/user-upload.txt b/Documentation/user-upload.txt
index 0670968..2bfc62d 100644
--- a/Documentation/user-upload.txt
+++ b/Documentation/user-upload.txt
@@ -460,6 +460,23 @@
 Given the trace ID an administrator can find the corresponding logs and
 investigate issues more easily.
 
+[[deadline]]
+==== Setting a deadline
+
+When pushing to Gerrit it's possible that the client sets a deadline after which
+the push should be aborted. To do this the `deadline=<deadline>` push option
+must be set on the git push. Values must be specified using standard time unit
+abbreviations ('ms', 'sec', 'min', etc.).
+
+----
+  git push -o deadline=10m ssh://john.doe@git.example.com:29418/kernel/common HEAD:refs/for/master
+----
+
+Setting a deadline for the push overrides any
+link:config-gerrit.html#deadline.id[server-side deadline] that has been
+configured on the host, but not the link:config.html#receive.timeout[receive
+timeout].
+
 [[push_replace]]
 === Replace Changes
 
@@ -622,6 +639,20 @@
 point, which could be slow and create lots of unintended new changes.
 To create multiple new changes, run push multiple times.
 
+[[ignore-attention-set]]
+=== Ignore automatic attention set rules
+
+Normally, we add users to the attention set based on several rules such as adding
+reviewers, replying, and many others. The full rule list is in
+link:user-attention-set.html[Attention Set].
+
+--ignore-automatic-attention-set-rules (also known as -ias and
+-ignore-attention-set) can be used to keep the attention set as it were before
+the push.
+
+----
+  git push ssh://john.doe@git.example.com:29418/kernel/common my-merged-commit:refs/for/master%ias
+----
 
 == repo upload
 
diff --git a/WORKSPACE b/WORKSPACE
index b501243..b3bf30b 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", "TESTCONTAINERS_VERSION", "declare_nongoogle_deps")
+load("//tools:nongoogle.bzl", "declare_nongoogle_deps")
 
 http_archive(
     name = "platforms",
@@ -100,6 +100,19 @@
     firefox = True,
 )
 
+http_archive(
+    name = "rules_pkg",
+    sha256 = "038f1caa773a7e35b3663865ffb003169c6a71dc995e39bf4815792f385d837d",
+    urls = [
+        "https://mirror.bazel.build/github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
+        "https://github.com/bazelbuild/rules_pkg/releases/download/0.4.0/rules_pkg-0.4.0.tar.gz",
+    ],
+)
+
+load("@rules_pkg//:deps.bzl", "rules_pkg_dependencies")
+
+rules_pkg_dependencies()
+
 # Golang support for PolyGerrit local dev server.
 http_archive(
     name = "io_bazel_rules_go",
@@ -601,24 +614,24 @@
     sha1 = "eff48ed53995db2dadf0456426cc1f8700136f86",
 )
 
-AUTO_VALUE_GSON_VERSION = "1.3.0"
+AUTO_VALUE_GSON_VERSION = "1.3.1"
 
 maven_jar(
     name = "auto-value-gson-runtime",
     artifact = "com.ryanharter.auto.value:auto-value-gson-runtime:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "a69a9db5868bb039bd80f60661a771b643eaba59",
+    sha1 = "addda2ae6cce9f855788274df5de55dde4de7b71",
 )
 
 maven_jar(
     name = "auto-value-gson-extension",
     artifact = "com.ryanharter.auto.value:auto-value-gson-extension:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "6a61236d17b58b05e32b4c532bcb348280d2212b",
+    sha1 = "0c4c01a3e10e5b10df2e5f5697efa4bb3f453ac1",
 )
 
 maven_jar(
     name = "auto-value-gson-factory",
     artifact = "com.ryanharter.auto.value:auto-value-gson-factory:" + AUTO_VALUE_GSON_VERSION,
-    sha1 = "b1f01918c0d6cb1f5482500e6b9e62589334dbb0",
+    sha1 = "9ed8d79144ee8d60cc94cc11f847b5ed8ee9f19c",
 )
 
 maven_jar(
@@ -635,38 +648,6 @@
 
 declare_nongoogle_deps()
 
-LUCENE_VERS = "6.6.5"
-
-maven_jar(
-    name = "lucene-core",
-    artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
-    sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
-)
-
-maven_jar(
-    name = "lucene-analyzers-common",
-    artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
-    sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
-)
-
-maven_jar(
-    name = "backward-codecs",
-    artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
-    sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
-)
-
-maven_jar(
-    name = "lucene-misc",
-    artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
-    sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
-)
-
-maven_jar(
-    name = "lucene-queryparser",
-    artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
-    sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
-)
-
 maven_jar(
     name = "mime-util",
     artifact = "eu.medsea.mimeutil:mime-util:2.1.3",
@@ -722,7 +703,7 @@
     sha1 = "f7be08ec23c21485b9b5a1cf1654c2ec8c58168d",
 )
 
-GITILES_VERS = "0.4"
+GITILES_VERS = "0.4-1"
 
 GITILES_REPO = GERRIT
 
@@ -731,14 +712,14 @@
     artifact = "com.google.gitiles:blame-cache:" + GITILES_VERS,
     attach_source = False,
     repository = GITILES_REPO,
-    sha1 = "567198123898aa86bd854d3fcb044dc7a1845741",
+    sha1 = "0df80c6b8822147e1f116fd7804b8a0de544f402",
 )
 
 maven_jar(
     name = "gitiles-servlet",
     artifact = "com.google.gitiles:gitiles-servlet:" + GITILES_VERS,
     repository = GITILES_REPO,
-    sha1 = "0dd832a6df108af0c75ae29b752fda64ccbd6886",
+    sha1 = "60870897d22b840e65623fd024eabd9cc9706ebe",
 )
 
 # prettify must match the version used in Gitiles
@@ -787,12 +768,6 @@
     sha1 = "fd369423346b2f1525c413e33f8cf95b09c92cbd",
 )
 
-# Base the following org.apache.httpcomponents versions on what
-# elasticsearch-rest-client explicitly depends on, except for
-# commons-codec (non-http) which is not necessary yet. Note that
-# below httpcore version(s) differs from the HTTPCOMP_VERS range,
-# upstream: that specific dependency has no HTTPCOMP_VERS version
-# equivalent currently.
 HTTPCOMP_VERS = "4.5.2"
 
 maven_jar(
@@ -934,16 +909,28 @@
     exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:package.json",
+    package_path = "",
     symlink_node_modules = True,
     yarn_lock = "//:yarn.lock",
 )
 
 yarn_install(
     name = "ui_npm",
-    args = ["--prod"],
+    args = [
+        "--prod",
+        # By default, yarn install all optional dependencies.
+        # In some cases, it installs a lot of additional dependencies which
+        # are not required (for example, "resemblejs" has one optional
+        # dependencies "canvas" that leads to tens of additional dependencies).
+        # Each additional dependency requires a license even if it is not used
+        # in our code.  We want to ensure that all optional dependencies are
+        # explicitly added to package.json.
+        "--ignore-optional",
+    ],
     exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/app/package.json",
+    package_path = "polygerrit-ui/app",
     symlink_node_modules = True,
     yarn_lock = "//:polygerrit-ui/app/yarn.lock",
 )
@@ -953,6 +940,7 @@
     exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:polygerrit-ui/package.json",
+    package_path = "polygerrit-ui",
     symlink_node_modules = True,
     yarn_lock = "//:polygerrit-ui/yarn.lock",
 )
@@ -962,6 +950,7 @@
     exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:tools/node_tools/package.json",
+    package_path = "tools/node_tools",
     symlink_node_modules = True,
     yarn_lock = "//:tools/node_tools/yarn.lock",
 )
@@ -972,215 +961,9 @@
     exports_directories_only = False,
     frozen_lockfile = False,
     package_json = "//:plugins/package.json",
+    package_path = "plugins",
     symlink_node_modules = True,
     yarn_lock = "//:plugins/yarn.lock",
 )
 
-load("//tools/bzl:js.bzl", "bower_archive", "npm_binary")
-
-# NPM binaries bundled along with their dependencies.
-#
-# For full instructions on adding new binaries to the build, see
-# http://gerrit-review.googlesource.com/Documentation/dev-bazel.html#npm-binary
-npm_binary(
-    name = "bower",
-)
-
-npm_binary(
-    name = "polymer-bundler",
-    repository = GERRIT,
-)
-
-npm_binary(
-    name = "crisper",
-    repository = GERRIT,
-)
-
-# bower_archive() seed components.
-bower_archive(
-    name = "iron-autogrow-textarea",
-    package = "polymerelements/iron-autogrow-textarea",
-    sha1 = "2f04c7e2a72d462de36093ab2b4889db20f699f6",
-    version = "2.2.0",
-)
-
-bower_archive(
-    name = "es6-promise",
-    package = "stefanpenner/es6-promise",
-    sha1 = "a3a797bb22132f1ef75f9a2556173f81870c2e53",
-    version = "3.3.0",
-)
-
-bower_archive(
-    name = "fetch",
-    package = "fetch",
-    sha1 = "1b05a2bb40c73232c2909dc196de7519fe4db7a9",
-    version = "1.0.0",
-)
-
-bower_archive(
-    name = "iron-dropdown",
-    package = "polymerelements/iron-dropdown",
-    sha1 = "3902ba164552b1bfc59e6fa692efa4a1fd8dd4ea",
-    version = "2.2.1",
-)
-
-bower_archive(
-    name = "iron-input",
-    package = "polymerelements/iron-input",
-    sha1 = "f79952ff4f6f103c0a2cbd3dacf25935257ff392",
-    version = "2.1.3",
-)
-
-bower_archive(
-    name = "iron-overlay-behavior",
-    package = "polymerelements/iron-overlay-behavior",
-    sha1 = "c2d2eac1b162420d9475ade2f16d5db8959b93fc",
-    version = "2.3.4",
-)
-
-bower_archive(
-    name = "iron-selector",
-    package = "polymerelements/iron-selector",
-    sha1 = "3f3fcb55f6bd606ea493f99eab9daae21f7a6139",
-    version = "2.1.0",
-)
-
-bower_archive(
-    name = "paper-button",
-    package = "polymerelements/paper-button",
-    sha1 = "bcb783d74e1177c1d0836340e7c0280699d1438c",
-    version = "2.1.3",
-)
-
-bower_archive(
-    name = "paper-input",
-    package = "polymerelements/paper-input",
-    sha1 = "c1a81a4173d22e72e8ab609eb3715a75273396b3",
-    version = "2.2.3",
-)
-
-bower_archive(
-    name = "paper-tabs",
-    package = "polymerelements/paper-tabs",
-    sha1 = "589b8e6efa0f171c93233137c8ea013dcea0ffc7",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "iron-icon",
-    package = "polymerelements/iron-icon",
-    sha1 = "d21e7d4f1bdc6de881390f888e28d53155eeb551",
-    version = "2.1.0",
-)
-
-bower_archive(
-    name = "iron-iconset-svg",
-    package = "polymerelements/iron-iconset-svg",
-    sha1 = "07c0ce02ce6479856758893416a3709009db7f22",
-    version = "2.2.1",
-)
-
-bower_archive(
-    name = "moment",
-    package = "moment/moment",
-    sha1 = "fc8ce2c799bab21f6ced7aff928244f4ca8880aa",
-    version = "2.13.0",
-)
-
-bower_archive(
-    name = "page",
-    package = "visionmedia/page.js",
-    sha1 = "4a31889cd75cc5e7f68a4c7f256eecaf27102eee",
-    version = "1.11.4",
-)
-
-bower_archive(
-    name = "paper-item",
-    package = "polymerelements/paper-item",
-    sha1 = "c3bad022cf182d2bf1c8a44374c7fcb1409afbfa",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "paper-listbox",
-    package = "polymerelements/paper-listbox",
-    sha1 = "78247cc32bb776f204efef17cff3095878036a40",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "paper-toggle-button",
-    package = "polymerelements/paper-toggle-button",
-    sha1 = "9927960afb0062726ec1b585ef3e32764c3bbac9",
-    version = "2.1.1",
-)
-
-bower_archive(
-    name = "polymer",
-    package = "polymer/polymer",
-    sha1 = "d06e17a1d8dc6187ee5aa8c5b3501da10901c82f",
-    version = "2.7.2",
-)
-
-bower_archive(
-    name = "polymer-resin",
-    package = "polymer/polymer-resin",
-    sha1 = "94c29926c20ea3a9b636f26b3e0d689ead8137e5",
-    version = "2.0.1",
-)
-
-bower_archive(
-    name = "resemblejs",
-    package = "rsmbl/Resemble.js",
-    sha1 = "49d5f022417c389b630d6f7ee667aa9540075c42",
-    version = "2.10.1",
-)
-
-bower_archive(
-    name = "codemirror-minified",
-    package = "Dominator008/codemirror-minified",
-    sha1 = "904bae2a8716087fd21e92324e8a136a0c4a99b7",
-    version = "5.62.2",
-)
-
-# bower test stuff
-
-bower_archive(
-    name = "iron-test-helpers",
-    package = "polymerelements/iron-test-helpers",
-    sha1 = "882be2d4c8714b39299b5f7bf25253c4e8a40761",
-    version = "2.0.1",
-)
-
-bower_archive(
-    name = "test-fixture",
-    package = "polymerelements/test-fixture",
-    sha1 = "7d72ddfebf555a2dd1fc60a85427d9026b509723",
-    version = "3.0.0",
-)
-
-bower_archive(
-    name = "web-component-tester",
-    package = "polymer/web-component-tester",
-    sha1 = "d84f6a13bde5f8fd39ee208d43f33925410530d7",
-    version = "6.5.1",
-)
-
 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/contrib/ui-api-proxy.go b/contrib/ui-api-proxy.go
deleted file mode 100644
index 1ae1c1a..0000000
--- a/contrib/ui-api-proxy.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// ui-api-proxy is a reverse http proxy that allows the UI to be served from
-// a different host than the API. This allows testing new UI features served
-// from localhost but using live production data.
-//
-// Run the binary, download & install the Go tools available at
-// http://golang.org/doc/install . To run, execute `go run ui-api-proxy.go`.
-// For a description of the available flags, execute
-// `go run ui-api-proxy.go --help`.
-package main
-
-import (
-	"flag"
-	"fmt"
-	"log"
-	"net"
-	"net/http"
-	"net/http/httputil"
-	"net/url"
-	"strings"
-	"time"
-)
-
-var (
-	ui   = flag.String("ui", "http://localhost:8080", "host to which ui requests will be forwarded")
-	api  = flag.String("api", "https://gerrit-review.googlesource.com", "host to which api requests will be forwarded")
-	port = flag.Int("port", 0, "port on which to run this server")
-)
-
-func main() {
-	flag.Parse()
-
-	uiURL, err := url.Parse(*ui)
-	if err != nil {
-		log.Fatalf("proxy: parsing ui addr %q failed: %v\n", *ui, err)
-	}
-	apiURL, err := url.Parse(*api)
-	if err != nil {
-		log.Fatalf("proxy: parsing api addr %q failed: %v\n", *api, err)
-	}
-
-	l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%v", *port))
-	if err != nil {
-		log.Fatalln("proxy: listen failed: ", err)
-	}
-	defer l.Close()
-	fmt.Printf("OK\nListening on http://%v/\n", l.Addr())
-
-	err = http.Serve(l, &httputil.ReverseProxy{
-		FlushInterval: 500 * time.Millisecond,
-		Director: func(r *http.Request) {
-			if strings.HasPrefix(r.URL.Path, "/changes/") || strings.HasPrefix(r.URL.Path, "/projects/") {
-				r.URL.Scheme, r.URL.Host = apiURL.Scheme, apiURL.Host
-			} else {
-				r.URL.Scheme, r.URL.Host = uiURL.Scheme, uiURL.Host
-			}
-			if r.URL.Scheme == "" {
-				r.URL.Scheme = "http"
-			}
-			r.Host, r.URL.Opaque, r.URL.RawQuery = r.URL.Host, r.RequestURI, ""
-		},
-	})
-	if err != nil {
-		log.Fatalln("proxy: serve failed: ", err)
-	}
-}
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
deleted file mode 100644
index b05a671..0000000
--- a/e2e-tests/src/test/resources/hooks/commit-msg
+++ /dev/null
@@ -1,43 +0,0 @@
-#!/bin/sh
-#
-# Part of Gerrit Code Review (https://www.gerritcodereview.com/)
-#
-# 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.
-
-# avoid [[ which is not POSIX sh.
-if test "$#" != 1 ; then
-  echo "$0 requires an argument."
-  exit 1
-fi
-
-if test ! -f "$1" ; then
-  echo "file does not exist: $1"
-  exit 1
-fi
-
-if test ! -s "$1" ; then
-  echo "file is empty: $1"
-  exit 1
-fi
-
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
-dest="$1.tmp.${random}"
-
-# Avoid the --in-place option which only appeared in Git 2.8
-# Avoid the --if-exists option which only appeared in Git 2.15
-cat "$1" \
-| git -c trailer.ifexists=doNothing interpret-trailers --trailer "Change-Id: I${random}" > "${dest}" \
-&& mv "${dest}" "$1"
diff --git a/e2e-tests/src/test/resources/hooks/commit-msg b/e2e-tests/src/test/resources/hooks/commit-msg
new file mode 120000
index 0000000..6066256
--- /dev/null
+++ b/e2e-tests/src/test/resources/hooks/commit-msg
@@ -0,0 +1 @@
+../../../../../resources/com/google/gerrit/server/tools/root/hooks/commit-msg
\ No newline at end of file
diff --git a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
index 426c806..3934a6a 100644
--- a/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractDaemonTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.OptionalSubject.optionals;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
@@ -58,6 +59,7 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.InternalGroup;
@@ -70,6 +72,7 @@
 import com.google.gerrit.entities.PermissionRule.Action;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -83,6 +86,7 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 import com.google.gerrit.extensions.client.SubmitType;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeType;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -103,6 +107,7 @@
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.change.BatchAbandon;
 import com.google.gerrit.server.change.ChangeFinder;
 import com.google.gerrit.server.change.ChangeResource;
@@ -127,6 +132,8 @@
 import com.google.gerrit.server.notedb.AbstractChangeNotes;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotesCommit;
+import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.TestServerPlugin;
 import com.google.gerrit.server.project.ProjectCache;
@@ -168,6 +175,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
@@ -195,7 +203,6 @@
 import org.eclipse.jgit.util.FS;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
-import org.junit.AfterClass;
 import org.junit.Before;
 import org.junit.ClassRule;
 import org.junit.Rule;
@@ -297,6 +304,8 @@
   protected TestRepository<InMemoryRepository> testRepo;
   protected String resourcePrefix;
   protected Description description;
+  protected GerritServer.Description testMethodDescription;
+
   protected boolean testRequiresSsh;
   protected BlockStrategy noSleepBlockStrategy = t -> {}; // Don't sleep in tests.
 
@@ -343,22 +352,29 @@
   }
 
   @After
+  public void verifyNoPiiInChangeNotes() throws RestApiException, IOException {
+    if (testMethodDescription.verifyNoPiiInChangeNotes()) {
+      verifyNoAccountDetailsInChangeNotes();
+    }
+  }
+
+  @After
   public void closeEventRecorder() {
     if (eventRecorder != null) {
       eventRecorder.close();
     }
   }
 
-  @AfterClass
+  @ConfigSuite.AfterConfig
   public static void stopCommonServer() throws Exception {
     if (commonServer != null) {
       try {
         commonServer.close();
-      } catch (Throwable t) {
+      } catch (Exception e) {
         throw new AssertionError(
             "Error stopping common server in "
                 + (firstTest != null ? firstTest.getTestClass().getName() : "unknown test class"),
-            t);
+            e);
       } finally {
         commonServer = null;
       }
@@ -430,6 +446,7 @@
         GerritServer.Description.forTestClass(description, configName);
     GerritServer.Description methodDesc =
         GerritServer.Description.forTestMethod(description, configName);
+    testMethodDescription = methodDesc;
 
     testRequiresSsh = classDesc.useSshAnnotation() || methodDesc.useSshAnnotation();
     if (!testRequiresSsh) {
@@ -462,7 +479,7 @@
     toClose = Collections.synchronizedList(new ArrayList<>());
 
     admin = accountCreator.admin();
-    user = accountCreator.user();
+    user = accountCreator.user1();
 
     // Evict and reindex accounts in case tests modify them.
     reindexAccount(admin.id());
@@ -698,6 +715,58 @@
     }
   }
 
+  /**
+   * Verify that NoteDB commits do not persist user-sensitive information, by running checks for all
+   * commits in {@link RefNames#changeMetaRef} for all changes, created during the test.
+   *
+   * <p>These tests prevent regression, assuming appropriate test coverage for new features. The
+   * verification is disabled by default and can be enabled using {@link VerifyNoPiiInChangeNotes}
+   * annotation either on test class or method.
+   */
+  protected void verifyNoAccountDetailsInChangeNotes() throws RestApiException, IOException {
+    List<ChangeInfo> allChanges = gApi.changes().query().get();
+
+    List<AccountState> allAccounts = accounts.all();
+    for (ChangeInfo change : allChanges) {
+      try (Repository repo = repoManager.openRepository(Project.nameKey(change.project))) {
+        String metaRefName =
+            RefNames.changeMetaRef(Change.Id.tryParse(change._number.toString()).get());
+        ObjectId metaTip = repo.getRefDatabase().exactRef(metaRefName).getObjectId();
+        ChangeNotesRevWalk revWalk = ChangeNotesCommit.newRevWalk(repo);
+        revWalk.reset();
+        revWalk.markStart(revWalk.parseCommit(metaTip));
+        ChangeNotesCommit commit;
+        while ((commit = revWalk.next()) != null) {
+          String fullMessage = commit.getFullMessage();
+          for (AccountState accountState : allAccounts) {
+            Account account = accountState.account();
+            assertThat(fullMessage).doesNotContain(account.getName());
+            if (account.fullName() != null) {
+              assertThat(fullMessage).doesNotContain(account.fullName());
+            }
+            if (account.displayName() != null) {
+              assertThat(fullMessage).doesNotContain(account.displayName());
+            }
+            if (account.preferredEmail() != null) {
+              assertThat(fullMessage).doesNotContain(account.preferredEmail());
+            }
+            if (accountState.userName().isPresent()) {
+              assertThat(fullMessage).doesNotContain(accountState.userName().get());
+            }
+            List<String> allEmails =
+                accountState.externalIds().stream()
+                    .map(ExternalId::email)
+                    .filter(Objects::nonNull)
+                    .collect(toImmutableList());
+            for (String email : allEmails) {
+              assertThat(fullMessage).doesNotContain(email);
+            }
+          }
+        }
+      }
+    }
+  }
+
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
     return testRepo.branch("HEAD").commit().insertChangeId();
   }
@@ -959,6 +1028,10 @@
     return gApi.changes().id(id).get(options);
   }
 
+  protected AccountInfo getAccountInfo(Account.Id accountId) throws RestApiException {
+    return gApi.accounts().id(accountId.get()).get();
+  }
+
   protected List<ChangeInfo> query(String q) throws RestApiException {
     return gApi.changes().query(q).get();
   }
@@ -1173,7 +1246,7 @@
 
   protected void assertMailReplyTo(Message message, String email) throws Exception {
     assertThat(message.headers()).containsKey("Reply-To");
-    EmailHeader.String replyTo = (EmailHeader.String) message.headers().get("Reply-To");
+    StringEmailHeader replyTo = (StringEmailHeader) message.headers().get("Reply-To");
     assertThat(replyTo.getString()).contains(email);
   }
 
@@ -1267,6 +1340,7 @@
     assertThat(diff.diffHeader).isNotNull();
     assertThat(diff.intralineStatus).isNull();
     assertThat(diff.webLinks).isNull();
+    assertThat(diff.editWebLinks).isNull();
 
     assertThat(diff.metaA).isNull();
     assertThat(diff.metaB).isNotNull();
@@ -1526,6 +1600,14 @@
         ObjectId.fromString(get(changeId, ListChangesOption.CURRENT_REVISION).currentRevision));
   }
 
+  protected void configSubmitRequirement(
+      Project.NameKey project, SubmitRequirement submitRequirement) throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().upsertSubmitRequirement(submitRequirement);
+      u.save();
+    }
+  }
+
   protected void configLabel(String label, LabelFunction func) throws Exception {
     configLabel(label, func, ImmutableList.of());
   }
diff --git a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
index 452df67..6c9a2b1 100644
--- a/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.AddressList;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.entities.NotifyConfig.NotifyType;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -135,13 +136,13 @@
             fact("expected to have message sent with", "X-Gerrit-MessageType header"));
       }
       EmailHeader header = message.headers().get("X-Gerrit-MessageType");
-      if (!header.equals(new EmailHeader.String(messageType))) {
+      if (!header.equals(new StringEmailHeader(messageType))) {
         failWithoutActual(
             fact("expected message of type", messageType),
             fact(
                 "actual",
-                header instanceof EmailHeader.String
-                    ? ((EmailHeader.String) header).getString()
+                header instanceof StringEmailHeader
+                    ? ((StringEmailHeader) header).getString()
                     : header));
       }
 
diff --git a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
index 60def29..f22ddea 100644
--- a/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
+++ b/java/com/google/gerrit/acceptance/AbstractPluginLogFileTest.java
@@ -34,7 +34,7 @@
 import org.kohsuke.args4j.Option;
 
 public class AbstractPluginLogFileTest extends AbstractDaemonTest {
-  protected static class Module extends AbstractModule {
+  protected static class TestModule extends AbstractModule {
     @Override
     public void configure() {
       bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
diff --git a/java/com/google/gerrit/acceptance/AccountCreator.java b/java/com/google/gerrit/acceptance/AccountCreator.java
index 1b0954e..c67991d 100644
--- a/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -29,8 +29,9 @@
 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.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -52,18 +53,21 @@
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final GroupCache groupCache;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final ExternalIdFactory externalIdFactory;
 
   @Inject
   AccountCreator(
       Sequences sequences,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       GroupCache groupCache,
-      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+      @ServerInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      ExternalIdFactory externalIdFactory) {
     accounts = new HashMap<>();
     this.sequences = sequences;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.groupCache = groupCache;
     this.groupsUpdateProvider = groupsUpdateProvider;
+    this.externalIdFactory = externalIdFactory;
   }
 
   public synchronized TestAccount create(
@@ -84,11 +88,11 @@
     String httpPass = null;
     if (username != null) {
       httpPass = "http-pass";
-      extIds.add(ExternalId.createUsername(username, id, httpPass));
+      extIds.add(externalIdFactory.createUsername(username, id, httpPass));
     }
 
     if (email != null) {
-      extIds.add(ExternalId.createEmail(id, email));
+      extIds.add(externalIdFactory.createEmail(id, email));
     }
 
     accountsUpdateProvider
@@ -145,8 +149,8 @@
     return create("admin2", "admin2@example.com", "Administrator2", null, "Administrators");
   }
 
-  public TestAccount user() throws Exception {
-    return create("user", "user@example.com", "User", null);
+  public TestAccount user1() throws Exception {
+    return create("user1", "user1@example.com", "User1", null);
   }
 
   public TestAccount user2() throws Exception {
@@ -168,10 +172,10 @@
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
             .build();
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 }
diff --git a/java/com/google/gerrit/acceptance/BUILD b/java/com/google/gerrit/acceptance/BUILD
index 5ee1a08..d6dff8f 100644
--- a/java/com/google/gerrit/acceptance/BUILD
+++ b/java/com/google/gerrit/acceptance/BUILD
@@ -75,12 +75,12 @@
     "//java/com/google/gerrit/extensions/restapi/testing:restapi-test-util",
     "//java/com/google/gerrit/gpg/testing:gpg-test-util",
     "//java/com/google/gerrit/git/testing",
+    "//java/com/google/gerrit/index/testing",
 ]
 
 PGM_DEPLOY_ENV = [
     "//lib:caffeine",
     "//lib:caffeine-guava",
-    "//lib/jackson:jackson-core",
     "//lib/prolog:cafeteria",
 ]
 
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index d72ee3f..3844788 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.events.ProjectIndexedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
@@ -32,9 +34,11 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.registration.PrivateInternals_DynamicMapImpl;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.ExceptionHook;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.change.ChangeETagComputation;
@@ -76,6 +80,8 @@
   private final DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners;
   private final DynamicSet<FileHistoryWebLink> fileHistoryWebLinks;
   private final DynamicSet<PatchSetWebLink> patchSetWebLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks;
+  private final DynamicSet<EditWebLink> editWebLinks;
   private final DynamicSet<FileWebLink> fileWebLinks;
   private final DynamicSet<RevisionCreatedListener> revisionCreatedListeners;
   private final DynamicSet<GroupBackend> groupBackends;
@@ -89,6 +95,8 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final DynamicSet<OnPostReview> onPostReviews;
+  private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
+  private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
 
   @Inject
   ExtensionRegistry(
@@ -111,6 +119,8 @@
       DynamicSet<GitReferenceUpdatedListener> refUpdatedListeners,
       DynamicSet<FileHistoryWebLink> fileHistoryWebLinks,
       DynamicSet<PatchSetWebLink> patchSetWebLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsWebLinks,
+      DynamicSet<EditWebLink> editWebLinks,
       DynamicSet<FileWebLink> fileWebLinks,
       DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
       DynamicSet<GroupBackend> groupBackends,
@@ -122,7 +132,9 @@
       DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       DynamicSet<PluginPushOption> pluginPushOption,
-      DynamicSet<OnPostReview> onPostReviews) {
+      DynamicSet<OnPostReview> onPostReviews,
+      DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
+      DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners) {
     this.accountIndexedListeners = accountIndexedListeners;
     this.changeIndexedListeners = changeIndexedListeners;
     this.groupIndexedListeners = groupIndexedListeners;
@@ -142,7 +154,9 @@
     this.refUpdatedListeners = refUpdatedListeners;
     this.fileHistoryWebLinks = fileHistoryWebLinks;
     this.patchSetWebLinks = patchSetWebLinks;
+    this.editWebLinks = editWebLinks;
     this.fileWebLinks = fileWebLinks;
+    this.resolveConflictsWebLinks = resolveConflictsWebLinks;
     this.revisionCreatedListeners = revisionCreatedListeners;
     this.groupBackends = groupBackends;
     this.accountActivationValidationListeners = accountActivationValidationListeners;
@@ -154,6 +168,8 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.pluginPushOptions = pluginPushOption;
     this.onPostReviews = onPostReviews;
+    this.reviewerAddedListeners = reviewerAddedListeners;
+    this.reviewerDeletedListeners = reviewerDeletedListeners;
   }
 
   public Registration newRegistration() {
@@ -244,6 +260,14 @@
       return add(patchSetWebLinks, patchSetWebLink);
     }
 
+    public Registration add(ResolveConflictsWebLink resolveConflictsWebLink) {
+      return add(resolveConflictsWebLinks, resolveConflictsWebLink);
+    }
+
+    public Registration add(EditWebLink editWebLink) {
+      return add(editWebLinks, editWebLink);
+    }
+
     public Registration add(FileWebLink fileWebLink) {
       return add(fileWebLinks, fileWebLink);
     }
@@ -294,6 +318,14 @@
       return add(onPostReviews, onPostReview);
     }
 
+    public Registration add(ReviewerAddedListener reviewerAddedListener) {
+      return add(reviewerAddedListeners, reviewerAddedListener);
+    }
+
+    public Registration add(ReviewerDeletedListener reviewerDeletedListener) {
+      return add(reviewerDeletedListeners, reviewerDeletedListener);
+    }
+
     private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
       return add(dynamicSet, extension, "gerrit");
     }
diff --git a/java/com/google/gerrit/acceptance/FakeGroupAuditService.java b/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
index 48dc408..a1c28b9 100644
--- a/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
+++ b/java/com/google/gerrit/acceptance/FakeGroupAuditService.java
@@ -39,7 +39,7 @@
 
 @Singleton
 public class FakeGroupAuditService extends AuditService {
-  public static class Module extends AbstractModule {
+  public static class FakeGroupAuditServiceModule extends AbstractModule {
     @Override
     public void configure() {
       DynamicSet.setOf(binder(), GroupAuditListener.class);
diff --git a/java/com/google/gerrit/acceptance/FakeSubmitRule.java b/java/com/google/gerrit/acceptance/FakeSubmitRule.java
new file mode 100644
index 0000000..1fbb9cf
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/FakeSubmitRule.java
@@ -0,0 +1,34 @@
+// 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;
+
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Status;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.SubmitRule;
+import com.google.inject.Singleton;
+import java.util.Optional;
+
+/** Fake submit rule that returns OK if the change contains one or more hashtags. */
+@Singleton
+public class FakeSubmitRule implements SubmitRule {
+  @Override
+  public Optional<SubmitRecord> evaluate(ChangeData cd) {
+    SubmitRecord record = new SubmitRecord();
+    record.status = cd.hashtags().isEmpty() ? Status.NOT_READY : Status.OK;
+    record.ruleName = FakeSubmitRule.class.getSimpleName();
+    return Optional.of(record);
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index d1e12f8..f13f02e 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -23,6 +23,9 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.FakeGroupAuditService.FakeGroupAuditServiceModule;
+import com.google.gerrit.acceptance.ReindexGroupsAtStartup.ReindexGroupsAtStartupModule;
+import com.google.gerrit.acceptance.ReindexProjectsAtStartup.ReindexProjectsAtStartupModule;
 import com.google.gerrit.acceptance.config.ConfigAnnotationParser;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.config.GerritConfigs;
@@ -43,22 +46,26 @@
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperationsImpl;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.FakeIndexModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.Daemon;
 import com.google.gerrit.pgm.Init;
 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.experiments.ConfigExperimentFeatures.ConfigExperimentFeaturesModule;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.index.options.AutoFlush;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.util.ReplicaUtil;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
-import com.google.gerrit.testing.FakeEmailSender;
+import com.google.gerrit.testing.FakeEmailSender.FakeEmailSenderModule;
 import com.google.gerrit.testing.InMemoryRepositoryManager;
 import com.google.gerrit.testing.SshMode;
 import com.google.gerrit.testing.TestLoggingActivator;
@@ -110,6 +117,8 @@
   public abstract static class Description {
     public static Description forTestClass(
         org.junit.runner.Description testDesc, String configName) {
+      VerifyNoPiiInChangeNotes verifyNoPiiInChangeNotes =
+          get(VerifyNoPiiInChangeNotes.class, testDesc.getTestClass());
       return new AutoValue_GerritServer_Description(
           testDesc,
           configName,
@@ -118,6 +127,7 @@
           has(Sandboxed.class, testDesc.getTestClass()),
           has(SkipProjectClone.class, testDesc.getTestClass()),
           has(UseSsh.class, testDesc.getTestClass()),
+          verifyNoPiiInChangeNotes != null && verifyNoPiiInChangeNotes.value(),
           false, // @UseSystemTime is only valid on methods.
           get(UseClockStep.class, testDesc.getTestClass()),
           get(UseTimezone.class, testDesc.getTestClass()),
@@ -137,6 +147,11 @@
         // on class level.
         useClockStep = get(UseClockStep.class, testDesc.getTestClass());
       }
+      VerifyNoPiiInChangeNotes verifyNoPiiInChangeNotes =
+          testDesc.getAnnotation(VerifyNoPiiInChangeNotes.class);
+      if (verifyNoPiiInChangeNotes == null) {
+        verifyNoPiiInChangeNotes = get(VerifyNoPiiInChangeNotes.class, testDesc.getTestClass());
+      }
 
       return new AutoValue_GerritServer_Description(
           testDesc,
@@ -152,6 +167,7 @@
               || has(SkipProjectClone.class, testDesc.getTestClass()),
           testDesc.getAnnotation(UseSsh.class) != null
               || has(UseSsh.class, testDesc.getTestClass()),
+          verifyNoPiiInChangeNotes != null && verifyNoPiiInChangeNotes.value(),
           testDesc.getAnnotation(UseSystemTime.class) != null,
           useClockStep,
           testDesc.getAnnotation(UseTimezone.class) != null
@@ -197,6 +213,8 @@
 
     abstract boolean useSshAnnotation();
 
+    abstract boolean verifyNoPiiInChangeNotes();
+
     boolean useSsh() {
       return useSshAnnotation() && SshMode.useSsh();
     }
@@ -279,7 +297,6 @@
    * @param baseConfig default config values; merged with config from {@code desc} and then written
    *     into {@code site/etc/gerrit.config}.
    * @param site temp directory where site will live.
-   * @throws Exception
    */
   public static void init(Description desc, Config baseConfig, Path site) throws Exception {
     checkArgument(!desc.memory(), "can't initialize site path for in-memory test: %s", desc);
@@ -292,6 +309,12 @@
     gerritConfig.load();
     gerritConfig.merge(cfg);
     mergeTestConfig(gerritConfig);
+    String configuredIndexBackend = cfg.getString("index", null, "type");
+    if (configuredIndexBackend == null) {
+      // Propagate index type to pgms that run off of the gerrit.config file on local disk.
+      IndexType indexType = IndexType.fromEnvironment().orElse(new IndexType("fake"));
+      gerritConfig.setString("index", null, "type", indexType.isLucene() ? "lucene" : "fake");
+    }
     gerritConfig.save();
 
     Init init = new Init();
@@ -327,7 +350,6 @@
    * @param testSysModule additional Guice module to use.
    * @param testSshModule additional Guice module to use.
    * @return started server.
-   * @throws Exception
    */
   public static GerritServer initAndStart(
       TemporaryFolder temporaryFolder,
@@ -364,7 +386,6 @@
    * @param additionalArgs additional command-line arguments for the daemon program; only allowed if
    *     the test is not in-memory.
    * @return started server.
-   * @throws Exception
    */
   public static GerritServer start(
       Description desc,
@@ -390,9 +411,9 @@
               }
             },
             site);
-    daemon.setEmailModuleForTesting(new FakeEmailSender.Module());
+    daemon.setEmailModuleForTesting(new FakeEmailSenderModule());
     daemon.setAuditEventModuleForTesting(
-        MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditService.Module()));
+        MoreObjects.firstNonNull(testAuditModule, new FakeGroupAuditServiceModule()));
     if (testSysModule != null) {
       daemon.addAdditionalSysModuleForTesting(testSysModule);
     }
@@ -400,6 +421,15 @@
       daemon.addAdditionalSshModuleForTesting(testSshModule);
     }
     daemon.setEnableSshd(desc.useSsh());
+    daemon.addAdditionalSysModuleForTesting(
+        new AbstractModule() {
+          @Override
+          protected void configure() {
+            bind(CommitValidationListener.class)
+                .annotatedWith(Exports.named("object-visibility-listener"))
+                .to(GitObjectVisibilityChecker.class);
+          }
+        });
 
     if (desc.memory()) {
       checkArgument(additionalArgs.length == 0, "cannot pass args to in-memory server");
@@ -428,10 +458,29 @@
     cfg.setString("gitweb", null, "cgi", "");
     cfg.setString(
         "accountPatchReviewDb", null, "url", JdbcAccountPatchReviewStore.TEST_IN_MEMORY_URL);
+
+    String configuredIndexBackend = cfg.getString("index", null, "type");
+    IndexType indexType;
+    if (configuredIndexBackend != null) {
+      // Explicitly configured index backend from gerrit.config trumps any other ways to configure
+      // index backends so that Reindex tests can be explicit about the backend they want to test
+      // against.
+      indexType = new IndexType(configuredIndexBackend);
+    } else {
+      // Allow configuring the index backend based on sys/env variables so that integration tests
+      // can be run against different index backends.
+      indexType = IndexType.fromEnvironment().orElse(new IndexType("fake"));
+    }
+    if (indexType.isLucene()) {
+      daemon.setIndexModule(
+          LuceneIndexModule.singleVersionAllLatest(
+              0, ReplicaUtil.isReplica(baseConfig), AutoFlush.ENABLED));
+    } else {
+      daemon.setIndexModule(FakeIndexModule.latestVersion(false));
+    }
+
     daemon.setEnableHttpd(desc.httpd());
-    daemon.setLuceneModule(
-        LuceneIndexModule.singleVersionAllLatest(
-            0, ReplicaUtil.isReplica(baseConfig), AutoFlush.ENABLED));
+    daemon.setInMemory(true);
     daemon.setDatabaseForTesting(
         ImmutableList.of(
             new InMemoryTestingDatabaseModule(cfg, site, inMemoryRepoManager),
@@ -441,9 +490,9 @@
                 bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
               }
             },
-            new ConfigExperimentFeatures.Module()));
+            new ConfigExperimentFeaturesModule()));
     daemon.addAdditionalSysModuleForTesting(
-        new ReindexProjectsAtStartup.Module(), new ReindexGroupsAtStartup.Module());
+        new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
     daemon.start();
     return new GerritServer(desc, null, createTestInjector(daemon), daemon, null);
   }
@@ -456,6 +505,8 @@
       String[] additionalArgs)
       throws Exception {
     requireNonNull(site);
+    daemon.addAdditionalSysModuleForTesting(
+        new ReindexProjectsAtStartupModule(), new ReindexGroupsAtStartupModule());
     ExecutorService daemonService = Executors.newSingleThreadExecutor();
     String[] args =
         Stream.concat(
@@ -534,7 +585,7 @@
             factory(PushOneCommit.Factory.class);
             install(InProcessProtocol.module());
             install(new NoSshModule());
-            install(new AsyncReceiveCommits.Module());
+            install(new AsyncReceiveCommitsModule());
             factory(ProjectResetter.Builder.Factory.class);
           }
 
diff --git a/java/com/google/gerrit/acceptance/GitObjectVisibilityChecker.java b/java/com/google/gerrit/acceptance/GitObjectVisibilityChecker.java
new file mode 100644
index 0000000..afa1b1d
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/GitObjectVisibilityChecker.java
@@ -0,0 +1,71 @@
+// 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;
+
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Checker that ensures that all Git commits that should be validated are readable using any {@link
+ * ObjectReader} on the repo. It is easy for users of the JGit API to forget to call {@code flush}
+ * on ObjectInserter which creates an illegal state for CommitValidators. This safeguard makes sure
+ * that any functionality tested in acceptance tests got this right.
+ */
+@Singleton
+public class GitObjectVisibilityChecker implements CommitValidationListener {
+  private final GitRepositoryManager gitRepositoryManager;
+
+  @Inject
+  GitObjectVisibilityChecker(GitRepositoryManager gitRepositoryManager) {
+    this.gitRepositoryManager = gitRepositoryManager;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    try {
+      try (Repository repo = gitRepositoryManager.openRepository(receiveEvent.getProjectNameKey());
+          ObjectReader reader = repo.newObjectReader()) {
+        if (!reader.has(receiveEvent.commit)) {
+          throw new IllegalStateException(
+              String.format(
+                  "Commit %s was not visible using a new object reader in the repo. "
+                      + "This creates an illegal state for commit validators. You must flush any ObjectReaders "
+                      + "before performing the ref transaction.",
+                  receiveEvent.commit));
+        }
+      }
+    } catch (IOException e) {
+      throw new StorageException(e);
+    }
+    return Collections.emptyList();
+  }
+
+  @Override
+  public boolean shouldValidateAllCommits() {
+    return true;
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
index de9a43d..373246a 100644
--- a/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
+++ b/java/com/google/gerrit/acceptance/InMemoryTestingDatabaseModule.java
@@ -21,7 +21,11 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.AllProjectsConfigProvider;
+import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.config.TrackingFooters;
@@ -54,6 +58,8 @@
   @Override
   protected void configure() {
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
+    bind(AllProjectsConfigProvider.class).to(FileBasedAllProjectsConfigProvider.class);
     bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
 
     if (repoManager != null) {
diff --git a/java/com/google/gerrit/acceptance/PushOneCommit.java b/java/com/google/gerrit/acceptance/PushOneCommit.java
index 67e26ec..d46fb78 100644
--- a/java/com/google/gerrit/acceptance/PushOneCommit.java
+++ b/java/com/google/gerrit/acceptance/PushOneCommit.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -282,6 +282,11 @@
     return this;
   }
 
+  public PushOneCommit noParent() throws Exception {
+    commitBuilder.noParents();
+    return this;
+  }
+
   public PushOneCommit addSymlink(String path, String target) throws Exception {
     RevBlob blobId = testRepo.blob(target);
     commitBuilder.edit(
diff --git a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
index 0b2282e..ba1c3cd 100644
--- a/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
+++ b/java/com/google/gerrit/acceptance/ReindexGroupsAtStartup.java
@@ -34,7 +34,7 @@
   private final Groups groups;
   private final Config cfg;
 
-  public static class Module extends LifecycleModule {
+  public static class ReindexGroupsAtStartupModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(ReindexGroupsAtStartup.class).in(Scopes.SINGLETON);
diff --git a/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java b/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java
index 2f0ffcb..991136f 100644
--- a/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java
+++ b/java/com/google/gerrit/acceptance/ReindexProjectsAtStartup.java
@@ -26,7 +26,7 @@
   private final ProjectIndexer projectIndexer;
   private final GitRepositoryManager repoMgr;
 
-  public static class Module extends LifecycleModule {
+  public static class ReindexProjectsAtStartupModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(ReindexProjectsAtStartup.class).in(Scopes.SINGLETON);
diff --git a/java/com/google/gerrit/acceptance/RestSession.java b/java/com/google/gerrit/acceptance/RestSession.java
index 7ee1b26..342cbd0 100644
--- a/java/com/google/gerrit/acceptance/RestSession.java
+++ b/java/com/google/gerrit/acceptance/RestSession.java
@@ -37,17 +37,17 @@
   }
 
   public RestResponse get(String endPoint) throws IOException {
-    return getWithHeader(endPoint, null);
+    return getWithHeaders(endPoint);
   }
 
   public RestResponse getJsonAccept(String endPoint) throws IOException {
-    return getWithHeader(endPoint, new BasicHeader(ACCEPT, "application/json"));
+    return getWithHeaders(endPoint, new BasicHeader(ACCEPT, "application/json"));
   }
 
-  public RestResponse getWithHeader(String endPoint, Header header) throws IOException {
+  public RestResponse getWithHeaders(String endPoint, Header... headers) throws IOException {
     Request get = Request.Get(getUrl(endPoint));
-    if (header != null) {
-      get.addHeader(header);
+    if (headers != null) {
+      get.setHeaders(headers);
     }
     return execute(get);
   }
@@ -57,22 +57,22 @@
   }
 
   public RestResponse put(String endPoint) throws IOException {
-    return put(endPoint, null);
+    return put(endPoint, /* content = */ null);
   }
 
   public RestResponse put(String endPoint, Object content) throws IOException {
-    return putWithHeader(endPoint, null, content);
+    return putWithHeaders(endPoint, content);
   }
 
-  public RestResponse putWithHeader(String endPoint, Header header) throws IOException {
-    return putWithHeader(endPoint, header, null);
+  public RestResponse putWithHeaders(String endPoint, Header... headers) throws IOException {
+    return putWithHeaders(endPoint, /* content= */ null, headers);
   }
 
-  public RestResponse putWithHeader(String endPoint, Header header, Object content)
+  public RestResponse putWithHeaders(String endPoint, Object content, Header... headers)
       throws IOException {
     Request put = Request.Put(getUrl(endPoint));
-    if (header != null) {
-      put.addHeader(header);
+    if (headers != null) {
+      put.setHeaders(headers);
     }
     if (content != null) {
       addContentToRequest(put, content);
@@ -91,18 +91,18 @@
   }
 
   public RestResponse post(String endPoint) throws IOException {
-    return post(endPoint, null);
+    return post(endPoint, /* content = */ null);
   }
 
   public RestResponse post(String endPoint, Object content) throws IOException {
-    return postWithHeader(endPoint, null, content);
+    return postWithHeaders(endPoint, content);
   }
 
-  public RestResponse postWithHeader(String endPoint, Header header, Object content)
+  public RestResponse postWithHeaders(String endPoint, Object content, Header... headers)
       throws IOException {
     Request post = Request.Post(getUrl(endPoint));
-    if (header != null) {
-      post.addHeader(header);
+    if (headers != null) {
+      post.setHeaders(headers);
     }
     if (content != null) {
       addContentToRequest(post, content);
@@ -119,6 +119,14 @@
     return execute(Request.Delete(getUrl(endPoint)));
   }
 
+  public RestResponse deleteWithHeaders(String endPoint, Header... headers) throws IOException {
+    Request delete = Request.Delete(getUrl(endPoint));
+    if (headers != null) {
+      delete.setHeaders(headers);
+    }
+    return execute(delete);
+  }
+
   private String getUrl(String endPoint) {
     return url + (account != null ? "/a" : "") + endPoint;
   }
diff --git a/java/com/google/gerrit/acceptance/SshSessionMina.java b/java/com/google/gerrit/acceptance/SshSessionMina.java
index 4d8691b..3b0ba3b 100644
--- a/java/com/google/gerrit/acceptance/SshSessionMina.java
+++ b/java/com/google/gerrit/acceptance/SshSessionMina.java
@@ -107,13 +107,11 @@
   @Override
   public int execAndReturnStatus(String command) throws Exception {
     Process process = getMinaSession().exec(command, 0);
-    InputStream in = process.getInputStream();
     InputStream err = process.getErrorStream();
 
     Scanner s = new Scanner(err, UTF_8.name()).useDelimiter("\\A");
     error = s.hasNext() ? s.next() : null;
 
-    s = new Scanner(in, UTF_8.name()).useDelimiter("\\A");
     try {
       return process.exitValue();
     } catch (IllegalThreadStateException e) {
diff --git a/java/com/google/gerrit/acceptance/TestMetricMaker.java b/java/com/google/gerrit/acceptance/TestMetricMaker.java
index d60ef1a..2620f99 100644
--- a/java/com/google/gerrit/acceptance/TestMetricMaker.java
+++ b/java/com/google/gerrit/acceptance/TestMetricMaker.java
@@ -34,11 +34,11 @@
  *
  * <pre>
  * public class MyTest extends AbstractDaemonTest {
- *   @Inject private TestMetricMaker testMetricMaker;
+ *   {@literal @}Inject private TestMetricMaker testMetricMaker;
  *
  *   ...
  *
- *   @Test
+ *   {@literal @}Test
  *   public void testFoo() throws Exception {
  *     testMetricMaker.reset();
  *     doSomething();
diff --git a/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java b/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java
new file mode 100644
index 0000000..1bdaa6e
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/VerifyNoPiiInChangeNotes.java
@@ -0,0 +1,35 @@
+// 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;
+
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Annotation for the acceptance tests, inherited from {@link AbstractDaemonTest}, to enable
+ * verification that NoteDB commits do not persist user-sensitive information. See {@link
+ * AbstractDaemonTest#verifyNoAccountDetailsInChangeNotes}.
+ *
+ * <p>Disabled by default, can be enabled per test class/test method.
+ */
+@Target({TYPE, METHOD})
+@Retention(RUNTIME)
+public @interface VerifyNoPiiInChangeNotes {
+  boolean value() default false;
+}
diff --git a/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java b/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
index ae88e37..87063c9 100644
--- a/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
+++ b/java/com/google/gerrit/acceptance/config/GlobalPluginConfig.java
@@ -28,12 +28,12 @@
   /** Name of the plugin, corresponding to {@code $site/etc/@pluginName.config}. */
   String pluginName();
 
-  /** @see GerritConfig#name() */
+  /** See {@link GerritConfig#name()} */
   String name();
 
-  /** @see GerritConfig#value() */
+  /** See {@link GerritConfig#value()} */
   String value() default "";
 
-  /** @see GerritConfig#values() */
+  /** See {@link GerritConfig#values()} */
   String[] values() default "";
 }
diff --git a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
index 8c1eebd..c6457a4 100644
--- a/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/account/AccountOperationsImpl.java
@@ -21,15 +21,18 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.InternalAccountUpdate;
+import com.google.gerrit.server.account.AccountsUpdate.ConfigureDeltaFromState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Optional;
+import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /**
@@ -42,13 +45,18 @@
   private final Accounts accounts;
   private final AccountsUpdate accountsUpdate;
   private final Sequences seq;
+  private final ExternalIdFactory externalIdFactory;
 
   @Inject
   public AccountOperationsImpl(
-      Accounts accounts, @ServerInitiated AccountsUpdate accountsUpdate, Sequences seq) {
+      Accounts accounts,
+      @ServerInitiated AccountsUpdate accountsUpdate,
+      Sequences seq,
+      ExternalIdFactory externalIdFactory) {
     this.accounts = accounts;
     this.accountsUpdate = accountsUpdate;
     this.seq = seq;
+    this.externalIdFactory = externalIdFactory;
   }
 
   @Override
@@ -61,24 +69,17 @@
     return TestAccountCreation.builder(this::createAccount);
   }
 
-  private Account.Id createAccount(TestAccountCreation accountCreation) throws Exception {
-    AccountsUpdate.AccountUpdater accountUpdater =
-        (accountState, updateBuilder) ->
-            fillBuilder(updateBuilder, accountCreation, accountState.account().id());
-    AccountState createdAccount = createAccount(accountUpdater);
+  private Account.Id createAccount(TestAccountCreation testAccountCreation) throws Exception {
+    Account.Id accountId = Account.id(seq.nextAccountId());
+    Consumer<AccountDelta.Builder> accountCreation =
+        deltaBuilder -> initAccountDelta(deltaBuilder, testAccountCreation, accountId);
+    AccountState createdAccount =
+        accountsUpdate.insert("Create Test Account", accountId, accountCreation);
     return createdAccount.account().id();
   }
 
-  private AccountState createAccount(AccountsUpdate.AccountUpdater accountUpdater)
-      throws IOException, ConfigInvalidException {
-    Account.Id accountId = Account.id(seq.nextAccountId());
-    return accountsUpdate.insert("Create Test Account", accountId, accountUpdater);
-  }
-
-  private static void fillBuilder(
-      InternalAccountUpdate.Builder builder,
-      TestAccountCreation accountCreation,
-      Account.Id accountId) {
+  private void initAccountDelta(
+      AccountDelta.Builder builder, TestAccountCreation accountCreation, Account.Id accountId) {
     accountCreation.fullname().ifPresent(builder::setFullName);
     accountCreation.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
     String httpPassword = accountCreation.httpPassword().orElse(null);
@@ -89,22 +90,19 @@
         .secondaryEmails()
         .forEach(
             secondaryEmail ->
-                builder.addExternalId(ExternalId.createEmail(accountId, secondaryEmail)));
+                builder.addExternalId(externalIdFactory.createEmail(accountId, secondaryEmail)));
   }
 
-  private static InternalAccountUpdate.Builder setPreferredEmail(
-      InternalAccountUpdate.Builder builder, Account.Id accountId, String preferredEmail) {
-    return builder
+  private void setPreferredEmail(
+      AccountDelta.Builder builder, Account.Id accountId, String preferredEmail) {
+    builder
         .setPreferredEmail(preferredEmail)
-        .addExternalId(ExternalId.createEmail(accountId, preferredEmail));
+        .addExternalId(externalIdFactory.createEmail(accountId, preferredEmail));
   }
 
-  private static InternalAccountUpdate.Builder setUsername(
-      InternalAccountUpdate.Builder builder,
-      Account.Id accountId,
-      String username,
-      String httpPassword) {
-    return builder.addExternalId(ExternalId.createUsername(username, accountId, httpPassword));
+  private void setUsername(
+      AccountDelta.Builder builder, Account.Id accountId, String username, String httpPassword) {
+    builder.addExternalId(externalIdFactory.createUsername(username, accountId, httpPassword));
   }
 
   private class PerAccountOperationsImpl implements PerAccountOperations {
@@ -155,21 +153,19 @@
 
     private void updateAccount(TestAccountUpdate accountUpdate)
         throws IOException, ConfigInvalidException {
-      AccountsUpdate.AccountUpdater accountUpdater =
-          (accountState, updateBuilder) -> fillBuilder(updateBuilder, accountUpdate, accountState);
-      Optional<AccountState> updatedAccount = updateAccount(accountUpdater);
+      ConfigureDeltaFromState configureDeltaFromState =
+          (accountState, deltaBuilder) -> fillBuilder(deltaBuilder, accountUpdate, accountState);
+      Optional<AccountState> updatedAccount = updateAccount(configureDeltaFromState);
       checkState(updatedAccount.isPresent(), "Tried to update non-existing test account");
     }
 
-    private Optional<AccountState> updateAccount(AccountsUpdate.AccountUpdater accountUpdater)
+    private Optional<AccountState> updateAccount(ConfigureDeltaFromState configureDeltaFromState)
         throws IOException, ConfigInvalidException {
-      return accountsUpdate.update("Update Test Account", accountId, accountUpdater);
+      return accountsUpdate.update("Update Test Account", accountId, configureDeltaFromState);
     }
 
     private void fillBuilder(
-        InternalAccountUpdate.Builder builder,
-        TestAccountUpdate accountUpdate,
-        AccountState accountState) {
+        AccountDelta.Builder builder, TestAccountUpdate accountUpdate, AccountState accountState) {
       accountUpdate.fullname().ifPresent(builder::setFullName);
       accountUpdate.preferredEmail().ifPresent(e -> setPreferredEmail(builder, accountId, e));
       String httpPassword = accountUpdate.httpPassword().orElse(null);
@@ -200,7 +196,7 @@
     }
 
     private void setSecondaryEmails(
-        InternalAccountUpdate.Builder builder,
+        AccountDelta.Builder builder,
         TestAccountUpdate accountUpdate,
         AccountState accountState,
         ImmutableSet<String> newSecondaryEmails) {
@@ -212,14 +208,14 @@
               .collect(toImmutableSet()));
       builder.addExternalIds(
           newSecondaryEmails.stream()
-              .map(secondaryEmail -> ExternalId.createEmail(accountId, secondaryEmail))
+              .map(secondaryEmail -> externalIdFactory.createEmail(accountId, secondaryEmail))
               .collect(toImmutableSet()));
       if (accountUpdate.preferredEmail().isPresent()) {
         builder.addExternalId(
-            ExternalId.createEmail(accountId, accountUpdate.preferredEmail().get()));
+            externalIdFactory.createEmail(accountId, accountUpdate.preferredEmail().get()));
       } else if (accountState.account().preferredEmail() != null) {
         builder.addExternalId(
-            ExternalId.createEmail(accountId, accountState.account().preferredEmail()));
+            externalIdFactory.createEmail(accountId, accountState.account().preferredEmail()));
       }
     }
 
@@ -235,8 +231,8 @@
 
       if (testAccountInvalidation.preferredEmailWithoutExternalId().isPresent()) {
         updateAccount(
-            (account, updateBuilder) ->
-                updateBuilder.setPreferredEmail(
+            (account, deltaBuilder) ->
+                deltaBuilder.setPreferredEmail(
                     testAccountInvalidation.preferredEmailWithoutExternalId().get()));
       }
     }
diff --git a/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
new file mode 100644
index 0000000..1038a14
--- /dev/null
+++ b/java/com/google/gerrit/acceptance/testsuite/change/ChangeKindCreator.java
@@ -0,0 +1,339 @@
+// 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.testsuite.change;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static org.eclipse.jgit.lib.Constants.HEAD;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+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.Project;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.client.ListChangesOption;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/** Helper to create changes of a certain {@link ChangeKind}. */
+public class ChangeKindCreator {
+  private GerritApi gApi;
+  private PushOneCommit.Factory pushFactory;
+  private RequestScopeOperations requestScopeOperations;
+  private ProjectOperations projectOperations;
+
+  @Inject
+  private ChangeKindCreator(
+      GerritApi gApi,
+      PushOneCommit.Factory pushFactory,
+      RequestScopeOperations requestScopeOperations,
+      ProjectOperations projectOperations) {
+    this.gApi = gApi;
+    this.pushFactory = pushFactory;
+    this.requestScopeOperations = requestScopeOperations;
+    this.projectOperations = projectOperations;
+  }
+
+  /** Creates a change with the given {@link ChangeKind} and returns the change id. */
+  public String createChange(
+      ChangeKind kind, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    switch (kind) {
+      case NO_CODE_CHANGE:
+      case REWORK:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return createChange(testRepo, user).getChangeId();
+      case MERGE_FIRST_PARENT_UPDATE:
+        return createChangeForMergeCommit(testRepo, user);
+      default:
+        throw new IllegalStateException("unexpected change kind: " + kind);
+    }
+  }
+
+  /** Updates a change with the given {@link ChangeKind}. */
+  public void updateChange(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case NO_CODE_CHANGE:
+        noCodeChange(changeId, testRepo, user);
+        return;
+      case REWORK:
+        rework(changeId, testRepo, user);
+        return;
+      case TRIVIAL_REBASE:
+        trivialRebase(changeId, testRepo, user, project);
+        return;
+      case MERGE_FIRST_PARENT_UPDATE:
+        updateFirstParent(changeId, testRepo, user);
+        return;
+      case NO_CHANGE:
+        noChange(changeId, testRepo, user);
+        return;
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+  }
+
+  /**
+   * Creates a cherry pick of the provided change with the given {@link ChangeKind} and returns the
+   * change id.
+   */
+  public String cherryPick(
+      String changeId,
+      ChangeKind changeKind,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    switch (changeKind) {
+      case REWORK:
+      case TRIVIAL_REBASE:
+        break;
+      case NO_CODE_CHANGE:
+      case NO_CHANGE:
+      case MERGE_FIRST_PARENT_UPDATE:
+      default:
+        assertWithMessage("unexpected change kind: " + changeKind).fail();
+    }
+
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit.Result r =
+        pushFactory
+            .create(
+                user.newIdent(),
+                testRepo,
+                PushOneCommit.SUBJECT,
+                "other.txt",
+                "new content " + System.nanoTime())
+            .to("refs/for/master");
+    r.assertOkStatus();
+    vote(user, r.getChangeId(), 2, 1);
+    merge(r);
+
+    String subject =
+        ChangeKind.TRIVIAL_REBASE.equals(changeKind)
+            ? PushOneCommit.SUBJECT
+            : "Reworked change " + System.nanoTime();
+    CherryPickInput in = new CherryPickInput();
+    in.destination = "master";
+    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
+    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
+    return c.changeId;
+  }
+
+  /** Creates a change that is a merge {@link ChangeKind} and returns the change id. */
+  public String createChangeForMergeCommit(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    ObjectId initial = testRepo.getRepository().exactRef(HEAD).getLeaf().getObjectId();
+
+    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1", testRepo, user);
+
+    testRepo.reset(initial);
+    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2", testRepo, user);
+
+    testRepo.reset(parent1.getCommit());
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo);
+    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+    return result.getChangeId();
+  }
+
+  /** Update the first parent of a merge. */
+  public void updateFirstParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
+
+    testRepo.reset(parent1);
+    PushOneCommit.Result newParent1 =
+        createChange("new parent 1", "p1-1.txt", "content 1-1", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.MERGE_FIRST_PARENT_UPDATE);
+  }
+
+  /** Update the second parent of a merge. */
+  public void updateSecondParent(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo c = detailedChange(changeId);
+    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
+    String parent1 = parents.get(0).commit;
+    String parent2 = parents.get(1).commit;
+    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
+
+    testRepo.reset(parent2);
+    PushOneCommit.Result newParent2 =
+        createChange("new parent 2", "p2-2.txt", "content 2-2", testRepo, user);
+
+    PushOneCommit merge = pushFactory.create(user.newIdent(), testRepo, changeId);
+    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
+    PushOneCommit.Result result = merge.to("refs/for/master");
+    result.assertOkStatus();
+
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void noCodeChange(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message("New subject " + System.nanoTime())
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CODE_CHANGE);
+  }
+
+  private void noChange(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
+
+    TestRepository<?>.CommitBuilder commitBuilder =
+        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
+    commitBuilder
+        .message(commitMessage)
+        .author(user.newIdent())
+        .committer(new PersonIdent(user.newIdent(), testRepo.getDate()));
+    commitBuilder.create();
+    GitUtil.pushHead(testRepo, "refs/for/master", false);
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.NO_CHANGE);
+  }
+
+  private void rework(
+      String changeId, TestRepository<InMemoryRepository> testRepo, TestAccount user)
+      throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT,
+            PushOneCommit.FILE_NAME,
+            "new content " + System.nanoTime(),
+            changeId);
+    push.to("refs/for/master").assertOkStatus();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.REWORK);
+  }
+
+  private void trivialRebase(
+      String changeId,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user,
+      Project.NameKey project)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    PushOneCommit push =
+        pushFactory.create(
+            user.newIdent(),
+            testRepo,
+            "Other Change",
+            "a" + System.nanoTime() + ".txt",
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
+    revision.review(in);
+    revision.submit();
+
+    gApi.changes().id(changeId).current().rebase();
+    assertThat(getChangeKind(changeId)).isEqualTo(ChangeKind.TRIVIAL_REBASE);
+  }
+
+  private ChangeKind getChangeKind(String changeId) throws Exception {
+    ChangeInfo c = gApi.changes().id(changeId).get(ListChangesOption.CURRENT_REVISION);
+    return c.revisions.get(c.currentRevision).kind;
+  }
+
+  private PushOneCommit.Result createChange(
+      TestRepository<InMemoryRepository> testRepo, TestAccount user) throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+    return result;
+  }
+
+  private ChangeInfo detailedChange(String changeId) throws Exception {
+    return gApi.changes()
+        .id(changeId)
+        .get(
+            ListChangesOption.DETAILED_LABELS,
+            ListChangesOption.CURRENT_REVISION,
+            ListChangesOption.CURRENT_COMMIT);
+  }
+
+  private PushOneCommit.Result createChange(
+      String subject,
+      String fileName,
+      String content,
+      TestRepository<InMemoryRepository> testRepo,
+      TestAccount user)
+      throws Exception {
+    PushOneCommit push = pushFactory.create(user.newIdent(), testRepo, subject, fileName, content);
+    return push.to("refs/for/master");
+  }
+
+  private void vote(TestAccount user, String changeId, int codeReviewVote, int verifiedVote)
+      throws Exception {
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput in =
+        new ReviewInput()
+            .label(LabelId.CODE_REVIEW, codeReviewVote)
+            .label(LabelId.VERIFIED, verifiedVote);
+    gApi.changes().id(changeId).current().review(in);
+  }
+
+  private void merge(PushOneCommit.Result r) throws Exception {
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(r.getChangeId()).current().submit();
+  }
+}
diff --git a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
index cde5134..dcf1158 100644
--- a/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/group/GroupOperationsImpl.java
@@ -23,10 +23,10 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupUuid;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -71,9 +71,8 @@
   private AccountGroup.UUID createNewGroup(TestGroupCreation groupCreation)
       throws ConfigInvalidException, IOException {
     InternalGroupCreation internalGroupCreation = toInternalGroupCreation(groupCreation);
-    InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupCreation);
-    InternalGroup internalGroup =
-        groupsUpdate.createGroup(internalGroupCreation, internalGroupUpdate);
+    GroupDelta groupDelta = toGroupDelta(groupCreation);
+    InternalGroup internalGroup = groupsUpdate.createGroup(internalGroupCreation, groupDelta);
     return internalGroup.getGroupUUID();
   }
 
@@ -89,8 +88,8 @@
         .build();
   }
 
-  private static InternalGroupUpdate toInternalGroupUpdate(TestGroupCreation groupCreation) {
-    InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+  private static GroupDelta toGroupDelta(TestGroupCreation groupCreation) {
+    GroupDelta.Builder builder = GroupDelta.builder();
     groupCreation.description().ifPresent(builder::setDescription);
     groupCreation.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
     groupCreation.visibleToAll().ifPresent(builder::setVisibleToAll);
@@ -147,12 +146,12 @@
 
     private void updateGroup(TestGroupUpdate groupUpdate)
         throws DuplicateKeyException, NoSuchGroupException, ConfigInvalidException, IOException {
-      InternalGroupUpdate internalGroupUpdate = toInternalGroupUpdate(groupUpdate);
-      groupsUpdate.updateGroup(groupUuid, internalGroupUpdate);
+      GroupDelta groupDelta = toGroupDelta(groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, groupDelta);
     }
 
-    private InternalGroupUpdate toInternalGroupUpdate(TestGroupUpdate groupUpdate) {
-      InternalGroupUpdate.Builder builder = InternalGroupUpdate.builder();
+    private GroupDelta toGroupDelta(TestGroupUpdate groupUpdate) {
+      GroupDelta.Builder builder = GroupDelta.builder();
       groupUpdate.name().map(AccountGroup::nameKey).ifPresent(builder::setName);
       groupUpdate.description().ifPresent(builder::setDescription);
       groupUpdate.ownerGroupUuid().ifPresent(builder::setOwnerGroupUUID);
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
index 738be4d..2dd3f91 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperations.java
@@ -81,11 +81,11 @@
      *
      * <p>Example:
      *
-     * <pre>
+     * <pre>{@code
      * projectOperations.forInvalidation()
      *     .addProjectConfigUpdater(cfg -> cfg.setString("invalidSection", null, "foo", "bar"))
      *     .invalidate();
-     * </pre>
+     * }</pre>
      *
      * <p><strong>Note:</strong> The invalidation will fail with an exception if the project to
      * invalidate doesn't exist.
diff --git a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
index 394f0f8..18da4b3 100644
--- a/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
+++ b/java/com/google/gerrit/acceptance/testsuite/project/ProjectOperationsImpl.java
@@ -23,7 +23,6 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.acceptance.testsuite.project.TestProjectCreation.Builder;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestCapability;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestLabelPermission;
 import com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.TestPermission;
@@ -81,7 +80,7 @@
   }
 
   @Override
-  public Builder newProject() {
+  public TestProjectCreation.Builder newProject() {
     return TestProjectCreation.builder(this::createNewProject);
   }
 
diff --git a/java/com/google/gerrit/auth/ldap/LdapRealm.java b/java/com/google/gerrit/auth/ldap/LdapRealm.java
index 9305914..9a9f309 100644
--- a/java/com/google/gerrit/auth/ldap/LdapRealm.java
+++ b/java/com/google/gerrit/auth/ldap/LdapRealm.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.account.EmailExpander;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
 import com.google.gerrit.server.auth.NoSuchUserException;
@@ -346,10 +347,12 @@
 
   static class UserLoader extends CacheLoader<String, Optional<Account.Id>> {
     private final ExternalIds externalIds;
+    private final ExternalIdKeyFactory externalIdKeyFactory;
 
     @Inject
-    UserLoader(ExternalIds externalIds) {
+    UserLoader(ExternalIds externalIds, ExternalIdKeyFactory externalIdKeyFactory) {
       this.externalIds = externalIds;
+      this.externalIdKeyFactory = externalIdKeyFactory;
     }
 
     @Override
@@ -358,7 +361,7 @@
           TraceContext.newTimer(
               "Loading account for username", Metadata.builder().username(username).build())) {
         return externalIds
-            .get(ExternalId.Key.create(SCHEME_GERRIT, username))
+            .get(externalIdKeyFactory.create(SCHEME_GERRIT, username))
             .map(ExternalId::accountId);
       }
     }
diff --git a/java/com/google/gerrit/common/PluginData.java b/java/com/google/gerrit/common/PluginData.java
index c440de1..289d93a 100644
--- a/java/com/google/gerrit/common/PluginData.java
+++ b/java/com/google/gerrit/common/PluginData.java
@@ -10,7 +10,7 @@
 // distributed under the License is distributed on an "AS IS" BASIS,
 // WITHOUT 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.common;
+// limitations under the License.
 
 package com.google.gerrit.common;
 
diff --git a/java/com/google/gerrit/common/data/GarbageCollectionResult.java b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
index 5e3601e..b995979 100644
--- a/java/com/google/gerrit/common/data/GarbageCollectionResult.java
+++ b/java/com/google/gerrit/common/data/GarbageCollectionResult.java
@@ -20,17 +20,17 @@
 
 /** A list of errors occurred during GC. */
 public class GarbageCollectionResult {
-  protected List<Error> errors;
+  protected List<GcError> errors;
 
   public GarbageCollectionResult() {
     errors = new ArrayList<>();
   }
 
-  public void addError(Error e) {
+  public void addError(GcError e) {
     errors.add(e);
   }
 
-  public List<Error> getErrors() {
+  public List<GcError> getErrors() {
     return errors;
   }
 
@@ -38,7 +38,7 @@
     return !errors.isEmpty();
   }
 
-  public static class Error {
+  public static class GcError {
     public enum Type {
       /** Git garbage collection was already scheduled for this project */
       GC_ALREADY_SCHEDULED,
@@ -53,9 +53,9 @@
     protected Type type;
     protected Project.NameKey projectName;
 
-    protected Error() {}
+    protected GcError() {}
 
-    public Error(Type type, Project.NameKey projectName) {
+    public GcError(Type type, Project.NameKey projectName) {
       this.type = type;
       this.projectName = projectName;
     }
diff --git a/java/com/google/gerrit/common/data/GitwebType.java b/java/com/google/gerrit/common/data/GitwebType.java
index 9cc408b..e69eacf 100644
--- a/java/com/google/gerrit/common/data/GitwebType.java
+++ b/java/com/google/gerrit/common/data/GitwebType.java
@@ -29,7 +29,7 @@
   private char pathSeparator = '/';
   private boolean urlEncode = true;
 
-  /** @return name displayed in links. */
+  /** Returns name displayed in links. */
   public String getLinkName() {
     return name;
   }
@@ -43,7 +43,7 @@
     this.name = name;
   }
 
-  /** @return parameterized string for the branch URL. */
+  /** Returns parameterized string for the branch URL. */
   public String getBranch() {
     return branch;
   }
@@ -57,7 +57,7 @@
     branch = str;
   }
 
-  /** @return parameterized string for the tag URL. */
+  /** Returns parameterized string for the tag URL. */
   public String getTag() {
     return tag;
   }
@@ -71,7 +71,7 @@
     tag = str;
   }
 
-  /** @return parameterized string for the file URL. */
+  /** Returns parameterized string for the file URL. */
   public String getFile() {
     return file;
   }
@@ -85,7 +85,7 @@
     file = str;
   }
 
-  /** @return parameterized string for the file history URL. */
+  /** Returns parameterized string for the file history URL. */
   public String getFileHistory() {
     return fileHistory;
   }
@@ -99,7 +99,7 @@
     fileHistory = str;
   }
 
-  /** @return parameterized string for the project URL. */
+  /** Returns parameterized string for the project URL. */
   public String getProject() {
     return project;
   }
@@ -113,7 +113,7 @@
     project = str;
   }
 
-  /** @return parameterized string for the revision URL. */
+  /** Returns parameterized string for the revision URL. */
   public String getRevision() {
     return revision;
   }
@@ -127,7 +127,7 @@
     revision = str;
   }
 
-  /** @return parameterized string for the root tree URL. */
+  /** Returns parameterized string for the root tree URL. */
   public String getRootTree() {
     return rootTree;
   }
@@ -141,7 +141,7 @@
     rootTree = str;
   }
 
-  /** @return path separator used for branch and project names. */
+  /** Returns path separator used for branch and project names. */
   public char getPathSeparator() {
     return pathSeparator;
   }
@@ -155,7 +155,7 @@
     this.pathSeparator = separator;
   }
 
-  /** @return whether to URL encode path segments. */
+  /** Returns whether to URL encode path segments. */
   public boolean getUrlEncode() {
     return urlEncode;
   }
diff --git a/java/com/google/gerrit/common/data/GlobalCapability.java b/java/com/google/gerrit/common/data/GlobalCapability.java
index 51d9ecd..253266d 100644
--- a/java/com/google/gerrit/common/data/GlobalCapability.java
+++ b/java/com/google/gerrit/common/data/GlobalCapability.java
@@ -109,6 +109,9 @@
   /** Can perform streaming of Gerrit events. */
   public static final String STREAM_EVENTS = "streamEvents";
 
+  /** Can query permissions for any (project, user) pair */
+  public static final String VIEW_ACCESS = "viewAccess";
+
   /** Can view all accounts, regardless of {@code accounts.visibility}. */
   public static final String VIEW_ALL_ACCOUNTS = "viewAllAccounts";
 
@@ -124,9 +127,6 @@
   /** Can view all pending tasks in the queue (not just the filtered set). */
   public static final String VIEW_QUEUE = "viewQueue";
 
-  /** Can query permissions for any (project, user) pair */
-  public static final String VIEW_ACCESS = "viewAccess";
-
   private static final List<String> NAMES_ALL;
   private static final List<String> NAMES_LC;
   private static final String[] RANGE_NAMES = {
@@ -152,12 +152,12 @@
     NAMES_ALL.add(RUN_AS);
     NAMES_ALL.add(RUN_GC);
     NAMES_ALL.add(STREAM_EVENTS);
+    NAMES_ALL.add(VIEW_ACCESS);
     NAMES_ALL.add(VIEW_ALL_ACCOUNTS);
     NAMES_ALL.add(VIEW_CACHES);
     NAMES_ALL.add(VIEW_CONNECTIONS);
     NAMES_ALL.add(VIEW_PLUGINS);
     NAMES_ALL.add(VIEW_QUEUE);
-    NAMES_ALL.add(VIEW_ACCESS);
 
     NAMES_LC = new ArrayList<>(NAMES_ALL.size());
     for (String name : NAMES_ALL) {
@@ -165,17 +165,17 @@
     }
   }
 
-  /** @return all valid capability names. */
+  /** Returns all valid capability names. */
   public static Collection<String> getAllNames() {
     return Collections.unmodifiableList(NAMES_ALL);
   }
 
-  /** @return true if the name is recognized as a capability name. */
+  /** Returns true if the name is recognized as a capability name. */
   public static boolean isGlobalCapability(String varName) {
     return NAMES_LC.contains(varName.toLowerCase());
   }
 
-  /** @return true if the capability should have a range attached. */
+  /** Returns true if the capability should have a range attached. */
   public static boolean hasRange(String varName) {
     for (String n : RANGE_NAMES) {
       if (n.equalsIgnoreCase(varName)) {
@@ -189,7 +189,7 @@
     return Collections.unmodifiableList(Arrays.asList(RANGE_NAMES));
   }
 
-  /** @return the valid range for the capability if it has one, otherwise null. */
+  /** Returns the valid range for the capability if it has one, otherwise null. */
   public static PermissionRange.WithDefaults getRange(String varName) {
     if (QUERY_LIMIT.equalsIgnoreCase(varName)) {
       return new PermissionRange.WithDefaults(
diff --git a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
deleted file mode 100644
index 6ed1a51..0000000
--- a/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
+++ /dev/null
@@ -1,414 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.ArrayListMultimap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Streams;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.io.BaseEncoding;
-import com.google.common.io.CharStreams;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.SearchSourceBuilder;
-import com.google.gerrit.elasticsearch.bulk.DeleteRequest;
-import com.google.gerrit.entities.converter.ProtoConverter;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.FieldBundle;
-import com.google.gerrit.index.query.ListResultSet;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.ResultSet;
-import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
-import com.google.protobuf.MessageLite;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.UnsupportedEncodingException;
-import java.net.URLEncoder;
-import java.sql.Timestamp;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.function.Function;
-import org.apache.http.HttpEntity;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.entity.ContentType;
-import org.apache.http.nio.entity.NStringEntity;
-import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
-
-abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  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";
-
-  protected static byte[] decodeBase64(String base64String) {
-    return BaseEncoding.base64().decode(base64String);
-  }
-
-  protected static <T> List<T> decodeProtos(
-      JsonObject doc, String fieldName, ProtoConverter<?, T> converter) {
-    JsonArray field = doc.getAsJsonArray(fieldName);
-    if (field == null) {
-      return null;
-    }
-    return Streams.stream(field)
-        .map(JsonElement::getAsString)
-        .map(AbstractElasticIndex::decodeBase64)
-        .map(bytes -> parseProtoFrom(bytes, converter))
-        .collect(toImmutableList());
-  }
-
-  protected static <P extends MessageLite, T> T parseProtoFrom(
-      byte[] bytes, ProtoConverter<P, T> converter) {
-    P message = Protos.parseUnchecked(converter.getParser(), bytes);
-    return converter.fromProto(message);
-  }
-
-  static String getContent(Response response) throws IOException {
-    HttpEntity responseEntity = response.getEntity();
-    String content = "";
-    if (responseEntity != null) {
-      InputStream contentStream = responseEntity.getContent();
-      try (Reader reader = new InputStreamReader(contentStream, UTF_8)) {
-        content = CharStreams.toString(reader);
-      }
-    }
-    return content;
-  }
-
-  private final ElasticConfiguration config;
-  private final Schema<V> schema;
-  private final SitePaths sitePaths;
-  private final String indexNameRaw;
-
-  protected final ElasticRestClientProvider client;
-  protected final String indexName;
-  protected final Gson gson;
-  protected final ElasticQueryBuilder queryBuilder;
-
-  AbstractElasticIndex(
-      ElasticConfiguration config,
-      SitePaths sitePaths,
-      Schema<V> schema,
-      ElasticRestClientProvider client,
-      String indexName) {
-    this.config = config;
-    this.sitePaths = sitePaths;
-    this.schema = schema;
-    this.gson = new GsonBuilder().setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
-    this.queryBuilder = new ElasticQueryBuilder();
-    this.indexName = config.getIndexName(indexName, schema.getVersion());
-    this.indexNameRaw = indexName;
-    this.client = client;
-  }
-
-  @Override
-  public Schema<V> getSchema() {
-    return schema;
-  }
-
-  @Override
-  public void close() {
-    // Do nothing. Client is closed by the provider.
-  }
-
-  @Override
-  public void markReady(boolean ready) {
-    IndexUtils.setReady(sitePaths, indexNameRaw, schema.getVersion(), ready);
-  }
-
-  @Override
-  public void delete(K id) {
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, getDeleteActions(id), getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format("Failed to delete %s from index %s: %s", id, indexName, statusCode));
-    }
-  }
-
-  @Override
-  public void insert(V obj) {
-    // TODO: Implement real insert() if it helps performance
-    replace(obj);
-  }
-
-  @Override
-  public void deleteAll() {
-    // Delete the index, if it exists.
-    String endpoint = indexName + client.adapter().indicesExistParams();
-    Response response = performRequest("HEAD", endpoint);
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode == HttpStatus.SC_OK) {
-      response = performRequest("DELETE", indexName);
-      statusCode = response.getStatusLine().getStatusCode();
-      if (statusCode != HttpStatus.SC_OK) {
-        throw new StorageException(
-            String.format("Failed to delete index %s: %s", indexName, statusCode));
-      }
-    }
-
-    // Recreate the index.
-    String indexCreationFields = concatJsonString(getSettings(), getMappings());
-    response = performRequest("PUT", indexName, indexCreationFields);
-    statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      String error = String.format("Failed to create index %s: %s", indexName, statusCode);
-      throw new StorageException(error);
-    }
-  }
-
-  protected abstract String getDeleteActions(K id);
-
-  protected abstract String getMappings();
-
-  private String getSettings() {
-    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config)));
-  }
-
-  protected abstract String getId(V v);
-
-  protected String getMappingsForSingleType(MappingProperties properties) {
-    return getMappingsFor(properties);
-  }
-
-  protected String getMappingsFor(MappingProperties properties) {
-    JsonObject mappings = new JsonObject();
-
-    mappings.add(MAPPINGS, gson.toJsonTree(properties));
-    return gson.toJson(mappings);
-  }
-
-  protected String getDeleteRequest(K id) {
-    return new DeleteRequest(id.toString(), indexName).toString();
-  }
-
-  protected abstract V fromDocument(JsonObject doc, Set<String> fields);
-
-  protected FieldBundle toFieldBundle(JsonObject doc) {
-    Map<String, FieldDef<V, ?>> allFields = getSchema().getFields();
-    ListMultimap<String, Object> rawFields = ArrayListMultimap.create();
-    for (Map.Entry<String, JsonElement> element :
-        doc.get(client.adapter().rawFieldsKey()).getAsJsonObject().entrySet()) {
-      checkArgument(
-          allFields.containsKey(element.getKey()), "Unrecognized field " + element.getKey());
-      FieldType<?> type = allFields.get(element.getKey()).getType();
-      Iterable<JsonElement> innerItems =
-          element.getValue().isJsonArray()
-              ? element.getValue().getAsJsonArray()
-              : Collections.singleton(element.getValue());
-      for (JsonElement inner : innerItems) {
-        if (type == FieldType.EXACT || type == FieldType.FULL_TEXT || type == FieldType.PREFIX) {
-          rawFields.put(element.getKey(), inner.getAsString());
-        } else if (type == FieldType.INTEGER || type == FieldType.INTEGER_RANGE) {
-          rawFields.put(element.getKey(), inner.getAsInt());
-        } else if (type == FieldType.LONG) {
-          rawFields.put(element.getKey(), inner.getAsLong());
-        } else if (type == FieldType.TIMESTAMP) {
-          rawFields.put(element.getKey(), new Timestamp(inner.getAsLong()));
-        } else if (type == FieldType.STORED_ONLY) {
-          rawFields.put(element.getKey(), decodeBase64(inner.getAsString()));
-        } else {
-          throw FieldType.badFieldType(type);
-        }
-      }
-    }
-    return new FieldBundle(rawFields);
-  }
-
-  protected String toAction(String type, String id, String action) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty("_id", id);
-    properties.addProperty("_index", indexName);
-    properties.addProperty("_type", type);
-
-    JsonObject jsonAction = new JsonObject();
-    jsonAction.add(action, properties);
-    return jsonAction.toString() + System.lineSeparator();
-  }
-
-  protected void addNamedElement(String name, JsonObject element, JsonArray array) {
-    JsonObject arrayElement = new JsonObject();
-    arrayElement.add(name, element);
-    array.add(arrayElement);
-  }
-
-  protected Map<String, String> getRefreshParam() {
-    Map<String, String> params = new HashMap<>();
-    params.put("refresh", "true");
-    return params;
-  }
-
-  protected String getSearch(SearchSourceBuilder searchSource, JsonArray sortArray) {
-    JsonObject search = new JsonParser().parse(searchSource.toString()).getAsJsonObject();
-    search.add("sort", sortArray);
-    return gson.toJson(search);
-  }
-
-  protected JsonArray getSortArray(String idFieldName) {
-    JsonObject properties = new JsonObject();
-    properties.addProperty(ORDER, ASC_SORT_ORDER);
-
-    JsonArray sortArray = new JsonArray();
-    addNamedElement(idFieldName, properties, sortArray);
-    return sortArray;
-  }
-
-  protected String getURI(String request) {
-    try {
-      return URLEncoder.encode(indexName, UTF_8.toString()) + "/" + request;
-    } catch (UnsupportedEncodingException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  protected Response postRequest(String uri, Object payload) {
-    return performRequest("POST", uri, payload);
-  }
-
-  protected Response postRequest(String uri, Object payload, Map<String, String> params) {
-    return performRequest("POST", uri, payload, params);
-  }
-
-  private String concatJsonString(String target, String addition) {
-    return target.substring(0, target.length() - 1) + "," + addition.substring(1);
-  }
-
-  private Response performRequest(String method, String uri) {
-    return performRequest(method, uri, null);
-  }
-
-  private Response performRequest(String method, String uri, @Nullable Object payload) {
-    return performRequest(method, uri, payload, Collections.emptyMap());
-  }
-
-  private Response performRequest(
-      String method, String uri, @Nullable Object payload, Map<String, String> params) {
-    Request request = new Request(method, uri.startsWith("/") ? uri : "/" + uri);
-    if (payload != null) {
-      String payloadStr = payload instanceof String ? (String) payload : payload.toString();
-      request.setEntity(new NStringEntity(payloadStr, ContentType.APPLICATION_JSON));
-    }
-    for (Map.Entry<String, String> entry : params.entrySet()) {
-      request.addParameter(entry.getKey(), entry.getValue());
-    }
-    try {
-      return client.get().performRequest(request);
-    } catch (IOException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  protected class ElasticQuerySource implements DataSource<V> {
-    private final QueryOptions opts;
-    private final String search;
-
-    ElasticQuerySource(Predicate<V> p, QueryOptions opts, JsonArray sortArray)
-        throws QueryParseException {
-      this.opts = opts;
-      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
-      SearchSourceBuilder searchSource =
-          new SearchSourceBuilder(client.adapter())
-              .query(qb)
-              .from(opts.start())
-              .size(opts.limit())
-              .fields(Lists.newArrayList(opts.fields()));
-      search = getSearch(searchSource, sortArray);
-    }
-
-    @Override
-    public int getCardinality() {
-      return 10;
-    }
-
-    @Override
-    public ResultSet<V> read() {
-      return readImpl(doc -> AbstractElasticIndex.this.fromDocument(doc, opts.fields()));
-    }
-
-    @Override
-    public ResultSet<FieldBundle> readRaw() {
-      return readImpl(AbstractElasticIndex.this::toFieldBundle);
-    }
-
-    private <T> ResultSet<T> readImpl(Function<JsonObject, T> mapper) {
-      try {
-        String uri = getURI(SEARCH);
-        Response response =
-            performRequest(HttpPost.METHOD_NAME, uri, search, Collections.emptyMap());
-        StatusLine statusLine = response.getStatusLine();
-        if (statusLine.getStatusCode() == HttpStatus.SC_OK) {
-          String content = getContent(response);
-          JsonObject obj =
-              new JsonParser().parse(content).getAsJsonObject().getAsJsonObject("hits");
-          if (obj.get("hits") != null) {
-            JsonArray json = obj.getAsJsonArray("hits");
-            ImmutableList.Builder<T> results = ImmutableList.builderWithExpectedSize(json.size());
-            for (int i = 0; i < json.size(); i++) {
-              T mapperResult = mapper.apply(json.get(i).getAsJsonObject());
-              if (mapperResult != null) {
-                results.add(mapperResult);
-              }
-            }
-            return new ListResultSet<>(results.build());
-          }
-        } else {
-          logger.atSevere().log(statusLine.getReasonPhrase());
-        }
-        return new ListResultSet<>(ImmutableList.of());
-      } catch (IOException e) {
-        throw new StorageException(e);
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/BUILD b/java/com/google/gerrit/elasticsearch/BUILD
deleted file mode 100644
index 8bab80b..0000000
--- a/java/com/google/gerrit/elasticsearch/BUILD
+++ /dev/null
@@ -1,33 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "elasticsearch",
-    srcs = glob(["**/*.java"]),
-    visibility = ["//visibility:public"],
-    deps = [
-        "//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/index",
-        "//java/com/google/gerrit/index:query_exception",
-        "//java/com/google/gerrit/index/project",
-        "//java/com/google/gerrit/lifecycle",
-        "//java/com/google/gerrit/proto",
-        "//java/com/google/gerrit/server",
-        "//lib:gson",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib:protobuf",
-        "//lib/commons:lang",
-        "//lib/elasticsearch-rest-client",
-        "//lib/flogger:api",
-        "//lib/guice",
-        "//lib/guice:guice-assistedinject",
-        "//lib/httpcomponents:httpasyncclient",
-        "//lib/httpcomponents:httpclient",
-        "//lib/httpcomponents:httpcore",
-        "//lib/httpcomponents:httpcore-nio",
-        "//lib/jackson:jackson-core",
-    ],
-)
diff --git a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java b/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
deleted file mode 100644
index 8967789..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticAccountIndex.java
+++ /dev/null
@@ -1,143 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.account.AccountField;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticAccountIndex extends AbstractElasticIndex<Account.Id, AccountState>
-    implements AccountIndex {
-  static class AccountMapping {
-    final MappingProperties accounts;
-
-    AccountMapping(Schema<AccountState> schema, ElasticQueryAdapter adapter) {
-      this.accounts = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String ACCOUNTS = "accounts";
-
-  private final AccountMapping mapping;
-  private final Provider<AccountCache> accountCache;
-  private final Schema<AccountState> schema;
-
-  @Inject
-  ElasticAccountIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<AccountCache> accountCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<AccountState> schema) {
-    super(cfg, sitePaths, schema, client, ACCOUNTS);
-    this.accountCache = accountCache;
-    this.mapping = new AccountMapping(schema, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(AccountState as) {
-    BulkRequest bulk =
-        new IndexRequest(getId(as), indexName)
-            .add(new UpdateRequest<>(schema, as, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace account %s in index %s: %s",
-              as.account().id(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<AccountState> getSource(Predicate<AccountState> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray =
-        getSortArray(
-            schema.useLegacyNumericFields()
-                ? AccountField.ID.getName()
-                : AccountField.ID_STR.getName());
-    return new ElasticQuerySource(
-        p,
-        opts.filterFields(o -> IndexUtils.accountFields(o, schema.useLegacyNumericFields())),
-        sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(Account.Id a) {
-    return getDeleteRequest(a);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.accounts);
-  }
-
-  @Override
-  protected String getId(AccountState as) {
-    return as.account().id().toString();
-  }
-
-  @Override
-  protected AccountState fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    Account.Id id =
-        Account.id(
-            source
-                .getAsJsonObject()
-                .get(
-                    schema.useLegacyNumericFields()
-                        ? AccountField.ID.getName()
-                        : AccountField.ID_STR.getName())
-                .getAsInt());
-    // Use the AccountCache rather than depending on any stored fields in the document (of which
-    // there shouldn't be any). The most expensive part to compute anyway is the effective group
-    // IDs, and we don't have a good way to reindex when those change.
-    // If the account doesn't exist return an empty AccountState to represent the missing account
-    // to account the fact that the account exists in the index.
-    return accountCache.get().getEvenIfMissing(id);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
deleted file mode 100644
index 162654d..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
+++ /dev/null
@@ -1,431 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
-
-import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.entities.converter.ChangeProtoConverter;
-import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
-import com.google.gerrit.entities.converter.PatchSetProtoConverter;
-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;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.ReviewerByEmailSet;
-import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.change.MergeabilityComputationBehavior;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-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;
-import org.apache.http.HttpStatus;
-import org.eclipse.jgit.lib.Config;
-import org.elasticsearch.client.Response;
-
-/** Secondary index implementation using Elasticsearch. */
-class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
-    implements ChangeIndex {
-  static class ChangeMapping {
-    final MappingProperties changes;
-    final MappingProperties openChanges;
-    final MappingProperties closedChanges;
-
-    ChangeMapping(Schema<ChangeData> schema, ElasticQueryAdapter adapter) {
-      MappingProperties mapping = ElasticMapping.createMapping(schema, adapter);
-      this.changes = mapping;
-      this.openChanges = mapping;
-      this.closedChanges = mapping;
-    }
-  }
-
-  private static final String CHANGES = "changes";
-
-  private final ChangeMapping mapping;
-  private final ChangeData.Factory changeDataFactory;
-  private final Schema<ChangeData> schema;
-  private final FieldDef<ChangeData, ?> idField;
-  private final ImmutableSet<String> skipFields;
-
-  @Inject
-  ElasticChangeIndex(
-      ElasticConfiguration cfg,
-      ChangeData.Factory changeDataFactory,
-      SitePaths sitePaths,
-      ElasticRestClientProvider clientBuilder,
-      @GerritServerConfig Config gerritConfig,
-      @Assisted Schema<ChangeData> schema) {
-    super(cfg, sitePaths, schema, clientBuilder, CHANGES);
-    this.changeDataFactory = changeDataFactory;
-    this.schema = schema;
-    this.mapping = new ChangeMapping(schema, client.adapter());
-    this.idField =
-        this.schema.useLegacyNumericFields() ? ChangeField.LEGACY_ID : ChangeField.LEGACY_ID_STR;
-    this.skipFields =
-        MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex()
-            ? ImmutableSet.of()
-            : ImmutableSet.of(ChangeField.MERGEABLE.getName());
-  }
-
-  @Override
-  public void replace(ChangeData cd) {
-    BulkRequest bulk =
-        new IndexRequest(getId(cd), indexName).add(new UpdateRequest<>(schema, cd, skipFields));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace change %s in index %s: %s", cd.getId(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<ChangeData> getSource(Predicate<ChangeData> p, QueryOptions opts)
-      throws QueryParseException {
-    QueryOptions filteredOpts =
-        opts.filterFields(o -> IndexUtils.changeFields(o, schema.useLegacyNumericFields()));
-    return new ElasticQuerySource(p, filteredOpts, getSortArray());
-  }
-
-  private JsonArray getSortArray() {
-    JsonObject properties = new JsonObject();
-    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);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsFor(mapping.changes);
-  }
-
-  @Override
-  protected String getId(ChangeData cd) {
-    return cd.getId().toString();
-  }
-
-  @Override
-  protected ChangeData fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement sourceElement = json.get("_source");
-    if (sourceElement == null) {
-      sourceElement = json.getAsJsonObject().get("fields");
-    }
-    JsonObject source = sourceElement.getAsJsonObject();
-    JsonElement c = source.get(ChangeField.CHANGE.getName());
-
-    if (c == null) {
-      int id = source.get(idField.getName()).getAsInt();
-      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
-      String projectName = requireNonNull(source.get(ChangeField.PROJECT.getName()).getAsString());
-      return changeDataFactory.create(Project.nameKey(projectName), Change.id(id));
-    }
-
-    ChangeData cd =
-        changeDataFactory.create(
-            parseProtoFrom(decodeBase64(c.getAsString()), ChangeProtoConverter.INSTANCE));
-
-    // Any decoding that is done here must also be done in {@link LuceneChangeIndex}.
-
-    // Patch sets.
-    cd.setPatchSets(
-        decodeProtos(source, ChangeField.PATCH_SET.getName(), PatchSetProtoConverter.INSTANCE));
-
-    // Approvals.
-    if (source.get(ChangeField.APPROVAL.getName()) != null) {
-      cd.setCurrentApprovals(
-          decodeProtos(
-              source, ChangeField.APPROVAL.getName(), PatchSetApprovalProtoConverter.INSTANCE));
-    } else if (fields.contains(ChangeField.APPROVAL.getName())) {
-      cd.setCurrentApprovals(Collections.emptyList());
-    }
-
-    // Added & Deleted.
-    JsonElement addedElement = source.get(ChangeField.ADDED.getName());
-    JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
-    if (addedElement != null && deletedElement != null) {
-      // Changed lines.
-      int added = addedElement.getAsInt();
-      int deleted = deletedElement.getAsInt();
-      cd.setChangedLines(added, deleted);
-    }
-
-    // Star.
-    JsonElement starredElement = source.get(ChangeField.STAR.getName());
-    if (starredElement != null) {
-      ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-      JsonArray starBy = starredElement.getAsJsonArray();
-      if (starBy.size() > 0) {
-        for (int i = 0; i < starBy.size(); i++) {
-          String[] indexableFields = starBy.get(i).getAsString().split(":");
-          Optional<Account.Id> id = Account.Id.tryParse(indexableFields[0]);
-          if (id.isPresent()) {
-            stars.put(id.get(), indexableFields[1]);
-          }
-        }
-      }
-      cd.setStars(stars);
-    }
-
-    // Mergeable.
-    JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
-    if (mergeableElement != null && !skipFields.contains(ChangeField.MERGEABLE.getName())) {
-      String mergeable = mergeableElement.getAsString();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-
-    // Reviewed-by.
-    if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
-      JsonArray reviewedBy = source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
-      if (reviewedBy.size() > 0) {
-        Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-        for (int i = 0; i < reviewedBy.size(); i++) {
-          int aId = reviewedBy.get(i).getAsInt();
-          if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
-            break;
-          }
-          accounts.add(Account.id(aId));
-        }
-        cd.setReviewedBy(accounts);
-      }
-    } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
-      cd.setReviewedBy(Collections.emptySet());
-    }
-
-    // Hashtag.
-    if (source.get(ChangeField.HASHTAG.getName()) != null) {
-      JsonArray hashtagArray = source.get(ChangeField.HASHTAG.getName()).getAsJsonArray();
-      if (hashtagArray.size() > 0) {
-        Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtagArray.size());
-        for (int i = 0; i < hashtagArray.size(); i++) {
-          hashtags.add(hashtagArray.get(i).getAsString());
-        }
-        cd.setHashtags(hashtags);
-      }
-    } else if (fields.contains(ChangeField.HASHTAG.getName())) {
-      cd.setHashtags(Collections.emptySet());
-    }
-
-    // Star.
-    if (source.get(ChangeField.STAR.getName()) != null) {
-      JsonArray starArray = source.get(ChangeField.STAR.getName()).getAsJsonArray();
-      if (starArray.size() > 0) {
-        ListMultimap<Account.Id, String> stars =
-            MultimapBuilder.hashKeys().arrayListValues().build();
-        for (int i = 0; i < starArray.size(); i++) {
-          StarredChangesUtil.StarField starField =
-              StarredChangesUtil.StarField.parse(starArray.get(i).getAsString());
-          stars.put(starField.accountId(), starField.label());
-        }
-        cd.setStars(stars);
-      }
-    } else if (fields.contains(ChangeField.STAR.getName())) {
-      cd.setStars(ImmutableListMultimap.of());
-    }
-
-    // Reviewer.
-    if (source.get(ChangeField.REVIEWER.getName()) != null) {
-      cd.setReviewers(
-          ChangeField.parseReviewerFieldValues(
-              cd.getId(),
-              FluentIterable.from(source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.REVIEWER.getName())) {
-      cd.setReviewers(ReviewerSet.empty());
-    }
-
-    // Reviewer-by-email.
-    if (source.get(ChangeField.REVIEWER_BY_EMAIL.getName()) != null) {
-      cd.setReviewersByEmail(
-          ChangeField.parseReviewerByEmailFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.REVIEWER_BY_EMAIL.getName())) {
-      cd.setReviewersByEmail(ReviewerByEmailSet.empty());
-    }
-
-    // Pending-reviewer.
-    if (source.get(ChangeField.PENDING_REVIEWER.getName()) != null) {
-      cd.setPendingReviewers(
-          ChangeField.parseReviewerFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.PENDING_REVIEWER.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.PENDING_REVIEWER.getName())) {
-      cd.setPendingReviewers(ReviewerSet.empty());
-    }
-
-    // Pending-reviewer-by-email.
-    if (source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()) != null) {
-      cd.setPendingReviewersByEmail(
-          ChangeField.parseReviewerByEmailFieldValues(
-              cd.getId(),
-              FluentIterable.from(
-                      source.get(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName()).getAsJsonArray())
-                  .transform(JsonElement::getAsString)));
-    } else if (fields.contains(ChangeField.PENDING_REVIEWER_BY_EMAIL.getName())) {
-      cd.setPendingReviewersByEmail(ReviewerByEmailSet.empty());
-    }
-
-    // Stored-submit-record-strict.
-    decodeSubmitRecords(
-        source,
-        ChangeField.STORED_SUBMIT_RECORD_STRICT.getName(),
-        ChangeField.SUBMIT_RULE_OPTIONS_STRICT,
-        cd);
-
-    // Stored-submit-record-lenient.
-    decodeSubmitRecords(
-        source,
-        ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName(),
-        ChangeField.SUBMIT_RULE_OPTIONS_LENIENT,
-        cd);
-
-    // Ref-state.
-    if (fields.contains(ChangeField.REF_STATE.getName())) {
-      cd.setRefStates(RefState.parseStates(getByteArray(source, ChangeField.REF_STATE.getName())));
-    }
-
-    // Ref-state-pattern.
-    if (fields.contains(ChangeField.REF_STATE_PATTERN.getName())) {
-      cd.setRefStatePatterns(getByteArray(source, ChangeField.REF_STATE_PATTERN.getName()));
-    }
-
-    // Unresolved-comment-count.
-    decodeUnresolvedCommentCount(source, ChangeField.UNRESOLVED_COMMENT_COUNT.getName(), cd);
-
-    // Attention set.
-    if (fields.contains(ChangeField.ATTENTION_SET_FULL.getName())) {
-      ChangeField.parseAttentionSet(
-          FluentIterable.from(source.getAsJsonArray(ChangeField.ATTENTION_SET_FULL.getName()))
-              .transform(ElasticChangeIndex::decodeBase64JsonElement)
-              .toSet(),
-          cd);
-    }
-
-    if (fields.contains(ChangeField.MERGED_ON.getName())) {
-      decodeMergedOn(source, cd);
-    }
-
-    return cd;
-  }
-
-  private Iterable<byte[]> getByteArray(JsonObject source, String name) {
-    JsonElement element = source.get(name);
-    return element != null
-        ? Iterables.transform(element.getAsJsonArray(), e -> decodeBase64(e.getAsString()))
-        : Collections.emptyList();
-  }
-
-  private void decodeSubmitRecords(
-      JsonObject doc, String fieldName, SubmitRuleOptions opts, ChangeData out) {
-    JsonArray records = doc.getAsJsonArray(fieldName);
-    if (records == null) {
-      return;
-    }
-    ChangeField.parseSubmitRecords(
-        FluentIterable.from(records)
-            .transform(ElasticChangeIndex::decodeBase64JsonElement)
-            .toList(),
-        opts,
-        out);
-  }
-
-  private static String decodeBase64JsonElement(JsonElement input) {
-    return new String(decodeBase64(input.getAsString()), UTF_8);
-  }
-
-  private void decodeUnresolvedCommentCount(JsonObject doc, String fieldName, ChangeData out) {
-    JsonElement count = doc.get(fieldName);
-    if (count == null) {
-      return;
-    }
-    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
deleted file mode 100644
index c4435297..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticConfiguration.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// 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.elasticsearch;
-
-import static com.google.common.base.MoreObjects.firstNonNull;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.ProvisionException;
-import com.google.inject.Singleton;
-import java.net.URI;
-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 {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  static final String SECTION_ELASTICSEARCH = "elasticsearch";
-  static final String KEY_PASSWORD = "password";
-  static final String KEY_USERNAME = "username";
-  static final String KEY_PREFIX = "prefix";
-  static final String KEY_SERVER = "server";
-  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;
-
-  final String username;
-  final String password;
-  final int numberOfShards;
-  final int numberOfReplicas;
-  final int maxResultWindow;
-  final int connectTimeout;
-  final int socketTimeout;
-  final String prefix;
-
-  @Inject
-  ElasticConfiguration(@GerritServerConfig Config cfg) {
-    this.cfg = cfg;
-    this.password = cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD);
-    this.username =
-        password == null
-            ? null
-            : firstNonNull(
-                cfg.getString(SECTION_ELASTICSEARCH, null, KEY_USERNAME), DEFAULT_USERNAME);
-    this.prefix = Strings.nullToEmpty(cfg.getString(SECTION_ELASTICSEARCH, null, KEY_PREFIX));
-    this.numberOfShards =
-        cfg.getInt(SECTION_ELASTICSEARCH, null, KEY_NUMBER_OF_SHARDS, DEFAULT_NUMBER_OF_SHARDS);
-    this.numberOfReplicas =
-        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 {
-        URI uri = new URI(server);
-        int port = uri.getPort();
-        HttpHost httpHost =
-            new HttpHost(
-                uri.getHost(), port == -1 ? Integer.valueOf(DEFAULT_PORT) : port, uri.getScheme());
-        this.hosts.add(httpHost);
-      } catch (URISyntaxException | IllegalArgumentException e) {
-        logger.atSevere().log("Invalid server URI %s: %s", server, e.getMessage());
-      }
-    }
-
-    if (hosts.isEmpty()) {
-      throw new ProvisionException("No valid Elasticsearch servers configured");
-    }
-
-    logger.atInfo().log("Elasticsearch servers: %s", hosts);
-  }
-
-  Config getConfig() {
-    return cfg;
-  }
-
-  HttpHost[] getHosts() {
-    return hosts.toArray(new HttpHost[hosts.size()]);
-  }
-
-  String getIndexName(String name, int schemaVersion) {
-    return String.format("%s%s_%04d", prefix, name, schemaVersion);
-  }
-
-  int getNumberOfShards() {
-    return numberOfShards;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticException.java b/java/com/google/gerrit/elasticsearch/ElasticException.java
deleted file mode 100644
index d4baf75..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticException.java
+++ /dev/null
@@ -1,27 +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.elasticsearch;
-
-class ElasticException extends RuntimeException {
-  private static final long serialVersionUID = 1L;
-
-  ElasticException(String message) {
-    super(message);
-  }
-
-  ElasticException(String message, Throwable cause) {
-    super(message, cause);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java b/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
deleted file mode 100644
index 781ed43..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticGroupIndex.java
+++ /dev/null
@@ -1,126 +0,0 @@
-// 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.elasticsearch;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.InternalGroup;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.index.group.GroupIndex;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticGroupIndex extends AbstractElasticIndex<AccountGroup.UUID, InternalGroup>
-    implements GroupIndex {
-  static class GroupMapping {
-    final MappingProperties groups;
-
-    GroupMapping(Schema<InternalGroup> schema, ElasticQueryAdapter adapter) {
-      this.groups = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  private static final String GROUPS = "groups";
-
-  private final GroupMapping mapping;
-  private final Provider<GroupCache> groupCache;
-  private final Schema<InternalGroup> schema;
-
-  @Inject
-  ElasticGroupIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<GroupCache> groupCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<InternalGroup> schema) {
-    super(cfg, sitePaths, schema, client, GROUPS);
-    this.groupCache = groupCache;
-    this.mapping = new GroupMapping(schema, client.adapter());
-    this.schema = schema;
-  }
-
-  @Override
-  public void replace(InternalGroup group) {
-    BulkRequest bulk =
-        new IndexRequest(getId(group), indexName)
-            .add(new UpdateRequest<>(schema, group, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace group %s in index %s: %s",
-              group.getGroupUUID().get(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<InternalGroup> getSource(Predicate<InternalGroup> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray = getSortArray(GroupField.UUID.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::groupFields), sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(AccountGroup.UUID g) {
-    return getDeleteRequest(g);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.groups);
-  }
-
-  @Override
-  protected String getId(InternalGroup group) {
-    return group.getGroupUUID().get();
-  }
-
-  @Override
-  protected InternalGroup fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    AccountGroup.UUID uuid =
-        AccountGroup.uuid(source.getAsJsonObject().get(GroupField.UUID.getName()).getAsString());
-    // Use the GroupCache rather than depending on any stored fields in the
-    // document (of which there shouldn't be any).
-    return groupCache.get().get(uuid).orElse(null);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
deleted file mode 100644
index 15d6126..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
+++ /dev/null
@@ -1,69 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch;
-
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.server.index.AbstractIndexModule;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.index.account.AccountIndex;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.group.GroupIndex;
-import java.util.Map;
-
-public class ElasticIndexModule extends AbstractIndexModule {
-  public static ElasticIndexModule singleVersionWithExplicitVersions(
-      Map<String, Integer> versions, int threads, boolean slave) {
-    return new ElasticIndexModule(versions, threads, slave);
-  }
-
-  public static ElasticIndexModule latestVersion(boolean slave) {
-    return new ElasticIndexModule(null, 0, slave);
-  }
-
-  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads, boolean slave) {
-    super(singleVersions, threads, slave);
-  }
-
-  @Override
-  public void configure() {
-    super.configure();
-    install(ElasticRestClientProvider.module());
-  }
-
-  @Override
-  protected Class<? extends AccountIndex> getAccountIndex() {
-    return ElasticAccountIndex.class;
-  }
-
-  @Override
-  protected Class<? extends ChangeIndex> getChangeIndex() {
-    return ElasticChangeIndex.class;
-  }
-
-  @Override
-  protected Class<? extends GroupIndex> getGroupIndex() {
-    return ElasticGroupIndex.class;
-  }
-
-  @Override
-  protected Class<? extends ProjectIndex> getProjectIndex() {
-    return ElasticProjectIndex.class;
-  }
-
-  @Override
-  protected Class<? extends VersionManager> getVersionManager() {
-    return ElasticIndexVersionManager.class;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
deleted file mode 100644
index 100022a..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionDiscovery.java
+++ /dev/null
@@ -1,61 +0,0 @@
-// 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.elasticsearch;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gson.JsonParser;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.List;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-import org.elasticsearch.client.Request;
-import org.elasticsearch.client.Response;
-
-@Singleton
-class ElasticIndexVersionDiscovery {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ElasticRestClientProvider client;
-
-  @Inject
-  ElasticIndexVersionDiscovery(ElasticRestClientProvider client) {
-    this.client = client;
-  }
-
-  List<String> discover(String prefix, String indexName) throws IOException {
-    String name = prefix + indexName + "_";
-    Request request = new Request("GET", client.adapter().getVersionDiscoveryUrl(name));
-    Response response = client.get().performRequest(request);
-
-    StatusLine statusLine = response.getStatusLine();
-    if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
-      String message =
-          String.format(
-              "Failed to discover index versions for %s: %d: %s",
-              name, statusLine.getStatusCode(), statusLine.getReasonPhrase());
-      logger.atSevere().log(message);
-      throw new IOException(message);
-    }
-
-    return new JsonParser()
-        .parse(AbstractElasticIndex.getContent(response)).getAsJsonObject().entrySet().stream()
-            .map(e -> e.getKey().replace(name, ""))
-            .collect(toList());
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java b/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
deleted file mode 100644
index b9d86d5..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticIndexVersionManager.java
+++ /dev/null
@@ -1,82 +0,0 @@
-// 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.elasticsearch;
-
-import com.google.common.base.Strings;
-import com.google.common.flogger.FluentLogger;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.index.Index;
-import com.google.gerrit.index.IndexDefinition;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.GerritIndexStatus;
-import com.google.gerrit.server.index.OnlineUpgradeListener;
-import com.google.gerrit.server.index.VersionManager;
-import com.google.gerrit.server.plugincontext.PluginSetContext;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
-import java.util.TreeMap;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-public class ElasticIndexVersionManager extends VersionManager {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final String prefix;
-  private final ElasticIndexVersionDiscovery versionDiscovery;
-
-  @Inject
-  ElasticIndexVersionManager(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      PluginSetContext<OnlineUpgradeListener> listeners,
-      Collection<IndexDefinition<?, ?, ?>> defs,
-      ElasticIndexVersionDiscovery versionDiscovery) {
-    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
-    this.versionDiscovery = versionDiscovery;
-    prefix = Strings.nullToEmpty(cfg.getString("elasticsearch", null, "prefix"));
-  }
-
-  @Override
-  protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
-      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
-    try {
-      List<String> discovered = versionDiscovery.discover(prefix, def.getName());
-      logger.atFine().log("Discovered versions for %s: %s", def.getName(), discovered);
-      for (String version : discovered) {
-        Integer v = Ints.tryParse(version);
-        if (v == null || version.length() != 4) {
-          logger.atWarning().log("Unrecognized version in index %s: %s", def.getName(), version);
-          continue;
-        }
-        versions.put(v, new Version<>(null, v, true, cfg.getReady(def.getName(), v)));
-      }
-    } catch (IOException e) {
-      logger.atSevere().withCause(e).log("Error scanning index: %s", def.getName());
-    }
-
-    for (Schema<V> schema : def.getSchemas().values()) {
-      int v = schema.getVersion();
-      boolean exists = versions.containsKey(v);
-      versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v)));
-    }
-    return versions;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/java/com/google/gerrit/elasticsearch/ElasticMapping.java
deleted file mode 100644
index edd05c9..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticMapping.java
+++ /dev/null
@@ -1,123 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.Schema;
-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()) {
-      String name = field.getName();
-      FieldType<?> fieldType = field.getType();
-      if (fieldType == FieldType.EXACT) {
-        mapping.addExactField(name);
-      } else if (fieldType == FieldType.TIMESTAMP) {
-        mapping.addTimestamp(name);
-      } else if (fieldType == FieldType.INTEGER
-          || fieldType == FieldType.INTEGER_RANGE
-          || fieldType == FieldType.LONG) {
-        mapping.addNumber(name);
-      } else if (fieldType == FieldType.FULL_TEXT) {
-        mapping.addStringWithAnalyzer(name);
-      } else if (fieldType == FieldType.PREFIX || fieldType == FieldType.STORED_ONLY) {
-        mapping.addString(name);
-      } else {
-        throw new IllegalStateException("Unsupported field type: " + fieldType.getName());
-      }
-    }
-    return mapping.build();
-  }
-
-  static class Builder {
-    private final ElasticQueryAdapter adapter;
-    private final ImmutableMap.Builder<String, FieldProperties> fields =
-        new ImmutableMap.Builder<>();
-
-    Builder(ElasticQueryAdapter adapter) {
-      this.adapter = adapter;
-    }
-
-    MappingProperties build() {
-      MappingProperties properties = new MappingProperties();
-      properties.properties = fields.build();
-      return properties;
-    }
-
-    Builder addExactField(String name) {
-      FieldProperties key = new FieldProperties(adapter.exactFieldType());
-      key.index = adapter.indexProperty();
-      FieldProperties properties;
-      properties = new FieldProperties(adapter.exactFieldType());
-      properties.fields = ImmutableMap.of("key", key);
-      fields.put(name, properties);
-      return this;
-    }
-
-    Builder addTimestamp(String name) {
-      FieldProperties properties = new FieldProperties(TIMESTAMP_FIELD_TYPE);
-      properties.type = TIMESTAMP_FIELD_TYPE;
-      properties.format = TIMESTAMP_FIELD_FORMAT;
-      fields.put(name, properties);
-      return this;
-    }
-
-    Builder addNumber(String name) {
-      fields.put(name, new FieldProperties("long"));
-      return this;
-    }
-
-    Builder addString(String name) {
-      fields.put(name, new FieldProperties(adapter.stringFieldType()));
-      return this;
-    }
-
-    Builder addStringWithAnalyzer(String name) {
-      FieldProperties key = new FieldProperties(adapter.stringFieldType());
-      key.analyzer = "custom_with_char_filter";
-      fields.put(name, key);
-      return this;
-    }
-
-    Builder add(String name, String type) {
-      fields.put(name, new FieldProperties(type));
-      return this;
-    }
-  }
-
-  static class MappingProperties {
-    Map<String, FieldProperties> properties;
-  }
-
-  static class FieldProperties {
-    String type;
-    String index;
-    String format;
-    String analyzer;
-    Map<String, FieldProperties> fields;
-
-    FieldProperties(String type) {
-      this.type = type;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java b/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
deleted file mode 100644
index b8bfc38..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticProjectIndex.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// 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.elasticsearch;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
-import com.google.gerrit.elasticsearch.bulk.BulkRequest;
-import com.google.gerrit.elasticsearch.bulk.IndexRequest;
-import com.google.gerrit.elasticsearch.bulk.UpdateRequest;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.QueryOptions;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.project.ProjectData;
-import com.google.gerrit.index.project.ProjectField;
-import com.google.gerrit.index.project.ProjectIndex;
-import com.google.gerrit.index.query.DataSource;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gson.JsonArray;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.util.Optional;
-import java.util.Set;
-import org.apache.http.HttpStatus;
-import org.elasticsearch.client.Response;
-
-public class ElasticProjectIndex extends AbstractElasticIndex<Project.NameKey, ProjectData>
-    implements ProjectIndex {
-  static class ProjectMapping {
-    MappingProperties projects;
-
-    ProjectMapping(Schema<ProjectData> schema, ElasticQueryAdapter adapter) {
-      this.projects = ElasticMapping.createMapping(schema, adapter);
-    }
-  }
-
-  static final String PROJECTS = "projects";
-
-  private final ProjectMapping mapping;
-  private final Provider<ProjectCache> projectCache;
-  private final Schema<ProjectData> schema;
-
-  @Inject
-  ElasticProjectIndex(
-      ElasticConfiguration cfg,
-      SitePaths sitePaths,
-      Provider<ProjectCache> projectCache,
-      ElasticRestClientProvider client,
-      @Assisted Schema<ProjectData> schema) {
-    super(cfg, sitePaths, schema, client, PROJECTS);
-    this.projectCache = projectCache;
-    this.schema = schema;
-    this.mapping = new ProjectMapping(schema, client.adapter());
-  }
-
-  @Override
-  public void replace(ProjectData projectState) {
-    BulkRequest bulk =
-        new IndexRequest(projectState.getProject().getName(), indexName)
-            .add(new UpdateRequest<>(schema, projectState, ImmutableSet.of()));
-
-    String uri = getURI(BULK);
-    Response response = postRequest(uri, bulk, getRefreshParam());
-    int statusCode = response.getStatusLine().getStatusCode();
-    if (statusCode != HttpStatus.SC_OK) {
-      throw new StorageException(
-          String.format(
-              "Failed to replace project %s in index %s: %s",
-              projectState.getProject().getName(), indexName, statusCode));
-    }
-  }
-
-  @Override
-  public DataSource<ProjectData> getSource(Predicate<ProjectData> p, QueryOptions opts)
-      throws QueryParseException {
-    JsonArray sortArray = getSortArray(ProjectField.NAME.getName());
-    return new ElasticQuerySource(p, opts.filterFields(IndexUtils::projectFields), sortArray);
-  }
-
-  @Override
-  protected String getDeleteActions(Project.NameKey nameKey) {
-    return getDeleteRequest(nameKey);
-  }
-
-  @Override
-  protected String getMappings() {
-    return getMappingsForSingleType(mapping.projects);
-  }
-
-  @Override
-  protected String getId(ProjectData projectState) {
-    return projectState.getProject().getName();
-  }
-
-  @Override
-  protected ProjectData fromDocument(JsonObject json, Set<String> fields) {
-    JsonElement source = json.get("_source");
-    if (source == null) {
-      source = json.getAsJsonObject().get("fields");
-    }
-
-    Project.NameKey nameKey =
-        Project.nameKey(source.getAsJsonObject().get(ProjectField.NAME.getName()).getAsString());
-    Optional<ProjectState> state = projectCache.get().get(nameKey);
-    if (!state.isPresent()) {
-      return null;
-    }
-    return state.get().toProjectData();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java b/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
deleted file mode 100644
index 19d9901..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryAdapter.java
+++ /dev/null
@@ -1,63 +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.elasticsearch;
-
-public class ElasticQueryAdapter {
-  private static final String INDICES = "?allow_no_indices=false";
-
-  private final String searchFilteringName;
-  private final String exactFieldType;
-  private final String stringFieldType;
-  private final String indexProperty;
-  private final String rawFieldsKey;
-  private final String versionDiscoveryUrl;
-
-  ElasticQueryAdapter() {
-    this.versionDiscoveryUrl = "/%s*";
-    this.searchFilteringName = "_source";
-    this.exactFieldType = "keyword";
-    this.stringFieldType = "text";
-    this.indexProperty = "true";
-    this.rawFieldsKey = "_source";
-  }
-
-  public String searchFilteringName() {
-    return searchFilteringName;
-  }
-
-  String indicesExistParams() {
-    return INDICES;
-  }
-
-  String exactFieldType() {
-    return exactFieldType;
-  }
-
-  String stringFieldType() {
-    return stringFieldType;
-  }
-
-  String indexProperty() {
-    return indexProperty;
-  }
-
-  String rawFieldsKey() {
-    return rawFieldsKey;
-  }
-
-  String getVersionDiscoveryUrl(String name) {
-    return String.format(versionDiscoveryUrl, name);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
deleted file mode 100644
index 40ac603..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
+++ /dev/null
@@ -1,163 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch;
-
-import com.google.gerrit.elasticsearch.builders.BoolQueryBuilder;
-import com.google.gerrit.elasticsearch.builders.QueryBuilder;
-import com.google.gerrit.elasticsearch.builders.QueryBuilders;
-import com.google.gerrit.index.FieldDef;
-import com.google.gerrit.index.FieldType;
-import com.google.gerrit.index.query.AndPredicate;
-import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.IntegerRangePredicate;
-import com.google.gerrit.index.query.NotPredicate;
-import com.google.gerrit.index.query.OrPredicate;
-import com.google.gerrit.index.query.PostFilterPredicate;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.index.query.RegexPredicate;
-import com.google.gerrit.index.query.TimestampRangePredicate;
-import java.time.Instant;
-
-public class ElasticQueryBuilder {
-
-  <T> QueryBuilder toQueryBuilder(Predicate<T> p) throws QueryParseException {
-    if (p instanceof AndPredicate) {
-      return and(p);
-    } else if (p instanceof OrPredicate) {
-      return or(p);
-    } else if (p instanceof NotPredicate) {
-      return not(p);
-    } else if (p instanceof IndexPredicate) {
-      return fieldQuery((IndexPredicate<T>) p);
-    } else if (p instanceof PostFilterPredicate) {
-      return QueryBuilders.matchAllQuery();
-    } else {
-      throw new QueryParseException("cannot create query for index: " + p);
-    }
-  }
-
-  private <T> BoolQueryBuilder and(Predicate<T> p) throws QueryParseException {
-    BoolQueryBuilder b = QueryBuilders.boolQuery();
-    for (Predicate<T> c : p.getChildren()) {
-      b.must(toQueryBuilder(c));
-    }
-    return b;
-  }
-
-  private <T> BoolQueryBuilder or(Predicate<T> p) throws QueryParseException {
-    BoolQueryBuilder q = QueryBuilders.boolQuery();
-    for (Predicate<T> c : p.getChildren()) {
-      q.should(toQueryBuilder(c));
-    }
-    return q;
-  }
-
-  private <T> QueryBuilder not(Predicate<T> p) throws QueryParseException {
-    Predicate<T> n = p.getChild(0);
-    if (n instanceof TimestampRangePredicate) {
-      return notTimestamp((TimestampRangePredicate<T>) n);
-    }
-
-    // Lucene does not support negation, start with all and subtract.
-    BoolQueryBuilder q = QueryBuilders.boolQuery();
-    q.must(QueryBuilders.matchAllQuery());
-    q.mustNot(toQueryBuilder(n));
-    return q;
-  }
-
-  private <T> QueryBuilder fieldQuery(IndexPredicate<T> p) throws QueryParseException {
-    FieldType<?> type = p.getType();
-    FieldDef<?, ?> field = p.getField();
-    String name = field.getName();
-    String value = p.getValue();
-
-    if (type == FieldType.INTEGER) {
-      // QueryBuilder encodes integer fields as prefix coded bits,
-      // which elasticsearch's queryString can't handle.
-      // Create integer terms with string representations instead.
-      return QueryBuilders.termQuery(name, value);
-    } else if (type == FieldType.INTEGER_RANGE) {
-      return intRangeQuery(p);
-    } else if (type == FieldType.TIMESTAMP) {
-      return timestampQuery(p);
-    } else if (type == FieldType.EXACT) {
-      return exactQuery(p);
-    } else if (type == FieldType.PREFIX) {
-      return QueryBuilders.matchPhrasePrefixQuery(name, value);
-    } else if (type == FieldType.FULL_TEXT) {
-      return QueryBuilders.matchPhraseQuery(name, value);
-    } else {
-      throw FieldType.badFieldType(p.getType());
-    }
-  }
-
-  private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p) throws QueryParseException {
-    if (p instanceof IntegerRangePredicate) {
-      IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p;
-      int minimum = r.getMinimumValue();
-      int maximum = r.getMaximumValue();
-      if (minimum == maximum) {
-        // Just fall back to a standard integer query.
-        return QueryBuilders.termQuery(p.getField().getName(), minimum);
-      }
-      return QueryBuilders.rangeQuery(p.getField().getName()).gte(minimum).lte(maximum);
-    }
-    throw new QueryParseException("not an integer range: " + p);
-  }
-
-  private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r) throws QueryParseException {
-    if (r.getMinTimestamp().getTime() == 0) {
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gt(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
-    }
-    throw new QueryParseException("cannot negate: " + r);
-  }
-
-  private <T> QueryBuilder timestampQuery(IndexPredicate<T> p) throws QueryParseException {
-    if (p instanceof TimestampRangePredicate) {
-      TimestampRangePredicate<T> r = (TimestampRangePredicate<T>) p;
-      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()));
-      }
-      return QueryBuilders.rangeQuery(r.getField().getName())
-          .gte(Instant.ofEpochMilli(r.getMinTimestamp().getTime()))
-          .lte(Instant.ofEpochMilli(r.getMaxTimestamp().getTime()));
-    }
-    throw new QueryParseException("not a timestamp: " + p);
-  }
-
-  private <T> QueryBuilder exactQuery(IndexPredicate<T> p) {
-    String name = p.getField().getName();
-    String value = p.getValue();
-
-    if (!p.getField().isRepeatable() && value.isEmpty()) {
-      return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
-    } else if (p instanceof RegexPredicate) {
-      if (value.startsWith("^")) {
-        value = value.substring(1);
-      }
-      if (value.endsWith("$") && !value.endsWith("\\$") && !value.endsWith("\\\\$")) {
-        value = value.substring(0, value.length() - 1);
-      }
-      return QueryBuilders.regexpQuery(name + ".key", value);
-    } else {
-      return QueryBuilders.termQuery(name + ".key", value);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java b/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
deleted file mode 100644
index b41f365..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticRestClientProvider.java
+++ /dev/null
@@ -1,157 +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.elasticsearch;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.lifecycle.LifecycleModule;
-import com.google.gson.JsonParser;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.apache.http.HttpStatus;
-import org.apache.http.StatusLine;
-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;
-import org.elasticsearch.client.Response;
-import org.elasticsearch.client.RestClient;
-import org.elasticsearch.client.RestClientBuilder;
-
-@Singleton
-class ElasticRestClientProvider implements Provider<RestClient>, LifecycleListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ElasticConfiguration cfg;
-
-  private volatile RestClient client;
-  private ElasticQueryAdapter adapter;
-
-  @Inject
-  ElasticRestClientProvider(ElasticConfiguration cfg) {
-    this.cfg = cfg;
-  }
-
-  public static LifecycleModule module() {
-    return new LifecycleModule() {
-      @Override
-      protected void configure() {
-        listener().to(ElasticRestClientProvider.class);
-      }
-    };
-  }
-
-  @Override
-  public RestClient get() {
-    if (client == null) {
-      synchronized (this) {
-        if (client == null) {
-          client = build();
-          ElasticVersion version = getVersion();
-          logger.atInfo().log("Elasticsearch integration version %s", version);
-          adapter = new ElasticQueryAdapter();
-        }
-      }
-    }
-    return client;
-  }
-
-  @Override
-  public void start() {}
-
-  @Override
-  public void stop() {
-    if (client != null) {
-      try {
-        client.close();
-      } catch (IOException e) {
-        // Ignore. We can't do anything about it.
-      }
-    }
-  }
-
-  ElasticQueryAdapter adapter() {
-    get(); // Make sure we're connected
-    return adapter;
-  }
-
-  public static class FailedToGetVersion extends ElasticException {
-    private static final long serialVersionUID = 1L;
-    private static final String MESSAGE = "Failed to get Elasticsearch version";
-
-    FailedToGetVersion(StatusLine status) {
-      super(String.format("%s: %d %s", MESSAGE, status.getStatusCode(), status.getReasonPhrase()));
-    }
-
-    FailedToGetVersion(Throwable cause) {
-      super(MESSAGE, cause);
-    }
-  }
-
-  private ElasticVersion getVersion() throws ElasticException {
-    try {
-      Response response = client.performRequest(new Request("GET", "/"));
-      StatusLine statusLine = response.getStatusLine();
-      if (statusLine.getStatusCode() != HttpStatus.SC_OK) {
-        throw new FailedToGetVersion(statusLine);
-      }
-      String version =
-          new JsonParser()
-              .parse(AbstractElasticIndex.getContent(response))
-              .getAsJsonObject()
-              .get("version")
-              .getAsJsonObject()
-              .get("number")
-              .getAsString();
-      logger.atInfo().log("Connected to Elasticsearch version %s", version);
-      return ElasticVersion.forVersion(version);
-    } catch (IOException e) {
-      throw new FailedToGetVersion(e);
-    }
-  }
-
-  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;
-    if (username != null && password != null) {
-      CredentialsProvider credentialsProvider = new BasicCredentialsProvider();
-      credentialsProvider.setCredentials(
-          AuthScope.ANY, new UsernamePasswordCredentials(username, password));
-      builder.setHttpClientConfigCallback(
-          (HttpAsyncClientBuilder httpClientBuilder) ->
-              httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticSetting.java b/java/com/google/gerrit/elasticsearch/ElasticSetting.java
deleted file mode 100644
index 7ec0566..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticSetting.java
+++ /dev/null
@@ -1,97 +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.elasticsearch;
-
-import com.google.common.collect.ImmutableMap;
-import java.util.Map;
-
-class ElasticSetting {
-  /** The custom char mappings of "." to " " and "_" to " " in the form of UTF-8 */
-  private static final ImmutableMap<String, String> CUSTOM_CHAR_MAPPING =
-      ImmutableMap.of("\\u002E", "\\u0020", "\\u005F", "\\u0020");
-
-  static SettingProperties createSetting(ElasticConfiguration config) {
-    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config);
-  }
-
-  static class Builder {
-    private final ImmutableMap.Builder<String, FieldProperties> fields =
-        new ImmutableMap.Builder<>();
-
-    SettingProperties build(ElasticConfiguration config) {
-      SettingProperties properties = new SettingProperties();
-      properties.analysis = fields.build();
-      properties.numberOfShards = config.getNumberOfShards();
-      properties.numberOfReplicas = config.numberOfReplicas;
-      properties.maxResultWindow = config.maxResultWindow;
-      return properties;
-    }
-
-    Builder addCharFilter() {
-      FieldProperties charMapping = new FieldProperties("mapping");
-      charMapping.mappings = getCustomCharMappings(CUSTOM_CHAR_MAPPING);
-
-      FieldProperties charFilter = new FieldProperties();
-      charFilter.customMapping = charMapping;
-      fields.put("char_filter", charFilter);
-      return this;
-    }
-
-    Builder addAnalyzer() {
-      FieldProperties customAnalyzer = new FieldProperties("custom");
-      customAnalyzer.tokenizer = "standard";
-      customAnalyzer.charFilter = new String[] {"custom_mapping"};
-      customAnalyzer.filter = new String[] {"lowercase"};
-
-      FieldProperties analyzer = new FieldProperties();
-      analyzer.customWithCharFilter = customAnalyzer;
-      fields.put("analyzer", analyzer);
-      return this;
-    }
-
-    private static String[] getCustomCharMappings(ImmutableMap<String, String> map) {
-      int mappingIndex = 0;
-      int numOfMappings = map.size();
-      String[] mapping = new String[numOfMappings];
-      for (Map.Entry<String, String> e : map.entrySet()) {
-        mapping[mappingIndex++] = e.getKey() + "=>" + e.getValue();
-      }
-      return mapping;
-    }
-  }
-
-  static class SettingProperties {
-    Map<String, FieldProperties> analysis;
-    Integer numberOfShards;
-    Integer numberOfReplicas;
-    Integer maxResultWindow;
-  }
-
-  static class FieldProperties {
-    String tokenizer;
-    String type;
-    String[] charFilter;
-    String[] filter;
-    String[] mappings;
-    FieldProperties customMapping;
-    FieldProperties customWithCharFilter;
-
-    FieldProperties() {}
-
-    FieldProperties(String type) {
-      this.type = type;
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/ElasticVersion.java b/java/com/google/gerrit/elasticsearch/ElasticVersion.java
deleted file mode 100644
index 47fa383..0000000
--- a/java/com/google/gerrit/elasticsearch/ElasticVersion.java
+++ /dev/null
@@ -1,65 +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.elasticsearch;
-
-import com.google.common.base.Joiner;
-import java.util.regex.Pattern;
-
-public enum ElasticVersion {
-  V7_16("7.16.*");
-
-  private final String version;
-  private final Pattern pattern;
-
-  ElasticVersion(String version) {
-    this.version = version;
-    this.pattern = Pattern.compile(version);
-  }
-
-  public static class UnsupportedVersion extends ElasticException {
-    private static final long serialVersionUID = 1L;
-
-    UnsupportedVersion(String version) {
-      super(
-          String.format(
-              "Unsupported version: [%s]. Supported versions: %s", version, supportedVersions()));
-    }
-  }
-
-  /**
-   * Convert a version String to an ElasticVersion if supported.
-   *
-   * @param version for which to return an ElasticVersion
-   * @return the corresponding ElasticVersion if supported
-   * @throws UnsupportedVersion
-   */
-  public static ElasticVersion forVersion(String version) {
-    for (ElasticVersion value : ElasticVersion.values()) {
-      if (value.pattern.matcher(version).matches()) {
-        return value;
-      }
-    }
-    throw new UnsupportedVersion(version);
-  }
-
-  public static String supportedVersions() {
-    return Joiner.on(", ").join(ElasticVersion.values());
-  }
-
-  @Override
-  public String toString() {
-    return version;
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
deleted file mode 100644
index a204919..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/BoolQueryBuilder.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-
-/**
- * A Query that matches documents matching boolean combinations of other queries.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.BoolQueryBuilder.
- */
-public class BoolQueryBuilder extends QueryBuilder {
-
-  private final List<QueryBuilder> mustClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> mustNotClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> filterClauses = new ArrayList<>();
-
-  private final List<QueryBuilder> shouldClauses = new ArrayList<>();
-
-  /**
-   * Adds a query that <b>must</b> appear in the matching documents and will contribute to scoring.
-   */
-  public BoolQueryBuilder must(QueryBuilder queryBuilder) {
-    mustClauses.add(queryBuilder);
-    return this;
-  }
-
-  /**
-   * Adds a query that <b>must not</b> appear in the matching documents and will not contribute to
-   * scoring.
-   */
-  public BoolQueryBuilder mustNot(QueryBuilder queryBuilder) {
-    mustNotClauses.add(queryBuilder);
-    return this;
-  }
-
-  /**
-   * Adds a query that <i>should</i> appear in the matching documents. For a boolean query with no
-   * <tt>MUST</tt> clauses one or more <code>SHOULD</code> clauses must match a document for the
-   * BooleanQuery to match.
-   */
-  public BoolQueryBuilder should(QueryBuilder queryBuilder) {
-    shouldClauses.add(queryBuilder);
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("bool");
-    doXArrayContent("must", mustClauses, builder);
-    doXArrayContent("filter", filterClauses, builder);
-    doXArrayContent("must_not", mustNotClauses, builder);
-    doXArrayContent("should", shouldClauses, builder);
-    builder.endObject();
-  }
-
-  private void doXArrayContent(String field, List<QueryBuilder> clauses, XContentBuilder builder)
-      throws IOException {
-    if (clauses.isEmpty()) {
-      return;
-    }
-    if (clauses.size() == 1) {
-      builder.field(field);
-      clauses.get(0).toXContent(builder);
-    } else {
-      builder.startArray(field);
-      for (QueryBuilder clause : clauses) {
-        clause.toXContent(builder);
-      }
-      builder.endArray();
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
deleted file mode 100644
index 1b058d7..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/ExistsQueryBuilder.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * Constructs a query that only match on documents that the field has a value in them.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.ExistsQueryBuilder.
- */
-class ExistsQueryBuilder extends QueryBuilder {
-
-  private final String name;
-
-  ExistsQueryBuilder(String name) {
-    this.name = name;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("exists");
-    builder.field("field", name);
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
deleted file mode 100644
index a3b303c..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/MatchAllQueryBuilder.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A query that matches on all documents.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.MatchAllQueryBuilder.
- */
-class MatchAllQueryBuilder extends QueryBuilder {
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("match_all");
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
deleted file mode 100644
index c0becd1..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/MatchQueryBuilder.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-import java.util.Locale;
-
-/**
- * Match query is a query that analyzes the text and constructs a query as the result of the
- * analysis. It can construct different queries based on the type provided.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.MatchQueryBuilder.
- */
-class MatchQueryBuilder extends QueryBuilder {
-
-  enum Type {
-    /** The text is analyzed and used as a phrase query. */
-    MATCH_PHRASE,
-    /** The text is analyzed and used in a phrase query, with the last term acting as a prefix. */
-    MATCH_PHRASE_PREFIX;
-
-    @Override
-    public String toString() {
-      return name().toLowerCase(Locale.US);
-    }
-  }
-
-  private final String name;
-
-  private final Object text;
-
-  private Type type;
-
-  /** Constructs a new text query. */
-  MatchQueryBuilder(String name, Object text) {
-    this.name = name;
-    this.text = text;
-  }
-
-  /** Sets the type of the text query. */
-  MatchQueryBuilder type(Type type) {
-    this.type = type;
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject(type.toString()).field(name, text).endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
deleted file mode 100644
index d6f154e..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QueryBuilder.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/** A trimmed down version of org.elasticsearch.index.query.QueryBuilder. */
-public abstract class QueryBuilder {
-
-  protected QueryBuilder() {}
-
-  protected void toXContent(XContentBuilder builder) throws IOException {
-    builder.startObject();
-    doXContent(builder);
-    builder.endObject();
-  }
-
-  protected abstract void doXContent(XContentBuilder builder) throws IOException;
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java b/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
deleted file mode 100644
index 940146f..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QueryBuilders.java
+++ /dev/null
@@ -1,103 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-/**
- * A static factory for simple "import static" usage.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.QueryBuilders.
- */
-public abstract class QueryBuilders {
-
-  /** A query that match on all documents. */
-  public static MatchAllQueryBuilder matchAllQuery() {
-    return new MatchAllQueryBuilder();
-  }
-
-  /**
-   * Creates a text query with type "PHRASE" for the provided field name and text.
-   *
-   * @param name The field name.
-   * @param text The query text (to be analyzed).
-   */
-  public static MatchQueryBuilder matchPhraseQuery(String name, Object text) {
-    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE);
-  }
-
-  /**
-   * Creates a match query with type "PHRASE_PREFIX" for the provided field name and text.
-   *
-   * @param name The field name.
-   * @param text The query text (to be analyzed).
-   */
-  public static MatchQueryBuilder matchPhrasePrefixQuery(String name, Object text) {
-    return new MatchQueryBuilder(name, text).type(MatchQueryBuilder.Type.MATCH_PHRASE_PREFIX);
-  }
-
-  /**
-   * A Query that matches documents containing a term.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  public static TermQueryBuilder termQuery(String name, String value) {
-    return new TermQueryBuilder(name, value);
-  }
-
-  /**
-   * A Query that matches documents containing a term.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  public static TermQueryBuilder termQuery(String name, int value) {
-    return new TermQueryBuilder(name, value);
-  }
-
-  /**
-   * A Query that matches documents within an range of terms.
-   *
-   * @param name The field name
-   */
-  public static RangeQueryBuilder rangeQuery(String name) {
-    return new RangeQueryBuilder(name);
-  }
-
-  /**
-   * A Query that matches documents containing terms with a specified regular expression.
-   *
-   * @param name The name of the field
-   * @param regexp The regular expression
-   */
-  public static RegexpQueryBuilder regexpQuery(String name, String regexp) {
-    return new RegexpQueryBuilder(name, regexp);
-  }
-
-  /** A Query that matches documents matching boolean combinations of other queries. */
-  public static BoolQueryBuilder boolQuery() {
-    return new BoolQueryBuilder();
-  }
-
-  /**
-   * A filter to filter only documents where a field exists in them.
-   *
-   * @param name The name of the field
-   */
-  public static ExistsQueryBuilder existsQuery(String name) {
-    return new ExistsQueryBuilder(name);
-  }
-
-  private QueryBuilders() {}
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
deleted file mode 100644
index 1cb5c82..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/QuerySourceBuilder.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/** A trimmed down and modified version of org.elasticsearch.action.support.QuerySourceBuilder. */
-class QuerySourceBuilder {
-
-  private final QueryBuilder queryBuilder;
-
-  QuerySourceBuilder(QueryBuilder queryBuilder) {
-    this.queryBuilder = queryBuilder;
-  }
-
-  void innerToXContent(XContentBuilder builder) throws IOException {
-    builder.field("query");
-    queryBuilder.toXContent(builder);
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
deleted file mode 100644
index 32dbc0e..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/RangeQueryBuilder.java
+++ /dev/null
@@ -1,89 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that matches documents within an range of terms.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.RangeQueryBuilder.
- */
-public class RangeQueryBuilder extends QueryBuilder {
-
-  private final String name;
-  private Object from;
-  private Object to;
-  private boolean includeLower = true;
-  private boolean includeUpper = true;
-
-  /**
-   * A Query that matches documents within an range of terms.
-   *
-   * @param name The field name
-   */
-  RangeQueryBuilder(String name) {
-    this.name = name;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gt(Object from) {
-    this.from = from;
-    this.includeLower = false;
-    return this;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gte(Object from) {
-    this.from = from;
-    this.includeLower = true;
-    return this;
-  }
-
-  /** The from part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder gte(int from) {
-    this.from = from;
-    this.includeLower = true;
-    return this;
-  }
-
-  /** The to part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder lte(Object to) {
-    this.to = to;
-    this.includeUpper = true;
-    return this;
-  }
-
-  /** The to part of the range query. Null indicates unbounded. */
-  public RangeQueryBuilder lte(int to) {
-    this.to = to;
-    this.includeUpper = true;
-    return this;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("range");
-    builder.startObject(name);
-
-    builder.field("from", from);
-    builder.field("to", to);
-    builder.field("include_lower", includeLower);
-    builder.field("include_upper", includeUpper);
-
-    builder.endObject();
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
deleted file mode 100644
index b81ec20..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/RegexpQueryBuilder.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that does fuzzy matching for a specific value.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.RegexpQueryBuilder.
- */
-class RegexpQueryBuilder extends QueryBuilder {
-
-  private final String name;
-  private final String regexp;
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param regexp The regular expression
-   */
-  RegexpQueryBuilder(String name, String regexp) {
-    this.name = name;
-    this.regexp = regexp;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("regexp");
-    builder.startObject(name);
-
-    builder.field("value", regexp);
-    builder.field("flags_value", 65535);
-
-    builder.endObject();
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java b/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
deleted file mode 100644
index 35cbea9..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/SearchSourceBuilder.java
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import com.google.gerrit.elasticsearch.ElasticQueryAdapter;
-import java.io.IOException;
-import java.util.List;
-
-/**
- * A search source builder allowing to easily build search source.
- *
- * <p>A trimmed down and modified version of org.elasticsearch.search.builder.SearchSourceBuilder.
- */
-public class SearchSourceBuilder {
-  private final ElasticQueryAdapter adapter;
-
-  private QuerySourceBuilder querySourceBuilder;
-
-  private int from = -1;
-
-  private int size = -1;
-
-  private List<String> fieldNames;
-
-  /** Constructs a new search source builder. */
-  public SearchSourceBuilder(ElasticQueryAdapter adapter) {
-    this.adapter = adapter;
-  }
-
-  /** Constructs a new search source builder with a search query. */
-  public SearchSourceBuilder query(QueryBuilder query) {
-    if (this.querySourceBuilder == null) {
-      this.querySourceBuilder = new QuerySourceBuilder(query);
-    }
-    return this;
-  }
-
-  /** From index to start the search from. Defaults to <tt>0</tt>. */
-  public SearchSourceBuilder from(int from) {
-    this.from = from;
-    return this;
-  }
-
-  /** The number of search hits to return. Defaults to <tt>10</tt>. */
-  public SearchSourceBuilder size(int size) {
-    this.size = size;
-    return this;
-  }
-
-  /**
-   * Sets the fields to load and return as part of the search request. If none are specified, the
-   * source of the document will be returned.
-   */
-  public SearchSourceBuilder fields(List<String> fields) {
-    this.fieldNames = fields;
-    return this;
-  }
-
-  @Override
-  public final String toString() {
-    try {
-      XContentBuilder builder = new XContentBuilder();
-      toXContent(builder);
-      return builder.string();
-    } catch (IOException ioe) {
-      return "";
-    }
-  }
-
-  private void toXContent(XContentBuilder builder) throws IOException {
-    builder.startObject();
-    innerToXContent(builder);
-    builder.endObject();
-  }
-
-  private void innerToXContent(XContentBuilder builder) throws IOException {
-    if (from != -1) {
-      builder.field("from", from);
-    }
-    if (size != -1) {
-      builder.field("size", size);
-    }
-
-    if (querySourceBuilder != null) {
-      querySourceBuilder.innerToXContent(builder);
-    }
-
-    if (fieldNames != null) {
-      if (fieldNames.size() == 1) {
-        builder.field(adapter.searchFilteringName(), fieldNames.get(0));
-      } else {
-        builder.startArray(adapter.searchFilteringName());
-        for (String fieldName : fieldNames) {
-          builder.value(fieldName);
-        }
-        builder.endArray();
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java b/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
deleted file mode 100644
index 2b407c6..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/TermQueryBuilder.java
+++ /dev/null
@@ -1,67 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import java.io.IOException;
-
-/**
- * A Query that matches documents containing a term.
- *
- * <p>A trimmed down version of org.elasticsearch.index.query.TermQueryBuilder.
- */
-class TermQueryBuilder extends QueryBuilder {
-
-  private final String name;
-
-  private final Object value;
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  TermQueryBuilder(String name, String value) {
-    this(name, (Object) value);
-  }
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  TermQueryBuilder(String name, int value) {
-    this(name, (Object) value);
-  }
-
-  /**
-   * Constructs a new term query.
-   *
-   * @param name The name of the field
-   * @param value The value of the term
-   */
-  private TermQueryBuilder(String name, Object value) {
-    this.name = name;
-    this.value = value;
-  }
-
-  @Override
-  protected void doXContent(XContentBuilder builder) throws IOException {
-    builder.startObject("term");
-    builder.field(name, value);
-    builder.endObject();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java b/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
deleted file mode 100644
index 9c44583..0000000
--- a/java/com/google/gerrit/elasticsearch/builders/XContentBuilder.java
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (C) 2018 The Android Open Source Project, 2009-2015 Elasticsearch
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.elasticsearch.builders;
-
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.time.format.DateTimeFormatter.ISO_INSTANT;
-
-import com.fasterxml.jackson.core.JsonEncoding;
-import com.fasterxml.jackson.core.JsonFactory;
-import com.fasterxml.jackson.core.JsonGenerator;
-import com.fasterxml.jackson.core.json.JsonReadFeature;
-import com.fasterxml.jackson.core.json.JsonWriteFeature;
-import java.io.ByteArrayOutputStream;
-import java.io.Closeable;
-import java.io.IOException;
-import java.util.Date;
-
-/** A trimmed down and modified version of org.elasticsearch.common.xcontent.XContentBuilder. */
-public final class XContentBuilder implements Closeable {
-
-  private final JsonGenerator generator;
-
-  private final ByteArrayOutputStream bos = new ByteArrayOutputStream();
-
-  /**
-   * Constructs a new builder. Make sure to call {@link #close()} when the builder is done with.
-   * Inspired from org.elasticsearch.common.xcontent.json.JsonXContent static block.
-   */
-  public XContentBuilder() throws IOException {
-    this.generator =
-        JsonFactory.builder()
-            .configure(JsonReadFeature.ALLOW_UNQUOTED_FIELD_NAMES, true)
-            .configure(JsonWriteFeature.QUOTE_FIELD_NAMES, true)
-            .configure(JsonReadFeature.ALLOW_JAVA_COMMENTS, true)
-            .configure(JsonFactory.Feature.FAIL_ON_SYMBOL_HASH_OVERFLOW, false)
-            .build()
-            .createGenerator(bos, JsonEncoding.UTF8);
-  }
-
-  public XContentBuilder startObject(String name) throws IOException {
-    field(name);
-    startObject();
-    return this;
-  }
-
-  public XContentBuilder startObject() throws IOException {
-    generator.writeStartObject();
-    return this;
-  }
-
-  public XContentBuilder endObject() throws IOException {
-    generator.writeEndObject();
-    return this;
-  }
-
-  public void startArray(String name) throws IOException {
-    field(name);
-    startArray();
-  }
-
-  private void startArray() throws IOException {
-    generator.writeStartArray();
-  }
-
-  public void endArray() throws IOException {
-    generator.writeEndArray();
-  }
-
-  public XContentBuilder field(String name) throws IOException {
-    generator.writeFieldName(name);
-    return this;
-  }
-
-  public XContentBuilder field(String name, String value) throws IOException {
-    field(name);
-    generator.writeString(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, int value) throws IOException {
-    field(name);
-    generator.writeNumber(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, Iterable<?> value) throws IOException {
-    startArray(name);
-    for (Object o : value) {
-      value(o);
-    }
-    endArray();
-    return this;
-  }
-
-  public XContentBuilder field(String name, Object value) throws IOException {
-    field(name);
-    writeValue(value);
-    return this;
-  }
-
-  public XContentBuilder value(Object value) throws IOException {
-    writeValue(value);
-    return this;
-  }
-
-  public XContentBuilder field(String name, boolean value) throws IOException {
-    field(name);
-    generator.writeBoolean(value);
-    return this;
-  }
-
-  public XContentBuilder value(String value) throws IOException {
-    generator.writeString(value);
-    return this;
-  }
-
-  @Override
-  public void close() {
-    try {
-      generator.close();
-    } catch (IOException e) {
-      // ignore
-    }
-  }
-
-  /** Returns a string representation of the builder (only applicable for text based xcontent). */
-  public String string() {
-    close();
-    byte[] bytesArray = bos.toByteArray();
-    return new String(bytesArray, UTF_8);
-  }
-
-  private void writeValue(Object value) throws IOException {
-    if (value == null) {
-      generator.writeNull();
-      return;
-    }
-    Class<?> type = value.getClass();
-    if (type == String.class) {
-      generator.writeString((String) value);
-    } else if (type == Integer.class) {
-      generator.writeNumber(((Integer) value));
-    } else if (type == byte[].class) {
-      generator.writeBinary((byte[]) value);
-    } else if (value instanceof Date) {
-      generator.writeString(ISO_INSTANT.format(((Date) value).toInstant()));
-    } else {
-      // if this is a "value" object, like enum, DistanceUnit, ..., just toString it
-      // yea, it can be misleading when toString a Java class, but really, jackson should be used in
-      // that case
-      generator.writeString(value.toString());
-      // throw new ElasticsearchIllegalArgumentException("type not supported for generic value
-      // conversion: " + type);
-    }
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java b/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
deleted file mode 100644
index 16b821c..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/ActionRequest.java
+++ /dev/null
@@ -1,41 +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.elasticsearch.bulk;
-
-import com.google.gson.JsonObject;
-
-abstract class ActionRequest extends BulkRequest {
-
-  private final String action;
-  private final String id;
-  private final String index;
-
-  protected ActionRequest(String action, String id, String index) {
-    this.action = action;
-    this.id = id;
-    this.index = index;
-  }
-
-  @Override
-  protected String getRequest() {
-    JsonObject properties = new JsonObject();
-    properties.addProperty("_id", id);
-    properties.addProperty("_index", index);
-
-    JsonObject jsonAction = new JsonObject();
-    jsonAction.add(action, properties);
-    return jsonAction.toString() + System.lineSeparator();
-  }
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java b/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
deleted file mode 100644
index be5ad8d..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/BulkRequest.java
+++ /dev/null
@@ -1,43 +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.elasticsearch.bulk;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public abstract class BulkRequest {
-
-  private final List<BulkRequest> requests = new ArrayList<>();
-
-  protected BulkRequest() {
-    add(this);
-  }
-
-  public BulkRequest add(BulkRequest request) {
-    requests.add(request);
-    return this;
-  }
-
-  @Override
-  public String toString() {
-    StringBuilder builder = new StringBuilder();
-    for (BulkRequest request : requests) {
-      builder.append(request.getRequest());
-    }
-    return builder.toString();
-  }
-
-  protected abstract String getRequest();
-}
diff --git a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java b/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
deleted file mode 100644
index 196b8d6..0000000
--- a/java/com/google/gerrit/elasticsearch/bulk/UpdateRequest.java
+++ /dev/null
@@ -1,63 +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.elasticsearch.bulk;
-
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Streams;
-import com.google.gerrit.elasticsearch.builders.XContentBuilder;
-import com.google.gerrit.index.Schema;
-import com.google.gerrit.index.Schema.Values;
-import java.io.IOException;
-
-public class UpdateRequest<V> extends BulkRequest {
-
-  private final Schema<V> schema;
-  private final V v;
-  private final ImmutableSet<String> skipFields;
-
-  public UpdateRequest(Schema<V> schema, V v, ImmutableSet<String> skipFields) {
-    this.schema = schema;
-    this.v = v;
-    this.skipFields = skipFields;
-  }
-
-  @Override
-  protected String getRequest() {
-    try (XContentBuilder closeable = new XContentBuilder()) {
-      XContentBuilder builder = closeable.startObject();
-      for (Values<V> values : schema.buildFields(v, skipFields)) {
-        String name = values.getField().getName();
-        if (values.getField().isRepeatable()) {
-          builder.field(name, Streams.stream(values.getValues()).collect(toList()));
-        } else {
-          Object element = Iterables.getOnlyElement(values.getValues(), "");
-          if (shouldAddElement(element)) {
-            builder.field(name, element);
-          }
-        }
-      }
-      return builder.endObject().string() + System.lineSeparator();
-    } catch (IOException e) {
-      return e.toString();
-    }
-  }
-
-  private boolean shouldAddElement(Object element) {
-    return !(element instanceof String) || !((String) element).isEmpty();
-  }
-}
diff --git a/java/com/google/gerrit/entities/AccessSection.java b/java/com/google/gerrit/entities/AccessSection.java
index d97bca8..8ae0a5d 100644
--- a/java/com/google/gerrit/entities/AccessSection.java
+++ b/java/com/google/gerrit/entities/AccessSection.java
@@ -18,12 +18,14 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Optional;
 import java.util.function.Consumer;
+import java.util.regex.Pattern;
 
 /** Portion of a {@link Project} describing access rules. */
 @AutoValue
@@ -42,6 +44,20 @@
   /** Name of the access section. It could be a ref pattern or something else. */
   public abstract String getName();
 
+  /**
+   * A compiled regular expression in case {@link #getName()} is a regular expression. This is
+   * memoized to save callers from compiling patterns for every use.
+   */
+  @Memoized
+  public Optional<Pattern> getNamePattern() {
+    if (isValidRefSectionName(getName())
+        && getName().startsWith(REGEX_PREFIX)
+        && !getName().contains("${")) {
+      return Optional.of(Pattern.compile(getName()));
+    }
+    return Optional.empty();
+  }
+
   public abstract ImmutableList<Permission> getPermissions();
 
   public static AccessSection create(String name) {
@@ -52,7 +68,7 @@
     return new AutoValue_AccessSection.Builder().setName(name).setPermissions(ImmutableList.of());
   }
 
-  /** @return true if the name is likely to be a valid reference section name. */
+  /** Returns true if the name is likely to be a valid reference section name. */
   public static boolean isValidRefSectionName(String name) {
     return name.startsWith("refs/") || name.startsWith("^refs/");
   }
diff --git a/java/com/google/gerrit/entities/AccountGroup.java b/java/com/google/gerrit/entities/AccountGroup.java
index 0b2a346..001a544 100644
--- a/java/com/google/gerrit/entities/AccountGroup.java
+++ b/java/com/google/gerrit/entities/AccountGroup.java
@@ -54,7 +54,7 @@
       return uuid();
     }
 
-    /** @return true if the UUID is for a group managed within Gerrit. */
+    /** Returns true if the UUID is for a group managed within Gerrit. */
     public boolean isInternalGroup() {
       return get().matches("^[0-9a-f]{40}$");
     }
diff --git a/java/com/google/gerrit/entities/Address.java b/java/com/google/gerrit/entities/Address.java
index 2324330..5d63476 100644
--- a/java/com/google/gerrit/entities/Address.java
+++ b/java/com/google/gerrit/entities/Address.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.gerrit.common.Nullable;
 
 /** Represents an address (name + email) in an email message. */
@@ -66,8 +67,9 @@
 
   public abstract String email();
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return email().hashCode();
   }
 
diff --git a/java/com/google/gerrit/entities/CachedProjectConfig.java b/java/com/google/gerrit/entities/CachedProjectConfig.java
index 2a94bc8..76e35db 100644
--- a/java/com/google/gerrit/entities/CachedProjectConfig.java
+++ b/java/com/google/gerrit/entities/CachedProjectConfig.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.flogger.FluentLogger;
 import java.util.Collection;
 import java.util.List;
@@ -69,7 +70,7 @@
   public abstract AccountsSection getAccountsSection();
 
   /** Returns a map of {@link AccessSection}s keyed by their name. */
-  public abstract ImmutableMap<String, AccessSection> getAccessSections();
+  public abstract ImmutableSortedMap<String, AccessSection> getAccessSections();
 
   /** Returns the {@link AccessSection} with to the given name. */
   public Optional<AccessSection> getAccessSection(String refName) {
@@ -95,6 +96,9 @@
   /** Returns the {@link LabelType}s keyed by their name. */
   public abstract ImmutableMap<String, LabelType> getLabelSections();
 
+  /** Returns the {@link SubmitRequirement}s keyed by their name. */
+  public abstract ImmutableMap<String, SubmitRequirement> getSubmitRequirementSections();
+
   /** Returns configured {@link ConfiguredMimeTypes}s. */
   public abstract ConfiguredMimeTypes getMimeTypes();
 
@@ -169,6 +173,11 @@
       return this;
     }
 
+    public Builder addSubmitRequirementSection(SubmitRequirement submitRequirement) {
+      submitRequirementSectionsBuilder().put(submitRequirement.name(), submitRequirement);
+      return this;
+    }
+
     public abstract Builder setMimeTypes(ConfiguredMimeTypes value);
 
     public Builder addSubscribeSection(SubscribeSection subscribeSection) {
@@ -227,7 +236,7 @@
 
     protected abstract ImmutableMap.Builder<AccountGroup.UUID, GroupReference> groupsBuilder();
 
-    protected abstract ImmutableMap.Builder<String, AccessSection> accessSectionsBuilder();
+    protected abstract ImmutableSortedMap.Builder<String, AccessSection> accessSectionsBuilder();
 
     protected abstract ImmutableMap.Builder<String, ContributorAgreement>
         contributorAgreementsBuilder();
@@ -236,6 +245,9 @@
 
     protected abstract ImmutableMap.Builder<String, LabelType> labelSectionsBuilder();
 
+    protected abstract ImmutableMap.Builder<String, SubmitRequirement>
+        submitRequirementSectionsBuilder();
+
     protected abstract ImmutableMap.Builder<Project.NameKey, SubscribeSection>
         subscribeSectionsBuilder();
 
diff --git a/java/com/google/gerrit/entities/Change.java b/java/com/google/gerrit/entities/Change.java
index ca13db9..d1826bc 100644
--- a/java/com/google/gerrit/entities/Change.java
+++ b/java/com/google/gerrit/entities/Change.java
@@ -443,9 +443,6 @@
   /** Globally assigned unique identifier of the change */
   protected Key changeKey;
 
-  /** optimistic locking */
-  protected int rowVersion;
-
   /** When this change was first introduced into the database. */
   protected Timestamp createdOn;
 
@@ -526,7 +523,6 @@
     assignee = other.assignee;
     changeId = other.changeId;
     changeKey = other.changeKey;
-    rowVersion = other.rowVersion;
     createdOn = other.createdOn;
     lastUpdatedOn = other.lastUpdatedOn;
     owner = other.owner;
@@ -587,10 +583,6 @@
     lastUpdatedOn = now;
   }
 
-  public int getRowVersion() {
-    return rowVersion;
-  }
-
   public Account.Id getOwner() {
     return owner;
   }
diff --git a/java/com/google/gerrit/entities/ChangeMessage.java b/java/com/google/gerrit/entities/ChangeMessage.java
index f34cc7d..cb56c31 100644
--- a/java/com/google/gerrit/entities/ChangeMessage.java
+++ b/java/com/google/gerrit/entities/ChangeMessage.java
@@ -19,8 +19,16 @@
 import java.sql.Timestamp;
 import java.util.Objects;
 
-/** A message attached to a {@link Change}. */
+/**
+ * A message attached to a {@link Change}. This message is persisted in data storage, that is why it
+ * must have template form that does not contain Gerrit user identifiable information. Hence, it
+ * requires processing to convert it to user-facing form.
+ *
+ * <p>These messages are normally auto-generated by gerrit operations, but might also incorporate
+ * user input.
+ */
 public final class ChangeMessage {
+
   public static Key key(Change.Id changeId, String uuid) {
     return new AutoValue_ChangeMessage_Key(changeId, uuid);
   }
@@ -40,7 +48,10 @@
   /** When this comment was drafted. */
   protected Timestamp writtenOn;
 
-  /** The text left by the user. */
+  /**
+   * The text left by the user or Gerrit system in template form, that is free of Gerrit User
+   * Identifiable Information and can be persisted in data storage.
+   */
   @Nullable protected String message;
 
   /** Which patchset (if any) was this message generated from? */
@@ -54,11 +65,29 @@
 
   protected ChangeMessage() {}
 
-  public ChangeMessage(final ChangeMessage.Key k, Account.Id a, Timestamp wo, PatchSet.Id psid) {
-    key = k;
-    author = a;
-    writtenOn = wo;
-    patchset = psid;
+  public static ChangeMessage create(
+      final ChangeMessage.Key k, @Nullable Account.Id a, Timestamp wo, @Nullable PatchSet.Id psid) {
+    return create(k, a, wo, psid, /*messageTemplate=*/ null, /*realAuthor=*/ null, /*tag=*/ null);
+  }
+
+  public static ChangeMessage create(
+      final ChangeMessage.Key k,
+      @Nullable Account.Id a,
+      Timestamp wo,
+      @Nullable PatchSet.Id psid,
+      @Nullable String messageTemplate,
+      @Nullable Account.Id realAuthor,
+      @Nullable String tag) {
+    ChangeMessage message = new ChangeMessage();
+    message.key = k;
+    message.author = a;
+    message.writtenOn = wo;
+    message.patchset = psid;
+    message.message = messageTemplate;
+    // Use null for same real author, as before the column was added.
+    message.realAuthor = Objects.equals(a, realAuthor) ? null : realAuthor;
+    message.tag = tag;
+    return message;
   }
 
   public ChangeMessage.Key getKey() {
@@ -70,54 +99,27 @@
     return author;
   }
 
-  public void setAuthor(Account.Id accountId) {
-    if (author != null) {
-      throw new IllegalStateException("Cannot modify author once assigned");
-    }
-    author = accountId;
-  }
-
   public Account.Id getRealAuthor() {
     return realAuthor != null ? realAuthor : getAuthor();
   }
 
-  public void setRealAuthor(Account.Id id) {
-    // Use null for same real author, as before the column was added.
-    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
-  }
-
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
 
-  public void setWrittenOn(Timestamp ts) {
-    writtenOn = ts;
-  }
-
+  /** Message template, as persisted in data storage. */
   public String getMessage() {
     return message;
   }
 
-  public void setMessage(String s) {
-    message = s;
-  }
-
   public String getTag() {
     return tag;
   }
 
-  public void setTag(String tag) {
-    this.tag = tag;
-  }
-
   public PatchSet.Id getPatchSetId() {
     return patchset;
   }
 
-  public void setPatchSetId(PatchSet.Id id) {
-    patchset = id;
-  }
-
   @Override
   public boolean equals(Object o) {
     if (!(o instanceof ChangeMessage)) {
diff --git a/java/com/google/gerrit/entities/CoreDownloadSchemes.java b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
index 9bcd365..85e55a0 100644
--- a/java/com/google/gerrit/entities/CoreDownloadSchemes.java
+++ b/java/com/google/gerrit/entities/CoreDownloadSchemes.java
@@ -20,7 +20,6 @@
   public static final String ANON_HTTP = "anonymous http";
   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/EmailHeader.java b/java/com/google/gerrit/entities/EmailHeader.java
index 71414c7..bf5a644 100644
--- a/java/com/google/gerrit/entities/EmailHeader.java
+++ b/java/com/google/gerrit/entities/EmailHeader.java
@@ -31,14 +31,14 @@
 
   public abstract void write(Writer w) throws IOException;
 
-  public static class String extends EmailHeader {
-    private final java.lang.String value;
+  public static class StringEmailHeader extends EmailHeader {
+    private final String value;
 
-    public String(java.lang.String v) {
+    public StringEmailHeader(String v) {
       value = v;
     }
 
-    public java.lang.String getString() {
+    public String getString() {
       return value;
     }
 
@@ -63,16 +63,17 @@
 
     @Override
     public boolean equals(Object o) {
-      return (o instanceof String) && Objects.equals(value, ((String) o).value);
+      return (o instanceof StringEmailHeader)
+          && Objects.equals(value, ((StringEmailHeader) o).value);
     }
 
     @Override
-    public java.lang.String toString() {
+    public String toString() {
       return MoreObjects.toStringHelper(this).addValue(value).toString();
     }
   }
 
-  public static boolean needsQuotedPrintable(java.lang.String value) {
+  public static boolean needsQuotedPrintable(String value) {
     for (int i = 0; i < value.length(); i++) {
       if (value.charAt(i) < ' ' || '~' < value.charAt(i)) {
         return true;
@@ -99,7 +100,7 @@
     }
   }
 
-  public static java.lang.String quotedPrintable(java.lang.String value) {
+  public static String quotedPrintable(String value) {
     final StringBuilder r = new StringBuilder();
 
     r.append("=?UTF-8?Q?");
@@ -109,7 +110,7 @@
         r.append('_');
 
       } else if (needsQuotedPrintableWithinPhrase(cp)) {
-        byte[] buf = new java.lang.String(Character.toChars(cp)).getBytes(UTF_8);
+        byte[] buf = new String(Character.toChars(cp)).getBytes(UTF_8);
         for (byte b : buf) {
           r.append('=');
           r.append(Integer.toHexString((b >>> 4) & 0x0f).toUpperCase());
@@ -160,7 +161,7 @@
     }
 
     @Override
-    public java.lang.String toString() {
+    public String toString() {
       return MoreObjects.toStringHelper(this).addValue(value).toString();
     }
   }
@@ -182,7 +183,7 @@
       list.add(addr);
     }
 
-    public void remove(java.lang.String email) {
+    public void remove(String email) {
       list.removeIf(address -> address.email().equals(email));
     }
 
@@ -197,7 +198,7 @@
       boolean firstAddress = true;
       boolean needComma = false;
       for (Address addr : list) {
-        java.lang.String s = addr.toHeaderString();
+        String s = addr.toHeaderString();
         if (firstAddress) {
           firstAddress = false;
         } else if (72 < len + s.length()) {
@@ -226,7 +227,7 @@
     }
 
     @Override
-    public java.lang.String toString() {
+    public String toString() {
       return MoreObjects.toStringHelper(this).addValue(list).toString();
     }
   }
diff --git a/java/com/google/gerrit/entities/GroupDescription.java b/java/com/google/gerrit/entities/GroupDescription.java
index e950257..7054bed 100644
--- a/java/com/google/gerrit/entities/GroupDescription.java
+++ b/java/com/google/gerrit/entities/GroupDescription.java
@@ -22,22 +22,22 @@
 public class GroupDescription {
   /** The Basic information required to be exposed by any Group. */
   public interface Basic {
-    /** @return the non-null UUID of the group. */
+    /** Returns the non-null UUID of the group. */
     AccountGroup.UUID getGroupUUID();
 
-    /** @return the non-null name of the group. */
+    /** Returns the non-null name of the group. */
     String getName();
 
     /**
-     * @return optional email address to send to the group's members. If provided, Gerrit will use
-     *     this email address to send change notifications to the group.
+     * Returns optional email address to send to the group's members. If provided, Gerrit will use
+     * this email address to send change notifications to the group.
      */
     @Nullable
     String getEmailAddress();
 
     /**
-     * @return optional URL to information about the group. Typically a URL to a web page that
-     *     permits users to apply to join the group, or manage their membership.
+     * Returns optional URL to information about the group. Typically a URL to a web page that
+     * permits users to apply to join the group, or manage their membership.
      */
     @Nullable
     String getUrl();
diff --git a/java/com/google/gerrit/entities/GroupReference.java b/java/com/google/gerrit/entities/GroupReference.java
index 208ba0f..125153e 100644
--- a/java/com/google/gerrit/entities/GroupReference.java
+++ b/java/com/google/gerrit/entities/GroupReference.java
@@ -17,6 +17,7 @@
 import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.gerrit.common.Nullable;
 
 /** Describes a group within a projects {@link AccessSection}s. */
@@ -78,8 +79,9 @@
     return "?";
   }
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return uuid(this).hashCode();
   }
 
diff --git a/java/com/google/gerrit/entities/ImmutableConfig.java b/java/com/google/gerrit/entities/ImmutableConfig.java
index a5efc14..83a44d1 100644
--- a/java/com/google/gerrit/entities/ImmutableConfig.java
+++ b/java/com/google/gerrit/entities/ImmutableConfig.java
@@ -51,27 +51,27 @@
     return cfg;
   }
 
-  /** @see Config#getSections() */
+  /** See {@link Config#getSections()} */
   public Set<String> getSections() {
     return cfg.getSections();
   }
 
-  /** @see Config#getNames(String) */
+  /** See {@link Config#getNames(String)} */
   public Set<String> getNames(String section) {
     return cfg.getNames(section);
   }
 
-  /** @see Config#getNames(String, String) */
+  /** See {@link Config#getNames(String, String)} */
   public Set<String> getNames(String section, String subsection) {
     return cfg.getNames(section, subsection);
   }
 
-  /** @see Config#getStringList(String, String, String) */
+  /** See {@link Config#getStringList(String, String, String)} */
   public String[] getStringList(String section, String subsection, String name) {
     return cfg.getStringList(section, subsection, name);
   }
 
-  /** @see Config#getSubsections(String) */
+  /** See {@link Config#getSubsections(String)} */
   public Set<String> getSubsections(String section) {
     return cfg.getSubsections(section);
   }
diff --git a/java/com/google/gerrit/entities/LabelType.java b/java/com/google/gerrit/entities/LabelType.java
index 9649642..d254752 100644
--- a/java/com/google/gerrit/entities/LabelType.java
+++ b/java/com/google/gerrit/entities/LabelType.java
@@ -24,6 +24,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 @AutoValue
 public abstract class LabelType {
@@ -128,6 +129,8 @@
 
   public abstract boolean isCanOverride();
 
+  public abstract Optional<String> getCopyCondition();
+
   @Nullable
   public abstract ImmutableList<String> getRefPatterns();
 
@@ -239,6 +242,8 @@
 
     public abstract Builder setCopyAnyScore(boolean copyAnyScore);
 
+    public abstract Builder setCopyCondition(@Nullable String copyCondition);
+
     public abstract Builder setCopyMinScore(boolean copyMinScore);
 
     public abstract Builder setCopyMaxScore(boolean copyMaxScore);
diff --git a/java/com/google/gerrit/entities/LabelTypes.java b/java/com/google/gerrit/entities/LabelTypes.java
index 1c38c59..55a9976 100644
--- a/java/com/google/gerrit/entities/LabelTypes.java
+++ b/java/com/google/gerrit/entities/LabelTypes.java
@@ -20,6 +20,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 
 public class LabelTypes {
   protected List<LabelType> labelTypes;
@@ -36,12 +37,12 @@
     return labelTypes;
   }
 
-  public LabelType byLabel(LabelId labelId) {
-    return byLabel().get(labelId.get().toLowerCase());
+  public Optional<LabelType> byLabel(LabelId labelId) {
+    return Optional.ofNullable(byLabel().get(labelId.get().toLowerCase()));
   }
 
-  public LabelType byLabel(String labelName) {
-    return byLabel().get(labelName.toLowerCase());
+  public Optional<LabelType> byLabel(String labelName) {
+    return Optional.ofNullable(byLabel().get(labelName.toLowerCase()));
   }
 
   private Map<String, LabelType> byLabel() {
diff --git a/java/com/google/gerrit/entities/NotifyConfig.java b/java/com/google/gerrit/entities/NotifyConfig.java
index 17da81f..5c0a3db 100644
--- a/java/com/google/gerrit/entities/NotifyConfig.java
+++ b/java/com/google/gerrit/entities/NotifyConfig.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.entities;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
@@ -106,8 +107,9 @@
     return getName().compareTo(o.getName());
   }
 
+  @Memoized
   @Override
-  public final int hashCode() {
+  public int hashCode() {
     return getName().hashCode();
   }
 
diff --git a/java/com/google/gerrit/entities/Patch.java b/java/com/google/gerrit/entities/Patch.java
index 856765b..2d28046 100644
--- a/java/com/google/gerrit/entities/Patch.java
+++ b/java/com/google/gerrit/entities/Patch.java
@@ -78,25 +78,27 @@
     public abstract String fileName();
   }
 
-  /** Type of modification made to the file path. */
+  /**
+   * Type of modification made to the file path. Ordering of values matters (used by diff cache).
+   */
   public enum ChangeType implements CodedEnum {
     /** Path is being created/introduced by this patch. */
     ADDED('A'),
 
-    /** Path already exists, and has updated content. */
-    MODIFIED('M'),
-
-    /** Path existed, but is being removed by this patch. */
-    DELETED('D'),
-
     /** Path existed at the source but was moved. */
     RENAMED('R'),
 
+    /** Path already exists, and has updated content. */
+    MODIFIED('M'),
+
     /** Path was copied from the source. */
     COPIED('C'),
 
     /** Sufficient amount of content changed to claim the file was rewritten. */
-    REWRITE('W');
+    REWRITE('W'),
+
+    /** Path existed, but is being removed by this patch. */
+    DELETED('D');
 
     private final char code;
 
diff --git a/java/com/google/gerrit/entities/PatchSet.java b/java/com/google/gerrit/entities/PatchSet.java
index 5c8f7eb..b26e5c3 100644
--- a/java/com/google/gerrit/entities/PatchSet.java
+++ b/java/com/google/gerrit/entities/PatchSet.java
@@ -93,6 +93,12 @@
       return PatchSet.id(Change.id(changeId), patchSetId);
     }
 
+    /** Parse a PatchSet.Id from an edit ref. */
+    public static PatchSet.Id fromEditRef(String ref) {
+      Change.Id changeId = Change.Id.fromEditRefPart(ref);
+      return PatchSet.id(changeId, Ints.tryParse(ref.substring(ref.lastIndexOf('/') + 1)));
+    }
+
     static int fromRef(String ref, int changeIdEnd) {
       // Patch set ID.
       int ps = changeIdEnd + 1;
diff --git a/java/com/google/gerrit/entities/PatchSetApproval.java b/java/com/google/gerrit/entities/PatchSetApproval.java
index a4bb251..f853f77 100644
--- a/java/com/google/gerrit/entities/PatchSetApproval.java
+++ b/java/com/google/gerrit/entities/PatchSetApproval.java
@@ -41,7 +41,7 @@
   }
 
   public static Builder builder() {
-    return new AutoValue_PatchSetApproval.Builder().postSubmit(false);
+    return new AutoValue_PatchSetApproval.Builder().postSubmit(false).copied(false);
   }
 
   @AutoValue.Builder
@@ -72,6 +72,8 @@
 
     public abstract Builder postSubmit(boolean isPostSubmit);
 
+    public abstract Builder copied(boolean isCopied);
+
     abstract PatchSetApproval autoBuild();
 
     public PatchSetApproval build() {
@@ -111,10 +113,12 @@
 
   public abstract boolean postSubmit();
 
+  public abstract boolean copied();
+
   public abstract Builder toBuilder();
 
   public PatchSetApproval copyWithPatchSet(PatchSet.Id psId) {
-    return toBuilder().key(key(psId, key().accountId(), key().labelId())).build();
+    return toBuilder().key(key(psId, key().accountId(), key().labelId())).copied(true).build();
   }
 
   public PatchSet.Id patchSetId() {
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index 322c79e..95164bd 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -95,7 +95,7 @@
     LABEL_AS_INDEX = NAMES_LC.indexOf(Permission.LABEL_AS.toLowerCase());
   }
 
-  /** @return true if the name is recognized as a permission name. */
+  /** Returns true if the name is recognized as a permission name. */
   public static boolean isPermission(String varName) {
     return isLabel(varName) || isLabelAs(varName) || NAMES_LC.contains(varName.toLowerCase());
   }
@@ -104,22 +104,22 @@
     return isLabel(varName) || isLabelAs(varName);
   }
 
-  /** @return true if the permission name is actually for a review label. */
+  /** Returns true if the permission name is actually for a review label. */
   public static boolean isLabel(String varName) {
     return varName.startsWith(LABEL) && LABEL.length() < varName.length();
   }
 
-  /** @return true if the permission is for impersonated review labels. */
+  /** Returns true if the permission is for impersonated review labels. */
   public static boolean isLabelAs(String var) {
     return var.startsWith(LABEL_AS) && LABEL_AS.length() < var.length();
   }
 
-  /** @return permission name for the given review label. */
+  /** Returns permission name for the given review label. */
   public static String forLabel(String labelName) {
     return LABEL + labelName;
   }
 
-  /** @return permission name to apply a label for another user. */
+  /** Returns permission name to apply a label for another user. */
   public static String forLabelAs(String labelName) {
     return LABEL_AS + labelName;
   }
diff --git a/java/com/google/gerrit/entities/PermissionRange.java b/java/com/google/gerrit/entities/PermissionRange.java
index fa9f4c2..d283069 100644
--- a/java/com/google/gerrit/entities/PermissionRange.java
+++ b/java/com/google/gerrit/entities/PermissionRange.java
@@ -46,7 +46,7 @@
       defaultMax = max;
     }
 
-    /** @return all values between {@link #getMin()} and {@link #getMax()} */
+    /** Returns all values between {@link #getMin()} and {@link #getMax()} */
     public List<Integer> getValuesAsList() {
       ArrayList<Integer> r = new ArrayList<>(getRangeSize());
       for (int i = min; i <= max; i++) {
@@ -55,7 +55,7 @@
       return r;
     }
 
-    /** @return number of values between {@link #getMin()} and {@link #getMax()} */
+    /** Returns number of values between {@link #getMin()} and {@link #getMax()} */
     public int getRangeSize() {
       return max - min;
     }
diff --git a/java/com/google/gerrit/entities/Project.java b/java/com/google/gerrit/entities/Project.java
index ef3cbeb..617b827 100644
--- a/java/com/google/gerrit/entities/Project.java
+++ b/java/com/google/gerrit/entities/Project.java
@@ -150,6 +150,7 @@
     return builder;
   }
 
+  @Nullable
   public String getName() {
     return getNameKey() != null ? getNameKey().get() : null;
   }
@@ -183,7 +184,7 @@
 
   @Override
   public final String toString() {
-    return Optional.of(getName()).orElse("<null>");
+    return Optional.ofNullable(getName()).orElse("<null>");
   }
 
   public abstract Builder toBuilder();
diff --git a/java/com/google/gerrit/entities/SubmitRecord.java b/java/com/google/gerrit/entities/SubmitRecord.java
index 860997f..95ad9f8 100644
--- a/java/com/google/gerrit/entities/SubmitRecord.java
+++ b/java/com/google/gerrit/entities/SubmitRecord.java
@@ -68,6 +68,8 @@
     }
   }
 
+  // Name of the rule that created this submit record, formatted as '$pluginName~$ruleName'
+  public String ruleName;
   public Status status;
   public List<Label> labels;
   public List<LegacySubmitRequirement> requirements;
@@ -158,6 +160,7 @@
    */
   public SubmitRecord deepCopy() {
     SubmitRecord copy = new SubmitRecord();
+    copy.ruleName = ruleName;
     copy.status = status;
     copy.errorMessage = errorMessage;
     if (labels != null) {
diff --git a/java/com/google/gerrit/entities/SubmitRequirement.java b/java/com/google/gerrit/entities/SubmitRequirement.java
new file mode 100644
index 0000000..13e0b53
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirement.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 com.google.auto.value.AutoValue;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+
+/** Entity describing a requirement that should be met for a change to become submittable. */
+@AutoValue
+public abstract class SubmitRequirement {
+  /** Requirement name. */
+  public abstract String name();
+
+  /** Description of what this requirement means. */
+  public abstract Optional<String> description();
+
+  /**
+   * Expression of the condition that makes the requirement applicable. The expression should be
+   * evaluated for a specific {@link Change} and if it returns false, the requirement becomes
+   * irrelevant for the change (i.e. {@link #submittabilityExpression()} and {@link
+   * #overrideExpression()} become irrelevant).
+   *
+   * <p>An empty {@link Optional} indicates that the requirement is applicable for any change.
+   */
+  public abstract Optional<SubmitRequirementExpression> applicabilityExpression();
+
+  /**
+   * Expression of the condition that allows the submission of a change. The expression should be
+   * evaluated for a specific {@link Change} and if it returns true, the requirement becomes
+   * fulfilled for the change.
+   */
+  public abstract SubmitRequirementExpression submittabilityExpression();
+
+  /**
+   * Expression that, if evaluated to true, causes the submit requirement to be fulfilled,
+   * regardless of the submittability expression. This expression should be evaluated for a specific
+   * {@link Change}.
+   *
+   * <p>An empty {@link Optional} indicates that the requirement is not overridable.
+   */
+  public abstract Optional<SubmitRequirementExpression> overrideExpression();
+
+  /**
+   * Boolean value indicating if the {@link SubmitRequirement} definition can be overridden in child
+   * projects. Default is false.
+   */
+  public abstract boolean allowOverrideInChildProjects();
+
+  public static SubmitRequirement.Builder builder() {
+    return new AutoValue_SubmitRequirement.Builder();
+  }
+
+  public static TypeAdapter<SubmitRequirement> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirement.GsonTypeAdapter(gson);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder setName(String name);
+
+    public abstract Builder setDescription(Optional<String> description);
+
+    public abstract Builder setApplicabilityExpression(
+        Optional<SubmitRequirementExpression> applicabilityExpression);
+
+    public abstract Builder setSubmittabilityExpression(
+        SubmitRequirementExpression submittabilityExpression);
+
+    public abstract Builder setOverrideExpression(
+        Optional<SubmitRequirementExpression> overrideExpression);
+
+    public abstract Builder setAllowOverrideInChildProjects(boolean allowOverrideInChildProjects);
+
+    public abstract SubmitRequirement build();
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpression.java b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
new file mode 100644
index 0000000..2af1379
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpression.java
@@ -0,0 +1,50 @@
+// 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 com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+
+/** Describe a applicability, blocking or override expression of a {@link SubmitRequirement}. */
+@AutoValue
+public abstract class SubmitRequirementExpression {
+
+  public static SubmitRequirementExpression create(String expression) {
+    return new AutoValue_SubmitRequirementExpression(expression);
+  }
+
+  /**
+   * Creates a new {@link SubmitRequirementExpression}.
+   *
+   * @param expression String representation of the expression
+   * @return empty {@link Optional} if the input expression is null or empty, or an Optional
+   *     containing the expression otherwise.
+   */
+  public static Optional<SubmitRequirementExpression> of(@Nullable String expression) {
+    return Optional.ofNullable(Strings.emptyToNull(expression))
+        .map(SubmitRequirementExpression::create);
+  }
+
+  /** Returns the underlying String representing this {@link SubmitRequirementExpression}. */
+  public abstract String expressionString();
+
+  public static TypeAdapter<SubmitRequirementExpression> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpression.GsonTypeAdapter(gson);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
new file mode 100644
index 0000000..900b2e2
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementExpressionResult.java
@@ -0,0 +1,182 @@
+// 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 com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+
+/** Result of evaluating a submit requirement expression on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementExpressionResult {
+
+  /** Submit requirement expression for which this result is evaluated. */
+  public abstract SubmitRequirementExpression expression();
+
+  /** Status of evaluation. */
+  public abstract Status status();
+
+  /**
+   * Optional error message. Populated if the evaluator fails to evaluate the expression for a
+   * certain change.
+   */
+  public abstract Optional<String> errorMessage();
+
+  /**
+   * List leaf predicates that are fulfilled, for example the expression
+   *
+   * <p><i>label:code-review=+2 and branch:refs/heads/master</i>
+   *
+   * <p>has two leaf predicates:
+   *
+   * <ul>
+   *   <li>label:code-review=+2
+   *   <li>branch:refs/heads/master
+   * </ul>
+   *
+   * This method will return the leaf predicates that were fulfilled, for example if only the first
+   * predicate was fulfilled, the returned list will be equal to ["label:code-review=+2"].
+   */
+  public abstract ImmutableList<String> passingAtoms();
+
+  /**
+   * List of leaf predicates that are not fulfilled. See {@link #passingAtoms()} for more details.
+   */
+  public abstract ImmutableList<String> failingAtoms();
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression, PredicateResult predicateResult) {
+    return create(
+        expression,
+        predicateResult.status() ? Status.PASS : Status.FAIL,
+        predicateResult.getPassingAtoms(),
+        predicateResult.getFailingAtoms());
+  }
+
+  public static SubmitRequirementExpressionResult create(
+      SubmitRequirementExpression expression,
+      Status status,
+      ImmutableList<String> passingAtoms,
+      ImmutableList<String> failingAtoms) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression, status, Optional.empty(), passingAtoms, failingAtoms);
+  }
+
+  public static SubmitRequirementExpressionResult error(
+      SubmitRequirementExpression expression, String errorMessage) {
+    return new AutoValue_SubmitRequirementExpressionResult(
+        expression,
+        Status.ERROR,
+        Optional.of(errorMessage),
+        ImmutableList.of(),
+        ImmutableList.of());
+  }
+
+  public static TypeAdapter<SubmitRequirementExpressionResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementExpressionResult.GsonTypeAdapter(gson);
+  }
+
+  public enum Status {
+    /** Submit requirement expression is fulfilled for a given change. */
+    PASS,
+
+    /** Submit requirement expression is failing for a given change. */
+    FAIL,
+
+    /** Submit requirement expression contains invalid syntax and is not parsable. */
+    ERROR
+  }
+
+  /**
+   * Entity detailing the result of evaluating a predicate.
+   *
+   * <p>Example - branch:refs/heads/foo and has:unresolved
+   *
+   * <p>The above predicate is an "And" predicate having two child predicates:
+   *
+   * <ul>
+   *   <li>branch:refs/heads/foo
+   *   <li>has:unresolved
+   * </ul>
+   *
+   * <p>Each child predicate as well as the parent contains the result of its evaluation.
+   */
+  @AutoValue
+  public abstract static class PredicateResult {
+    abstract ImmutableList<PredicateResult> childPredicateResults();
+
+    /** We only set this field for leaf predicates. */
+    public abstract String predicateString();
+
+    /** true if the predicate is passing for a given change. */
+    abstract boolean status();
+
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is true. */
+    ImmutableList<String> getPassingAtoms() {
+      return getAtoms(/* status= */ true).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
+    /** Returns a list of leaf predicate results whose {@link PredicateResult#status()} is false. */
+    ImmutableList<String> getFailingAtoms() {
+      return getAtoms(/* status= */ false).stream()
+          .map(PredicateResult::predicateString)
+          .collect(ImmutableList.toImmutableList());
+    }
+
+    /**
+     * Returns the list of leaf {@link PredicateResult} whose {@link #status()} is equal to the
+     * {@code status} parameter.
+     */
+    private ImmutableList<PredicateResult> getAtoms(boolean status) {
+      ImmutableList.Builder<PredicateResult> atomsList = ImmutableList.builder();
+      getAtomsRecursively(atomsList, status);
+      return atomsList.build();
+    }
+
+    private void getAtomsRecursively(ImmutableList.Builder<PredicateResult> list, boolean status) {
+      if (!predicateString().isEmpty() && status() == status) {
+        list.add(this);
+        return;
+      }
+      childPredicateResults().forEach(c -> c.getAtomsRecursively(list, status));
+    }
+
+    public static Builder builder() {
+      return new AutoValue_SubmitRequirementExpressionResult_PredicateResult.Builder();
+    }
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder childPredicateResults(ImmutableList<PredicateResult> value);
+
+      protected abstract ImmutableList.Builder<PredicateResult> childPredicateResultsBuilder();
+
+      public abstract Builder predicateString(String value);
+
+      public abstract Builder status(boolean value);
+
+      public Builder addChildPredicateResult(PredicateResult result) {
+        childPredicateResultsBuilder().add(result);
+        return this;
+      }
+
+      public abstract PredicateResult build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubmitRequirementResult.java b/java/com/google/gerrit/entities/SubmitRequirementResult.java
new file mode 100644
index 0000000..13625c1
--- /dev/null
+++ b/java/com/google/gerrit/entities/SubmitRequirementResult.java
@@ -0,0 +1,167 @@
+// 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 com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.gson.Gson;
+import com.google.gson.TypeAdapter;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Result of evaluating a {@link SubmitRequirement} on a given Change. */
+@AutoValue
+public abstract class SubmitRequirementResult {
+  /** Submit requirement for which this result is evaluated. */
+  public abstract SubmitRequirement submitRequirement();
+
+  /** Result of evaluating a {@link SubmitRequirement#applicabilityExpression()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> applicabilityExpressionResult();
+
+  /**
+   * Result of evaluating a {@link SubmitRequirement#submittabilityExpression()} ()} on a change.
+   */
+  public abstract SubmitRequirementExpressionResult submittabilityExpressionResult();
+
+  /** Result of evaluating a {@link SubmitRequirement#overrideExpression()} ()} on a change. */
+  public abstract Optional<SubmitRequirementExpressionResult> overrideExpressionResult();
+
+  /** SHA-1 of the patchset commit ID for which the submit requirement was evaluated. */
+  public abstract ObjectId patchSetCommitId();
+
+  /**
+   * Whether this result was created from a legacy {@link SubmitRecord}, or by evaluating a {@link
+   * SubmitRequirement}.
+   *
+   * <p>If equals {@link Optional#empty()}, we treat the result as non-legacy (false).
+   */
+  public abstract Optional<Boolean> legacy();
+
+  public boolean isLegacy() {
+    return legacy().orElse(false);
+  }
+
+  @Memoized
+  public Status status() {
+    if (assertError(submittabilityExpressionResult())
+        || assertError(applicabilityExpressionResult())
+        || assertError(overrideExpressionResult())) {
+      return Status.ERROR;
+    } else if (assertFail(applicabilityExpressionResult())) {
+      return Status.NOT_APPLICABLE;
+    } else if (assertPass(overrideExpressionResult())) {
+      return Status.OVERRIDDEN;
+    } else if (assertPass(submittabilityExpressionResult())) {
+      return Status.SATISFIED;
+    } else {
+      return Status.UNSATISFIED;
+    }
+  }
+
+  /** Returns true if the submit requirement is fulfilled and can allow change submission. */
+  @Memoized
+  public boolean fulfilled() {
+    Status s = status();
+    return s == Status.SATISFIED || s == Status.OVERRIDDEN || s == Status.NOT_APPLICABLE;
+  }
+
+  public static Builder builder() {
+    return new AutoValue_SubmitRequirementResult.Builder();
+  }
+
+  public static TypeAdapter<SubmitRequirementResult> typeAdapter(Gson gson) {
+    return new AutoValue_SubmitRequirementResult.GsonTypeAdapter(gson);
+  }
+
+  public enum Status {
+    /** Submit requirement is fulfilled. */
+    SATISFIED,
+
+    /**
+     * Submit requirement is not satisfied. Happens when {@link
+     * SubmitRequirement#submittabilityExpression()} evaluates to false.
+     */
+    UNSATISFIED,
+
+    /**
+     * Submit requirement is overridden. Happens when {@link SubmitRequirement#overrideExpression()}
+     * evaluates to true.
+     */
+    OVERRIDDEN,
+
+    /**
+     * Submit requirement is not applicable for a given change. Happens when {@link
+     * SubmitRequirement#applicabilityExpression()} evaluates to false.
+     */
+    NOT_APPLICABLE,
+
+    /**
+     * Any of the applicability, blocking or override expressions contain invalid syntax and are not
+     * parsable.
+     */
+    ERROR
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder submitRequirement(SubmitRequirement submitRequirement);
+
+    public abstract Builder applicabilityExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder submittabilityExpressionResult(SubmitRequirementExpressionResult value);
+
+    public abstract Builder overrideExpressionResult(
+        Optional<SubmitRequirementExpressionResult> value);
+
+    public abstract Builder patchSetCommitId(ObjectId value);
+
+    public abstract Builder legacy(Optional<Boolean> value);
+
+    public abstract SubmitRequirementResult build();
+  }
+
+  private boolean assertPass(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertPass(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  private boolean assertFail(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private boolean assertError(Optional<SubmitRequirementExpressionResult> expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertError(SubmitRequirementExpressionResult expressionResult) {
+    return assertStatus(expressionResult, SubmitRequirementExpressionResult.Status.ERROR);
+  }
+
+  private boolean assertStatus(
+      SubmitRequirementExpressionResult expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.status() == status;
+  }
+
+  private boolean assertStatus(
+      Optional<SubmitRequirementExpressionResult> expressionResult,
+      SubmitRequirementExpressionResult.Status status) {
+    return expressionResult.isPresent() && assertStatus(expressionResult.get(), status);
+  }
+}
diff --git a/java/com/google/gerrit/entities/SubscribeSection.java b/java/com/google/gerrit/entities/SubscribeSection.java
index b95517c..574cae8 100644
--- a/java/com/google/gerrit/entities/SubscribeSection.java
+++ b/java/com/google/gerrit/entities/SubscribeSection.java
@@ -99,9 +99,10 @@
   public ImmutableSet<BranchNameKey> getDestinationBranches(
       BranchNameKey src, Collection<Ref> allRefsInRefsHeads) {
     Set<BranchNameKey> ret = new HashSet<>();
-    logger.atFine().log("Inspecting SubscribeSection %s", this);
-    for (RefSpec r : matchingRefSpecs()) {
-      logger.atFine().log("Inspecting [matching] ref %s", r);
+
+    ImmutableList<RefSpec> matching = matchingRefSpecs();
+    ImmutableList<RefSpec> multiMatch = multiMatchRefSpecs();
+    for (RefSpec r : matching) {
       if (!r.matchSource(src.branch())) {
         continue;
       }
@@ -118,8 +119,7 @@
       }
     }
 
-    for (RefSpec r : multiMatchRefSpecs()) {
-      logger.atFine().log("Inspecting [all] ref %s", r);
+    for (RefSpec r : multiMatch) {
       if (!r.matchSource(src.branch())) {
         continue;
       }
@@ -133,7 +133,9 @@
         }
       }
     }
-    logger.atFine().log("Returning possible branches: %s for project %s", ret, project());
+    logger.atFine().log(
+        "getDestinationBranches(%s): %s. matching refs: %s, multimatch refs: %s",
+        this, ret, matching, multiMatch);
     return ImmutableSet.copyOf(ret);
   }
 
diff --git a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
index 19c121249..eb2a381 100644
--- a/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeMessageProtoConverter.java
@@ -48,6 +48,8 @@
     if (writtenOn != null) {
       builder.setWrittenOn(writtenOn.getTime());
     }
+    // Build proto with template representation of the message. Templates are parsed when message is
+    // extracted from cache.
     String message = changeMessage.getMessage();
     if (message != null) {
       builder.setMessage(message);
@@ -79,16 +81,15 @@
     Timestamp writtenOn = proto.hasWrittenOn() ? new Timestamp(proto.getWrittenOn()) : null;
     PatchSet.Id patchSetId =
         proto.hasPatchset() ? patchSetIdConverter.fromProto(proto.getPatchset()) : null;
-    ChangeMessage changeMessage = new ChangeMessage(key, author, writtenOn, patchSetId);
-    if (proto.hasMessage()) {
-      changeMessage.setMessage(proto.getMessage());
-    }
-    if (proto.hasTag()) {
-      changeMessage.setTag(proto.getTag());
-    }
-    if (proto.hasRealAuthor()) {
-      changeMessage.setRealAuthor(accountIdConverter.fromProto(proto.getRealAuthor()));
-    }
+    // Only template representation of the message is stored in entity. Templates should be replaced
+    // before being served to the users.
+    String messageTemplate = proto.hasMessage() ? proto.getMessage() : null;
+    String tag = proto.hasTag() ? proto.getTag() : null;
+    Account.Id realAuthor =
+        proto.hasRealAuthor() ? accountIdConverter.fromProto(proto.getRealAuthor()) : null;
+    ChangeMessage changeMessage =
+        ChangeMessage.create(key, author, writtenOn, patchSetId, messageTemplate, realAuthor, tag);
+
     return changeMessage;
   }
 
diff --git a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
index 25e68f9..689b4aa 100644
--- a/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/ChangeProtoConverter.java
@@ -43,7 +43,6 @@
     Entities.Change.Builder builder =
         Entities.Change.newBuilder()
             .setChangeId(changeIdConverter.toProto(change.getId()))
-            .setRowVersion(change.getRowVersion())
             .setChangeKey(changeKeyConverter.toProto(change.getKey()))
             .setCreatedOn(change.getCreatedOn().getTime())
             .setLastUpdatedOn(change.getLastUpdatedOn().getTime())
diff --git a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
index 78a35ff..9e77025 100644
--- a/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
+++ b/java/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverter.java
@@ -39,7 +39,8 @@
             .setKey(patchSetApprovalKeyProtoConverter.toProto(patchSetApproval.key()))
             .setValue(patchSetApproval.value())
             .setGranted(patchSetApproval.granted().getTime())
-            .setPostSubmit(patchSetApproval.postSubmit());
+            .setPostSubmit(patchSetApproval.postSubmit())
+            .setCopied(patchSetApproval.copied());
 
     patchSetApproval.tag().ifPresent(builder::setTag);
     Account.Id realAccountId = patchSetApproval.realAccountId();
@@ -61,7 +62,8 @@
             .key(patchSetApprovalKeyProtoConverter.fromProto(proto.getKey()))
             .value(proto.getValue())
             .granted(new Timestamp(proto.getGranted()))
-            .postSubmit(proto.getPostSubmit());
+            .postSubmit(proto.getPostSubmit())
+            .copied(proto.getCopied());
     if (proto.hasTag()) {
       builder.tag(proto.getTag());
     }
diff --git a/java/com/google/gerrit/extensions/BUILD b/java/com/google/gerrit/extensions/BUILD
index 21949f7..f36018b 100644
--- a/java/com/google/gerrit/extensions/BUILD
+++ b/java/com/google/gerrit/extensions/BUILD
@@ -34,6 +34,7 @@
         "//java/com/google/gerrit/common:annotations",
         "//lib:guava",
         "//lib/auto:auto-value-annotations",
+        "//lib/errorprone:annotations",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
     ],
diff --git a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
index 6df9889..9c9c282 100644
--- a/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
+++ b/java/com/google/gerrit/extensions/api/accounts/AccountApi.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.extensions.api.accounts;
 
-import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -23,7 +22,6 @@
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -32,7 +30,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedSet;
 
 public interface AccountApi {
   AccountInfo get() throws RestApiException;
@@ -67,12 +64,6 @@
 
   void unstarChange(String changeId) throws RestApiException;
 
-  void setStars(String changeId, StarsInput input) throws RestApiException;
-
-  SortedSet<String> getStars(String changeId) throws RestApiException;
-
-  List<ChangeInfo> getStarredChanges() throws RestApiException;
-
   List<GroupInfo> getGroups() throws RestApiException;
 
   List<EmailInfo> getEmails() throws RestApiException;
@@ -221,21 +212,6 @@
     }
 
     @Override
-    public void setStars(String changeId, StarsInput input) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public SortedSet<String> getStars(String changeId) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
-    public List<ChangeInfo> getStarredChanges() throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public List<GroupInfo> getGroups() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/accounts/Accounts.java b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
index 15fca9a..285b385 100644
--- a/java/com/google/gerrit/extensions/api/accounts/Accounts.java
+++ b/java/com/google/gerrit/extensions/api/accounts/Accounts.java
@@ -38,7 +38,11 @@
    */
   AccountApi id(String id) throws RestApiException;
 
-  /** @see #id(String) */
+  /**
+   * Look up an account by ID. #id(String)
+   *
+   * <p>See #id(String)
+   */
   AccountApi id(int id) throws RestApiException;
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
index 7cbfebd..2224649 100644
--- a/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/ChangeApi.java
@@ -29,6 +29,8 @@
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -140,14 +142,6 @@
   boolean ignored() throws RestApiException;
 
   /**
-   * Mark this change as reviewed/unreviewed.
-   *
-   * @param reviewed flag to decide if this change should be marked as reviewed ({@code true}) or
-   *     unreviewed ({@code false})
-   */
-  void markAsReviewed(boolean reviewed) throws RestApiException;
-
-  /**
    * Create a new change that reverts this change.
    *
    * @see Changes#id(int)
@@ -205,13 +199,13 @@
 
   IncludedInInfo includedIn() throws RestApiException;
 
-  default AddReviewerResult addReviewer(String reviewer) throws RestApiException {
-    AddReviewerInput in = new AddReviewerInput();
+  default ReviewerResult addReviewer(String reviewer) throws RestApiException {
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(in);
   }
 
-  AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException;
+  ReviewerResult addReviewer(ReviewerInput in) throws RestApiException;
 
   SuggestedReviewersRequest suggestReviewers() throws RestApiException;
 
@@ -332,7 +326,6 @@
    * Get hashtags on a change.
    *
    * @return hashtags
-   * @throws RestApiException
    */
   Set<String> getHashtags() throws RestApiException;
 
@@ -367,7 +360,6 @@
    *
    * @return comments in a map keyed by path; comments have the {@code revision} field set to
    *     indicate their patch set.
-   * @throws RestApiException
    * @deprecated Callers should use {@link #commentsRequest()} instead
    */
   @Deprecated
@@ -380,7 +372,6 @@
    *
    * @return comments as a list; comments have the {@code revision} field set to indicate their
    *     patch set.
-   * @throws RestApiException
    * @deprecated Callers should use {@link #commentsRequest()} instead
    */
   @Deprecated
@@ -401,7 +392,6 @@
    *
    * @return robot comments in a map keyed by path; robot comments have the {@code revision} field
    *     set to indicate their patch set.
-   * @throws RestApiException
    */
   Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException;
 
@@ -410,7 +400,6 @@
    *
    * @return drafts in a map keyed by path; comments have the {@code revision} field set to indicate
    *     their patch set.
-   * @throws RestApiException
    */
   default Map<String, List<CommentInfo>> drafts() throws RestApiException {
     return draftsRequest().get();
@@ -421,7 +410,6 @@
    *
    * @return drafts as a list; comments have the {@code revision} field set to indicate their patch
    *     set.
-   * @throws RestApiException
    */
   default List<CommentInfo> draftsAsList() throws RestApiException {
     return draftsRequest().getAsList();
@@ -439,6 +427,10 @@
 
   ChangeInfo check(FixInput fix) throws RestApiException;
 
+  /** Returns the result of evaluating the {@link SubmitRequirementInput} input on the change. */
+  SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
+      throws RestApiException;
+
   void index() throws RestApiException;
 
   /** Check if this change is a pure revert of the change stored in revertOf. */
@@ -451,7 +443,6 @@
    * Get all messages of a change with detailed account info.
    *
    * @return a list of messages sorted by their creation time.
-   * @throws RestApiException
    */
   List<ChangeMessageInfo> messages() throws RestApiException;
 
@@ -474,7 +465,6 @@
      *
      * @return comments in a map keyed by path; comments have the {@code revision} field set to
      *     indicate their patch set.
-     * @throws RestApiException
      */
     public abstract Map<String, List<CommentInfo>> get() throws RestApiException;
 
@@ -643,7 +633,7 @@
     }
 
     @Override
-    public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+    public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
@@ -777,6 +767,12 @@
     }
 
     @Override
+    public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void index() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -814,11 +810,6 @@
     }
 
     @Override
-    public void markAsReviewed(boolean reviewed) throws RestApiException {
-      throw new NotImplementedException();
-    }
-
-    @Override
     public PureRevertInfo pureRevert() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/api/changes/FileApi.java b/java/com/google/gerrit/extensions/api/changes/FileApi.java
index 26f9452..e20ac56 100644
--- a/java/com/google/gerrit/extensions/api/changes/FileApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/FileApi.java
@@ -29,10 +29,18 @@
   /** Diff against the revision's parent version of the file. */
   DiffInfo diff() throws RestApiException;
 
-  /** @param base revision id of the revision to be used as the diff base */
+  /**
+   * Diff against the specified base
+   *
+   * @param base revision id of the revision to be used as the diff base
+   */
   DiffInfo diff(String base) throws RestApiException;
 
-  /** @param parent 1-based parent number to diff against */
+  /**
+   * Diff against the specified parent
+   *
+   * @param parent 1-based parent number to diff against
+   */
   DiffInfo diff(int parent) throws RestApiException;
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index fd445b6..11999ab 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -46,6 +46,9 @@
    */
   public DraftHandling drafts;
 
+  /** A list of draft IDs that should be published. */
+  public List<String> draftIdsToPublish;
+
   /** Who to send email notifications to after review is stored. */
   public NotifyHandling notify;
 
@@ -62,8 +65,8 @@
    */
   public String onBehalfOf;
 
-  /** Reviewers that should be added to this change. */
-  public List<AddReviewerInput> reviewers;
+  /** Reviewers that should be added to this change or removed from it. */
+  public List<ReviewerInput> reviewers;
 
   /**
    * If true mark the change as work in progress. It is an error for both {@link #workInProgress}
@@ -155,7 +158,7 @@
   }
 
   public ReviewInput reviewer(String reviewer, ReviewerState state, boolean confirmed) {
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = reviewer;
     input.state = state;
     input.confirmed = confirmed;
diff --git a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
index ff88bbe..95bea5b 100644
--- a/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewResult.java
@@ -29,7 +29,7 @@
    * Map of account or group identifier to outcome of adding as a reviewer. Null if no reviewer
    * additions were requested.
    */
-  @Nullable public Map<String, AddReviewerResult> reviewers;
+  @Nullable public Map<String, ReviewerResult> reviewers;
 
   /**
    * Boolean indicating whether the change was moved out of WIP by this review. Either true or null.
diff --git a/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java b/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
similarity index 97%
rename from java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
index bc8b28a..a7b511b 100644
--- a/java/com/google/gerrit/extensions/api/changes/AddReviewerInput.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewerInput.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import java.util.Map;
 
-public class AddReviewerInput {
+public class ReviewerInput {
   @DefaultInput public String reviewer;
   public Boolean confirmed;
   public ReviewerState state;
diff --git a/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java b/java/com/google/gerrit/extensions/api/changes/ReviewerResult.java
similarity index 74%
rename from java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
rename to java/com/google/gerrit/extensions/api/changes/ReviewerResult.java
index a23281a..1713674 100644
--- a/java/com/google/gerrit/extensions/api/changes/AddReviewerResult.java
+++ b/java/com/google/gerrit/extensions/api/changes/ReviewerResult.java
@@ -18,17 +18,17 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import java.util.List;
 
-/** Result object representing the outcome of a request to add a reviewer. */
-public class AddReviewerResult {
-  /** The identifier of an account or group that was to be added as a reviewer. */
+/** Result object representing the outcome of a request to add/remove a reviewer. */
+public class ReviewerResult {
+  /** The identifier of an account or group that was to be added/removed as a reviewer. */
   public String input;
 
-  /** If non-null, a string describing why the reviewer could not be added. */
+  /** If non-null, a string describing why the reviewer could not be added/removed. */
   @Nullable public String error;
 
   /**
    * Non-null and true if the reviewer cannot be added without explicit confirmation. This may be
-   * the case for groups of a certain size.
+   * the case for groups of a certain size. For removals, it's always false.
    */
   @Nullable public Boolean confirm;
 
@@ -39,17 +39,20 @@
   @Nullable public List<ReviewerInfo> reviewers;
 
   /**
-   * List of accounts CCed on the change. The size of this list may be greater than one (e.g. when a
-   * group is CCed). Null if no accounts were CCed or if reviewers is non-null.
+   * List of new accounts CCed on the change. The size of this list may be greater than one (e.g.
+   * when a group is CCed). Null if no accounts were CCed.
    */
   @Nullable public List<AccountInfo> ccs;
 
+  /** An account removed from the change. Null if no accounts were removed. */
+  @Nullable public AccountInfo removed;
+
   /**
    * Constructs a partially initialized result for the given reviewer.
    *
    * @param input String identifier of an account or group, from user request
    */
-  public AddReviewerResult(String input) {
+  public ReviewerResult(String input) {
     this.input = input;
   }
 
@@ -59,7 +62,7 @@
    * @param reviewer String identifier of an account or group
    * @param error Error message
    */
-  public AddReviewerResult(String reviewer, String error) {
+  public ReviewerResult(String reviewer, String error) {
     this(reviewer);
     this.error = error;
   }
@@ -69,7 +72,7 @@
    *
    * @param confirm Whether confirmation is needed.
    */
-  public AddReviewerResult(String reviewer, boolean confirm) {
+  public ReviewerResult(String reviewer, boolean confirm) {
     this(reviewer);
     this.confirm = confirm;
   }
diff --git a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index 73e6a4e..1307516 100644
--- a/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -44,12 +44,12 @@
 
   ReviewResult review(ReviewInput in) throws RestApiException;
 
-  default void submit() throws RestApiException {
+  default ChangeInfo submit() throws RestApiException {
     SubmitInput in = new SubmitInput();
-    submit(in);
+    return submit(in);
   }
 
-  void submit(SubmitInput in) throws RestApiException;
+  ChangeInfo submit(SubmitInput in) throws RestApiException;
 
   default BinaryResult submitPreview() throws RestApiException {
     return submitPreview("zip");
@@ -160,7 +160,6 @@
    *
    * @param format the format of the archive
    * @return the archive as {@link BinaryResult}
-   * @throws RestApiException
    */
   BinaryResult getArchive(ArchiveFormat format) throws RestApiException;
 
@@ -200,7 +199,7 @@
     }
 
     @Override
-    public void submit(SubmitInput in) throws RestApiException {
+    public ChangeInfo submit(SubmitInput in) throws RestApiException {
       throw new NotImplementedException();
     }
 
diff --git a/java/com/google/gerrit/extensions/api/changes/StarsInput.java b/java/com/google/gerrit/extensions/api/changes/StarsInput.java
deleted file mode 100644
index 1207d27..0000000
--- a/java/com/google/gerrit/extensions/api/changes/StarsInput.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.extensions.api.changes;
-
-import java.util.Set;
-
-public class StarsInput {
-  public Set<String> add;
-  public Set<String> remove;
-
-  public StarsInput() {}
-
-  public StarsInput(Set<String> add) {
-    this.add = add;
-  }
-
-  public StarsInput(Set<String> add, Set<String> remove) {
-    this.add = add;
-    this.remove = remove;
-  }
-}
diff --git a/java/com/google/gerrit/extensions/api/config/Config.java b/java/com/google/gerrit/extensions/api/config/Config.java
index eb7288d..041e1dd 100644
--- a/java/com/google/gerrit/extensions/api/config/Config.java
+++ b/java/com/google/gerrit/extensions/api/config/Config.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 
 public interface Config {
-  /** @return An API for getting server related configurations. */
+  /** Returns an API for getting server related configurations. */
   Server server();
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
index e582f1b..9fb57ad 100644
--- a/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
+++ b/java/com/google/gerrit/extensions/api/config/ConsistencyCheckInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.api.config;
 
+import com.google.errorprone.annotations.FormatMethod;
 import java.util.List;
 import java.util.Objects;
 
@@ -80,10 +81,12 @@
       return status.name() + ": " + message;
     }
 
+    @FormatMethod
     public static ConsistencyProblemInfo warning(String fmt, Object... args) {
       return new ConsistencyProblemInfo(Status.WARNING, String.format(fmt, args));
     }
 
+    @FormatMethod
     public static ConsistencyProblemInfo error(String fmt, Object... args) {
       return new ConsistencyProblemInfo(Status.ERROR, String.format(fmt, args));
     }
diff --git a/java/com/google/gerrit/extensions/api/config/Server.java b/java/com/google/gerrit/extensions/api/config/Server.java
index 70d1bff..8b69ded 100644
--- a/java/com/google/gerrit/extensions/api/config/Server.java
+++ b/java/com/google/gerrit/extensions/api/config/Server.java
@@ -24,7 +24,7 @@
 import java.util.List;
 
 public interface Server {
-  /** @return Version of server. */
+  /** Returns version of server. */
   String getVersion() throws RestApiException;
 
   ServerInfo getInfo() throws RestApiException;
diff --git a/java/com/google/gerrit/extensions/api/groups/GroupApi.java b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
index 067f120..e1b3a9f 100644
--- a/java/com/google/gerrit/extensions/api/groups/GroupApi.java
+++ b/java/com/google/gerrit/extensions/api/groups/GroupApi.java
@@ -24,53 +24,49 @@
 import java.util.List;
 
 public interface GroupApi {
-  /** @return group info with no {@code ListGroupsOption}s set. */
+  /** Returns group info with no {@code ListGroupsOption}s set. */
   GroupInfo get() throws RestApiException;
 
-  /** @return group info with all {@code ListGroupsOption}s set. */
+  /** Returns group info with all {@code ListGroupsOption}s set. */
   GroupInfo detail() throws RestApiException;
 
-  /** @return group name. */
+  /** Returns group name. */
   String name() throws RestApiException;
 
   /**
    * Set group name.
    *
    * @param name new name.
-   * @throws RestApiException
    */
   void name(String name) throws RestApiException;
 
-  /** @return owning group info. */
+  /** Returns owning group info. */
   GroupInfo owner() throws RestApiException;
 
   /**
    * Set group owner.
    *
    * @param owner identifier of new group owner.
-   * @throws RestApiException
    */
   void owner(String owner) throws RestApiException;
 
-  /** @return group description. */
+  /** Returns group description. */
   String description() throws RestApiException;
 
   /**
    * Set group decsription.
    *
    * @param description new description.
-   * @throws RestApiException
    */
   void description(String description) throws RestApiException;
 
-  /** @return group options. */
+  /** Returns group options. */
   GroupOptionsInfo options() throws RestApiException;
 
   /**
    * Set group options.
    *
    * @param options new options.
-   * @throws RestApiException
    */
   void options(GroupOptionsInfo options) throws RestApiException;
 
@@ -78,7 +74,6 @@
    * List group members, non-recursively.
    *
    * @return group members.
-   * @throws RestApiException
    */
   List<AccountInfo> members() throws RestApiException;
 
@@ -87,7 +82,6 @@
    *
    * @param recursive whether to recursively included groups.
    * @return group members.
-   * @throws RestApiException
    */
   List<AccountInfo> members(boolean recursive) throws RestApiException;
 
@@ -96,7 +90,6 @@
    *
    * @param members list of member identifiers, in any format accepted by {@link
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
-   * @throws RestApiException
    */
   void addMembers(List<String> members) throws RestApiException;
 
@@ -105,7 +98,6 @@
    *
    * @param members list of member identifiers, in any format accepted by {@link
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
-   * @throws RestApiException
    */
   default void addMembers(String... members) throws RestApiException {
     addMembers(Arrays.asList(members));
@@ -116,7 +108,6 @@
    *
    * @param members list of member identifiers, in any format accepted by {@link
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
-   * @throws RestApiException
    */
   void removeMembers(List<String> members) throws RestApiException;
 
@@ -125,7 +116,6 @@
    *
    * @param members list of member identifiers, in any format accepted by {@link
    *     com.google.gerrit.extensions.api.accounts.Accounts#id(String)}
-   * @throws RestApiException
    */
   default void removeMembers(String... members) throws RestApiException {
     removeMembers(Arrays.asList(members));
@@ -135,7 +125,6 @@
    * Lists the subgroups of this group.
    *
    * @return the found subgroups
-   * @throws RestApiException
    */
   List<GroupInfo> includedGroups() throws RestApiException;
 
@@ -143,7 +132,6 @@
    * Adds subgroups to this group.
    *
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
-   * @throws RestApiException
    */
   void addGroups(List<String> groups) throws RestApiException;
 
@@ -151,7 +139,6 @@
    * Adds subgroups to this group.
    *
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
-   * @throws RestApiException
    */
   default void addGroups(String... groups) throws RestApiException {
     addGroups(Arrays.asList(groups));
@@ -161,7 +148,6 @@
    * Removes subgroups from this group.
    *
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
-   * @throws RestApiException
    */
   void removeGroups(List<String> groups) throws RestApiException;
 
@@ -169,7 +155,6 @@
    * Removes subgroups from this group.
    *
    * @param groups list of group identifiers, in any format accepted by {@link Groups#id(String)}
-   * @throws RestApiException
    */
   default void removeGroups(String... groups) throws RestApiException {
     removeGroups(Arrays.asList(groups));
@@ -179,7 +164,6 @@
    * Returns the audit log of the group.
    *
    * @return list of audit events of the group.
-   * @throws RestApiException
    */
   List<? extends GroupAuditEventInfo> auditLog() throws RestApiException;
 
@@ -187,8 +171,6 @@
    * Reindexes the group.
    *
    * <p>Only supported for internal groups.
-   *
-   * @throws RestApiException
    */
   void index() throws RestApiException;
 
diff --git a/java/com/google/gerrit/extensions/api/groups/Groups.java b/java/com/google/gerrit/extensions/api/groups/Groups.java
index 81b5f47..1a46930 100644
--- a/java/com/google/gerrit/extensions/api/groups/Groups.java
+++ b/java/com/google/gerrit/extensions/api/groups/Groups.java
@@ -47,7 +47,7 @@
   /** Create a new group. */
   GroupApi create(GroupInput input) throws RestApiException;
 
-  /** @return new request for listing groups. */
+  /** Returns new request for listing groups. */
   ListRequest list();
 
   /**
diff --git a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
index 9873995..59475a4 100644
--- a/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
+++ b/java/com/google/gerrit/extensions/api/projects/ProjectApi.java
@@ -24,7 +24,10 @@
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.Collection;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public interface ProjectApi {
   ProjectApi create() throws RestApiException;
@@ -51,6 +54,9 @@
 
   ConfigInfo config(ConfigInput in) throws RestApiException;
 
+  Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+      throws RestApiException;
+
   ListRefsRequest<BranchInfo> branches();
 
   ListRefsRequest<TagInfo> tags();
@@ -289,6 +295,12 @@
     }
 
     @Override
+    public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public void description(DescriptionInput in) throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java b/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
index 417f55a..c3d760b 100644
--- a/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
+++ b/java/com/google/gerrit/extensions/auth/oauth/OAuthServiceProvider.java
@@ -40,9 +40,7 @@
    * After establishing of secure communication channel, this method supossed to access the
    * protected resoure and retrieve the username.
    *
-   * @param token
    * @return OAuth user information
-   * @throws IOException
    */
   OAuthUserInfo getUserInfo(OAuthToken token) throws IOException;
 
diff --git a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
index 21b319e..b26f435 100644
--- a/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
+++ b/java/com/google/gerrit/extensions/client/GeneralPreferencesInfo.java
@@ -148,6 +148,7 @@
   public DefaultBase defaultBaseForMerges;
   public Boolean publishCommentsOnPush;
   public Boolean disableKeyboardShortcuts;
+  public Boolean disableTokenHighlighting;
   public Boolean workInProgressByDefault;
   public List<MenuItem> my;
   public List<String> changeTable;
@@ -207,6 +208,7 @@
     p.defaultBaseForMerges = DefaultBase.FIRST_PARENT;
     p.publishCommentsOnPush = false;
     p.disableKeyboardShortcuts = false;
+    p.disableTokenHighlighting = false;
     p.workInProgressByDefault = false;
     return p;
   }
diff --git a/java/com/google/gerrit/extensions/client/ListChangesOption.java b/java/com/google/gerrit/extensions/client/ListChangesOption.java
index 6071cc7..f1f7831 100644
--- a/java/com/google/gerrit/extensions/client/ListChangesOption.java
+++ b/java/com/google/gerrit/extensions/client/ListChangesOption.java
@@ -85,7 +85,10 @@
    * Skip diffstat computation that compute the insertions field (number of lines inserted) and
    * deletions field (number of lines deleted)
    */
-  SKIP_DIFFSTAT(23);
+  SKIP_DIFFSTAT(23),
+
+  /** Include the evaluated submit requirements for the caller. */
+  SUBMIT_REQUIREMENTS(24);
 
   private final int value;
 
diff --git a/java/com/google/gerrit/extensions/client/ListOption.java b/java/com/google/gerrit/extensions/client/ListOption.java
index dba2eee..098966a 100644
--- a/java/com/google/gerrit/extensions/client/ListOption.java
+++ b/java/com/google/gerrit/extensions/client/ListOption.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.client;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.lang.reflect.InvocationTargetException;
 import java.util.EnumSet;
 import java.util.Set;
@@ -22,6 +23,22 @@
 public interface ListOption {
   int getValue();
 
+  static <T extends Enum<T> & ListOption> EnumSet<T> fromHexString(Class<T> clazz, String hex)
+      throws BadRequestException {
+    int parsed;
+    try {
+      parsed = Integer.parseInt(hex, 16);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException("not a hex-encoded 32-bit integer: " + hex, e);
+    }
+
+    try {
+      return fromBits(clazz, parsed);
+    } catch (IllegalArgumentException e) {
+      throw new BadRequestException(e.getMessage());
+    }
+  }
+
   static <T extends Enum<T> & ListOption> EnumSet<T> fromBits(Class<T> clazz, int v) {
     EnumSet<T> r = EnumSet.noneOf(clazz);
     T[] values;
@@ -43,7 +60,7 @@
     }
     if (v != 0) {
       throw new IllegalArgumentException(
-          "unknown " + clazz.getName() + ": " + Integer.toHexString(v));
+          "unknown " + clazz.getSimpleName() + ": " + Integer.toHexString(v));
     }
     return r;
   }
diff --git a/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java b/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
index 3c20ff7..8f5af76 100644
--- a/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
+++ b/java/com/google/gerrit/extensions/client/ProjectWatchInfo.java
@@ -54,6 +54,7 @@
   }
 
   @Override
+  @SuppressWarnings("OrphanedFormatString")
   public String toString() {
     StringBuilder b = new StringBuilder();
     b.append(project);
diff --git a/java/com/google/gerrit/extensions/common/AccountInfo.java b/java/com/google/gerrit/extensions/common/AccountInfo.java
index 60ba18d..4701e86 100644
--- a/java/com/google/gerrit/extensions/common/AccountInfo.java
+++ b/java/com/google/gerrit/extensions/common/AccountInfo.java
@@ -27,10 +27,13 @@
  * are defined in {@link AccountDetailInfo}.
  */
 public class AccountInfo {
-  /** Tags are additional properties of an account. */
-  public enum Tag {
+  /**
+   * Tags are additional properties of an account. These are just tags known to Gerrit core. Plugins
+   * may define their own.
+   */
+  public static final class Tags {
     /** Tag indicating that this account is a service user. */
-    SERVICE_USER
+    public static final String SERVICE_USER = "SERVICE_USER";
   }
 
   /** The numeric ID of the account. */
@@ -74,7 +77,7 @@
   public Boolean inactive;
 
   /** Tags, such as whether this account is a service user. */
-  public List<Tag> tags;
+  public List<String> tags;
 
   public AccountInfo(Integer id) {
     this._accountId = id;
diff --git a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
index ba865fb..d34ba6d 100644
--- a/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
+++ b/java/com/google/gerrit/extensions/common/AttentionSetInfo.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.extensions.common;
 
+import com.google.gerrit.common.Nullable;
 import java.sql.Timestamp;
 import java.util.Objects;
 
@@ -32,27 +33,40 @@
   /** The human readable reason why the user was added. */
   public String reason;
 
-  public AttentionSetInfo(AccountInfo account, Timestamp lastUpdate, String reason) {
+  /**
+   * The user that might be mentioned in {@link #reason} as the one who caused the update. This is
+   * needed since {@link #reason} contains the account in pseudonymized form and is expanded in the
+   * frontend. {@code null} if there is no such account.
+   */
+  @Nullable public AccountInfo reasonAccount;
+
+  public AttentionSetInfo(
+      AccountInfo account,
+      Timestamp lastUpdate,
+      String reason,
+      @Nullable AccountInfo reasonAccount) {
     this.account = account;
     this.lastUpdate = lastUpdate;
     this.reason = reason;
+    this.reasonAccount = reasonAccount;
   }
 
+  protected AttentionSetInfo() {}
+
   @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);
+          && Objects.equals(reason, attentionSetInfo.reason)
+          && Objects.equals(reasonAccount, attentionSetInfo.reasonAccount);
     }
     return false;
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(account, lastUpdate, reason);
+    return Objects.hash(account, lastUpdate, reason, reasonAccount);
   }
-
-  protected AttentionSetInfo() {}
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
index b387017..fc09b49 100644
--- a/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeConfigInfo.java
@@ -19,8 +19,6 @@
   public Boolean allowBlame;
   public Boolean showAssigneeInChangesTable;
   public Boolean disablePrivateChanges;
-  public String replyLabel;
-  public String replyTooltip;
   public int updateDelay;
   public Boolean submitWholeTopic;
   public String mergeabilityComputationBehavior;
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfo.java b/java/com/google/gerrit/extensions/common/ChangeInfo.java
index 9e915f5..2bb3dd7 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfo.java
@@ -112,6 +112,8 @@
   public List<PluginDefinedInfo> plugins;
   public Collection<TrackingIdInfo> trackingIds;
   public Collection<LegacySubmitRequirementInfo> requirements;
+  public Collection<SubmitRecordInfo> submitRecords;
+  public Collection<SubmitRequirementResultInfo> submitRequirements;
 
   public ChangeInfo() {}
 
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
index 0fff0ba..ad112d3 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDiffer.java
@@ -63,20 +63,23 @@
    */
   public static ChangeInfoDifference getDifference(
       ChangeInfo oldChangeInfo, ChangeInfo newChangeInfo) {
-    return ChangeInfoDifference.create(
-        /* added= */ getAdded(oldChangeInfo, newChangeInfo),
-        /* removed= */ getAdded(newChangeInfo, oldChangeInfo));
+    return ChangeInfoDifference.builder()
+        .setOldChangeInfo(oldChangeInfo)
+        .setNewChangeInfo(newChangeInfo)
+        .setAdded(getAdded(oldChangeInfo, newChangeInfo))
+        .setRemoved(getAdded(newChangeInfo, oldChangeInfo))
+        .build();
   }
 
   @SuppressWarnings("unchecked") // reflection is used to construct instances of T
   private static <T> T getAdded(T oldValue, T newValue) {
     if (newValue instanceof Collection) {
-      List result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
+      List<?> result = getAddedForCollection((Collection<?>) oldValue, (Collection<?>) newValue);
       return (T) result;
     }
 
     if (newValue instanceof Map) {
-      Map result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
+      Map<?, ?> result = getAddedForMap((Map<?, ?>) oldValue, (Map<?, ?>) newValue);
       return (T) result;
     }
 
@@ -143,7 +146,7 @@
     }
   }
 
-  /** @return {@code null} if nothing has been added to {@code oldCollection} */
+  /** Returns {@code null} if nothing has been added to {@code oldCollection} */
   private static ImmutableList<?> getAddedForCollection(
       Collection<?> oldCollection, Collection<?> newCollection) {
     ImmutableList<?> notInOldCollection = getAdditions(oldCollection, newCollection);
@@ -165,7 +168,7 @@
     return duplicatesMap.values().stream().flatMap(Collection::stream).collect(toImmutableList());
   }
 
-  /** @return {@code null} if nothing has been added to {@code oldMap} */
+  /** Returns {@code null} if nothing has been added to {@code oldMap} */
   private static ImmutableMap<Object, Object> getAddedForMap(Map<?, ?> oldMap, Map<?, ?> newMap) {
     ImmutableMap.Builder<Object, Object> additionsBuilder = ImmutableMap.builder();
     for (Map.Entry<?, ?> entry : newMap.entrySet()) {
diff --git a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
index 269c673..997c3ee 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInfoDifference.java
@@ -20,11 +20,29 @@
 @AutoValue
 public abstract class ChangeInfoDifference {
 
+  public abstract ChangeInfo oldChangeInfo();
+
+  public abstract ChangeInfo newChangeInfo();
+
   public abstract ChangeInfo added();
 
   public abstract ChangeInfo removed();
 
-  public static ChangeInfoDifference create(ChangeInfo added, ChangeInfo removed) {
-    return new AutoValue_ChangeInfoDifference(added, removed);
+  public static Builder builder() {
+    return new AutoValue_ChangeInfoDifference.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder setOldChangeInfo(ChangeInfo oldChangeInfo);
+
+    public abstract Builder setNewChangeInfo(ChangeInfo newChangeInfo);
+
+    public abstract Builder setAdded(ChangeInfo added);
+
+    public abstract Builder setRemoved(ChangeInfo removed);
+
+    public abstract ChangeInfoDifference build();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/ChangeInput.java b/java/com/google/gerrit/extensions/common/ChangeInput.java
index 1949ff4..ea12ef1 100644
--- a/java/com/google/gerrit/extensions/common/ChangeInput.java
+++ b/java/com/google/gerrit/extensions/common/ChangeInput.java
@@ -33,6 +33,7 @@
   public String baseChange;
   public String baseCommit;
   public Boolean newBranch;
+  public Map<String, String> validationOptions;
   public MergeInput merge;
 
   public AccountInput author;
diff --git a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
index 10456ff..c1cb1627 100644
--- a/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
+++ b/java/com/google/gerrit/extensions/common/ChangeMessageInfo.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.extensions.common;
 
 import java.sql.Timestamp;
+import java.util.Collection;
 import java.util.Objects;
 
+/** Represent {@link com.google.gerrit.entities.ChangeMessage} in the REST API. */
 public class ChangeMessageInfo {
   public String id;
   public String tag;
@@ -24,6 +26,7 @@
   public AccountInfo realAuthor;
   public Timestamp date;
   public String message;
+  public Collection<AccountInfo> accountsInMessage;
   public Integer _revisionNumber;
 
   public ChangeMessageInfo() {}
@@ -42,6 +45,7 @@
           && Objects.equals(realAuthor, cmi.realAuthor)
           && Objects.equals(date, cmi.date)
           && Objects.equals(message, cmi.message)
+          && Objects.equals(accountsInMessage, cmi.accountsInMessage)
           && Objects.equals(_revisionNumber, cmi._revisionNumber);
     }
     return false;
@@ -49,7 +53,8 @@
 
   @Override
   public int hashCode() {
-    return Objects.hash(id, tag, author, realAuthor, date, message, _revisionNumber);
+    return Objects.hash(
+        id, tag, author, realAuthor, date, message, accountsInMessage, _revisionNumber);
   }
 
   @Override
@@ -69,6 +74,8 @@
         + _revisionNumber
         + ", message=["
         + message
-        + "]}";
+        + "], accountsForTemplate="
+        + accountsInMessage
+        + "}";
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/CommitInfo.java b/java/com/google/gerrit/extensions/common/CommitInfo.java
index 1fd8755..202b829 100644
--- a/java/com/google/gerrit/extensions/common/CommitInfo.java
+++ b/java/com/google/gerrit/extensions/common/CommitInfo.java
@@ -29,6 +29,7 @@
   public String subject;
   public String message;
   public List<WebLinkInfo> webLinks;
+  public List<WebLinkInfo> resolveConflictsWebLinks;
 
   @Override
   public boolean equals(Object o) {
@@ -42,12 +43,14 @@
         && Objects.equals(committer, c.committer)
         && Objects.equals(subject, c.subject)
         && Objects.equals(message, c.message)
-        && Objects.equals(webLinks, c.webLinks);
+        && Objects.equals(webLinks, c.webLinks)
+        && Objects.equals(resolveConflictsWebLinks, c.resolveConflictsWebLinks);
   }
 
   @Override
   public int hashCode() {
-    return Objects.hash(commit, parents, author, committer, subject, message, webLinks);
+    return Objects.hash(
+        commit, parents, author, committer, subject, message, webLinks, resolveConflictsWebLinks);
   }
 
   @Override
@@ -64,6 +67,9 @@
     if (webLinks != null) {
       helper.add("webLinks", webLinks);
     }
+    if (resolveConflictsWebLinks != null) {
+      helper.add("resolveConflictsWebLinks", resolveConflictsWebLinks);
+    }
     return helper.toString();
   }
 }
diff --git a/java/com/google/gerrit/extensions/common/DiffInfo.java b/java/com/google/gerrit/extensions/common/DiffInfo.java
index 2511e96..5a9b82b 100644
--- a/java/com/google/gerrit/extensions/common/DiffInfo.java
+++ b/java/com/google/gerrit/extensions/common/DiffInfo.java
@@ -32,6 +32,8 @@
   public List<ContentEntry> content;
   // Links to the file diff in external sites
   public List<DiffWebLinkInfo> webLinks;
+  // Links to edit the file in external sites
+  public List<WebLinkInfo> editWebLinks;
   // Binary file
   public Boolean binary;
 
diff --git a/java/com/google/gerrit/extensions/common/FileInfo.java b/java/com/google/gerrit/extensions/common/FileInfo.java
index 510c2ad..c732663 100644
--- a/java/com/google/gerrit/extensions/common/FileInfo.java
+++ b/java/com/google/gerrit/extensions/common/FileInfo.java
@@ -44,4 +44,24 @@
   public int hashCode() {
     return Objects.hash(status, binary, oldPath, linesInserted, linesDeleted, sizeDelta, size);
   }
+
+  @Override
+  public String toString() {
+    return "FileInfo{"
+        + "status="
+        + status
+        + ", binary="
+        + binary
+        + ", oldPath="
+        + oldPath
+        + ", linesInserted="
+        + linesInserted
+        + ", linesDeleted="
+        + linesDeleted
+        + ", sizeDelta="
+        + sizeDelta
+        + ", size="
+        + size
+        + "}";
+  }
 }
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
index 9a6d086..6f733d6 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInfo.java
@@ -26,6 +26,7 @@
   public List<String> branches;
   public Boolean canOverride;
   public Boolean copyAnyScore;
+  public String copyCondition;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
   public Boolean copyAllScoresIfListOfFilesDidNotChange;
diff --git a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
index 87cae86..38b76c1 100644
--- a/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
+++ b/java/com/google/gerrit/extensions/common/LabelDefinitionInput.java
@@ -25,6 +25,8 @@
   public List<String> branches;
   public Boolean canOverride;
   public Boolean copyAnyScore;
+  public String copyCondition;
+  public Boolean unsetCopyCondition;
   public Boolean copyMinScore;
   public Boolean copyMaxScore;
   public Boolean copyAllScoresIfListOfFilesDidNotChange;
diff --git a/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.java
new file mode 100644
index 0000000..09c9841
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRecordInfo.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.extensions.common;
+
+import java.util.List;
+
+/** API response containing a {@link com.google.gerrit.entities.SubmitRecord} entity. */
+public class SubmitRecordInfo {
+  public enum Status {
+    OK,
+    NOT_READY,
+    CLOSED,
+    FORCED,
+    RULE_ERROR
+  }
+
+  public static class Label {
+    public enum Status {
+      OK,
+      REJECT,
+      NEED,
+      MAY,
+      IMPOSSIBLE
+    }
+
+    public String label;
+    public Status status;
+    public AccountInfo appliedBy;
+  }
+
+  public String ruleName;
+  public Status status;
+  public List<Label> labels;
+  public List<LegacySubmitRequirementInfo> requirements;
+  public String errorMessage;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
new file mode 100644
index 0000000..4d1fce2
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementExpressionInfo.java
@@ -0,0 +1,39 @@
+// 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.extensions.common;
+
+import java.util.List;
+
+/** Result of evaluating a single submit requirement expression. */
+public class SubmitRequirementExpressionInfo {
+
+  /** Submit requirement expression as a String. */
+  public String expression;
+
+  /** A boolean indicating if the expression is fulfilled on a change. */
+  public boolean fulfilled;
+
+  /**
+   * A list of all atoms that are passing, for example query "branch:refs/heads/foo and project:bar"
+   * has two atoms: ["branch:refs/heads/foo", "project:bar"].
+   */
+  public List<String> passingAtoms;
+
+  /**
+   * A list of all atoms that are failing, for example query "branch:refs/heads/foo and project:bar"
+   * has two atoms: ["branch:refs/heads/foo", "project:bar"].
+   */
+  public List<String> failingAtoms;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementInput.java b/java/com/google/gerrit/extensions/common/SubmitRequirementInput.java
new file mode 100644
index 0000000..96045d4
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementInput.java
@@ -0,0 +1,45 @@
+// 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.extensions.common;
+
+/** API Input describing a submit requirement entity. */
+public class SubmitRequirementInput {
+  /** Submit requirement name. */
+  public String name;
+
+  /** Submit requirement description. */
+  public String description;
+
+  /**
+   * Query expression that can be evaluated on any change. If evaluated to true on a change, the
+   * submit requirement is then applicable on this change.
+   */
+  public String applicabilityExpression;
+
+  /**
+   * Query expression that can be evaluated on any change. If evaluated to true on a change, the
+   * submit requirement is fulfilled and not blocking change submission.
+   */
+  public String submittabilityExpression;
+
+  /**
+   * Query expression that can be evaluated on any change. If evaluated to true on a change, the
+   * submit requirement is overridden and not blocking change submission.
+   */
+  public String overrideExpression;
+
+  /** Whether this submit requirement can be overridden in child projects. */
+  public Boolean allowOverrideInChildProjects;
+}
diff --git a/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
new file mode 100644
index 0000000..3d50f13
--- /dev/null
+++ b/java/com/google/gerrit/extensions/common/SubmitRequirementResultInfo.java
@@ -0,0 +1,67 @@
+// 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.extensions.common;
+
+/** Result of evaluating a submit requirement on a change. */
+public class SubmitRequirementResultInfo {
+  public enum Status {
+    /** Submit requirement is fulfilled. */
+    SATISFIED,
+
+    /**
+     * Submit requirement is not satisfied. Happens when {@code submittabilityExpressionResult} is
+     * not fulfilled.
+     */
+    UNSATISFIED,
+
+    /**
+     * Submit requirement is overridden. Happens when {@code overrideExpressionResult} is fulfilled.
+     */
+    OVERRIDDEN,
+
+    /**
+     * Submit requirement is not applicable for the change. Happens when {@code
+     * applicabilityExpressionResult} is not fulfilled.
+     */
+    NOT_APPLICABLE,
+
+    /**
+     * Any of the applicability, submittability or override expressions contain invalid syntax and
+     * are not parsable.
+     */
+    ERROR
+  }
+
+  /** Submit requirement name. */
+  public String name;
+
+  /** Submit requirement description. */
+  public String description;
+
+  /** Overall result (status) of evaluating this submit requirement. */
+  public Status status;
+
+  /** Whether this result was created from a legacy submit record. */
+  public boolean isLegacy;
+
+  /** Result of evaluating the applicability expression. */
+  public SubmitRequirementExpressionInfo applicabilityExpressionResult;
+
+  /** Result of evaluating the submittability expression. */
+  public SubmitRequirementExpressionInfo submittabilityExpressionResult;
+
+  /** Result of evaluating the override expression. */
+  public SubmitRequirementExpressionInfo overrideExpressionResult;
+}
diff --git a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
index deb03b0..2af9a767 100644
--- a/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
+++ b/java/com/google/gerrit/extensions/common/TestSubmitRuleInfo.java
@@ -19,7 +19,7 @@
 import java.util.Objects;
 
 public class TestSubmitRuleInfo {
-  /** @see com.google.gerrit.entities.SubmitRecord.Status */
+  /** See {@link com.google.gerrit.entities.SubmitRecord.Status} */
   public String status;
 
   public String errorMessage;
diff --git a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
index d344e18..71fc564 100644
--- a/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/CommitInfoSubject.java
@@ -20,6 +20,7 @@
 
 import com.google.common.truth.Correspondence;
 import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.gerrit.extensions.common.CommitInfo;
@@ -68,6 +69,16 @@
     return check("message").that(commitInfo.message);
   }
 
+  public IterableSubject webLinks() {
+    isNotNull();
+    return check("webLinks").that(commitInfo.webLinks);
+  }
+
+  public IterableSubject resolveConflictsWebLinks() {
+    isNotNull();
+    return check("resolveConflictsWebLinks").that(commitInfo.resolveConflictsWebLinks);
+  }
+
   public static Correspondence<CommitInfo, String> hasCommit() {
     return NullAwareCorrespondence.transforming(commitInfo -> commitInfo.commit, "hasCommit");
   }
diff --git a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
index e258134..b800d17 100644
--- a/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
+++ b/java/com/google/gerrit/extensions/common/testing/DiffInfoSubject.java
@@ -74,6 +74,11 @@
     return check("webLinks").that(diffInfo.webLinks);
   }
 
+  public IterableSubject editWebLinks() {
+    isNotNull();
+    return check("editWebLinks").that(diffInfo.editWebLinks);
+  }
+
   public BooleanSubject binary() {
     isNotNull();
     return check("binary").that(diffInfo.binary);
diff --git a/java/com/google/gerrit/extensions/conditions/BooleanCondition.java b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
index 162dd99..9c354fb 100644
--- a/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
+++ b/java/com/google/gerrit/extensions/conditions/BooleanCondition.java
@@ -48,7 +48,7 @@
 
   BooleanCondition() {}
 
-  /** @return evaluate the condition and return its value. */
+  /** Evaluates the condition and return its value. */
   public abstract boolean value();
 
   /**
@@ -63,7 +63,9 @@
    * Reduce evaluation tree by cutting off branches that evaluate trivially and replacing them with
    * a leave note corresponding to the value the branch evaluated to.
    *
-   * <p><code>
+   * <p>
+   *
+   * <pre>{@code
    * Example 1 (T=True, F=False, C=non-trivial check):
    *      OR
    *     /  \    =>    T
@@ -76,7 +78,7 @@
    *      AND
    *     /  \    =>    F
    *    T   F
-   * </code>
+   * }</pre>
    *
    * <p>There is no guarantee that the resulting tree is minimal. The only guarantee made is that
    * branches that evaluate trivially will be cut off and replaced by primitive values.
diff --git a/java/com/google/gerrit/extensions/config/DownloadScheme.java b/java/com/google/gerrit/extensions/config/DownloadScheme.java
index d81657a..96b5878 100644
--- a/java/com/google/gerrit/extensions/config/DownloadScheme.java
+++ b/java/com/google/gerrit/extensions/config/DownloadScheme.java
@@ -26,12 +26,12 @@
    */
   public abstract String getUrl(String project);
 
-  /** @return whether this scheme requires authentication */
+  /** Returns whether this scheme requires authentication */
   public abstract boolean isAuthRequired();
 
-  /** @return whether this scheme supports authentication */
+  /** Returns whether this scheme supports authentication */
   public abstract boolean isAuthSupported();
 
-  /** @return whether the download scheme is enabled */
+  /** Returns whether the download scheme is enabled */
   public abstract boolean isEnabled();
 }
diff --git a/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java b/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
index edb3e69..45c33c9 100644
--- a/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
+++ b/java/com/google/gerrit/extensions/events/GarbageCollectorListener.java
@@ -22,8 +22,9 @@
 public interface GarbageCollectorListener {
   interface Event extends ProjectEvent {
     /**
-     * @return Properties describing the result of the garbage collection performed by JGit.
-     * @see org.eclipse.jgit.api.GarbageCollectCommand#call()
+     * Returns properties describing the result of the garbage collection performed by JGit.
+     *
+     * <p>See {@link org.eclipse.jgit.api.GarbageCollectCommand#call }
      */
     Properties getStatistics();
   }
diff --git a/java/com/google/gerrit/extensions/restapi/BinaryResult.java b/java/com/google/gerrit/extensions/restapi/BinaryResult.java
index bdddfd9..2ee376e 100644
--- a/java/com/google/gerrit/extensions/restapi/BinaryResult.java
+++ b/java/com/google/gerrit/extensions/restapi/BinaryResult.java
@@ -63,7 +63,7 @@
   private boolean base64;
   private String attachmentName;
 
-  /** @return the MIME type of the result, for HTTP clients. */
+  /** Returns the MIME type of the result, for HTTP clients. */
   public String getContentType() {
     Charset enc = getCharacterEncoding();
     if (enc != null) {
@@ -100,7 +100,7 @@
     return this;
   }
 
-  /** @return length in bytes of the result; -1 if not known. */
+  /** Returns length in bytes of the result; -1 if not known. */
   public long getContentLength() {
     return contentLength;
   }
@@ -111,7 +111,7 @@
     return this;
   }
 
-  /** @return true if this result can be gzip compressed to clients. */
+  /** Returns true if this result can be gzip compressed to clients. */
   public boolean canGzip() {
     return gzip;
   }
@@ -122,7 +122,7 @@
     return this;
   }
 
-  /** @return true if the result must be base64 encoded. */
+  /** Returns true if the result must be base64 encoded. */
   public boolean isBase64() {
     return base64;
   }
diff --git a/java/com/google/gerrit/extensions/restapi/IdString.java b/java/com/google/gerrit/extensions/restapi/IdString.java
index 736c3ba..b2538fa 100644
--- a/java/com/google/gerrit/extensions/restapi/IdString.java
+++ b/java/com/google/gerrit/extensions/restapi/IdString.java
@@ -36,17 +36,17 @@
     urlEncoded = s;
   }
 
-  /** @return the decoded value of the string. */
+  /** Returns the decoded value of the string. */
   public String get() {
     return Url.decode(urlEncoded);
   }
 
-  /** @return true if the string is the empty string. */
+  /** Returns true if the string is the empty string. */
   public boolean isEmpty() {
     return urlEncoded.isEmpty();
   }
 
-  /** @return the original URL encoding supplied by the client. */
+  /** Returns the original URL encoding supplied by the client. */
   public String encoded() {
     return urlEncoded;
   }
diff --git a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
index a3f156b..1b88b1a 100644
--- a/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
+++ b/java/com/google/gerrit/extensions/restapi/PreconditionFailedException.java
@@ -25,10 +25,9 @@
 
   /**
    * @param msg message to return to the client describing the error.
-   * @cause original cause of the failed precondition.
+   * @param cause original cause of the failed precondition.
    */
   public PreconditionFailedException(String msg, Throwable cause) {
-    super(msg);
-    initCause(cause);
+    super(msg, cause);
   }
 }
diff --git a/java/com/google/gerrit/extensions/restapi/RestResource.java b/java/com/google/gerrit/extensions/restapi/RestResource.java
index cc5d48d..3c8144a 100644
--- a/java/com/google/gerrit/extensions/restapi/RestResource.java
+++ b/java/com/google/gerrit/extensions/restapi/RestResource.java
@@ -26,7 +26,7 @@
 
   /** A resource with a last modification date. */
   public interface HasLastModified {
-    /** @return time for the Last-Modified header. HTTP truncates the header value to seconds. */
+    /** Returns time for the Last-Modified header. HTTP truncates the header value to seconds. */
     Timestamp getLastModified();
   }
 
diff --git a/java/com/google/gerrit/extensions/webui/EditWebLink.java b/java/com/google/gerrit/extensions/webui/EditWebLink.java
new file mode 100644
index 0000000..cd70feb
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/EditWebLink.java
@@ -0,0 +1,36 @@
+// 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.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface EditWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a file to an
+   * external service for editing.
+   *
+   * <p>In order for the web link to be visible {@link WebLinkInfo#url} and {@link WebLinkInfo#name}
+   * must be set.
+   *
+   * @param projectName name of the project
+   * @param revision name of the revision (e.g. branch or commit ID)
+   * @param fileName name of the file
+   * @return WebLinkInfo that links to project in external service, null if there should be no link.
+   */
+  WebLinkInfo getEditWebLink(String projectName, String revision, String fileName);
+}
diff --git a/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
new file mode 100644
index 0000000..19402a9
--- /dev/null
+++ b/java/com/google/gerrit/extensions/webui/ResolveConflictsWebLink.java
@@ -0,0 +1,40 @@
+// 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.extensions.webui;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+
+@ExtensionPoint
+public interface ResolveConflictsWebLink extends WebLink {
+
+  /**
+   * {@link com.google.gerrit.extensions.common.WebLinkInfo} describing a link from a patch set to
+   * an external service for the purpose of resolving merge conflicts.
+   *
+   * <p>In order for the web link to be visible {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#url} and {@link
+   * com.google.gerrit.extensions.common.WebLinkInfo#name} must be 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, {@code null} if there should
+   *     be no link.
+   */
+  WebLinkInfo getResolveConflictsWebLink(
+      String projectName, String commit, String commitMessage, String branchName);
+}
diff --git a/java/com/google/gerrit/extensions/webui/UiAction.java b/java/com/google/gerrit/extensions/webui/UiAction.java
index b9d15d2..2f21bf3 100644
--- a/java/com/google/gerrit/extensions/webui/UiAction.java
+++ b/java/com/google/gerrit/extensions/webui/UiAction.java
@@ -30,7 +30,7 @@
    *     the same as {@code setVisible(false)}.
    */
   @Nullable
-  Description getDescription(R resource);
+  Description getDescription(R resource) throws Exception;
 
   /** Describes an action invokable through the web interface. */
   class Description {
diff --git a/java/com/google/gerrit/extensions/webui/WebUiPlugin.java b/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
index 2d49e1c..4f129b0 100644
--- a/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
+++ b/java/com/google/gerrit/extensions/webui/WebUiPlugin.java
@@ -44,7 +44,7 @@
 
   private String pluginName;
 
-  /** @return installed name of the plugin that provides this UI feature. */
+  /** Returns installed name of the plugin that provides this UI feature. */
   public final String getPluginName() {
     return pluginName;
   }
@@ -54,7 +54,7 @@
     this.pluginName = pluginName;
   }
 
-  /** @return path to initialization script within the plugin's JAR. */
+  /** Returns path to initialization script within the plugin's JAR. */
   public abstract String getJavaScriptResourcePath();
 
   @Override
diff --git a/java/com/google/gerrit/git/GitUpdateFailureException.java b/java/com/google/gerrit/git/GitUpdateFailureException.java
index 76ef217..7fcb828 100644
--- a/java/com/google/gerrit/git/GitUpdateFailureException.java
+++ b/java/com/google/gerrit/git/GitUpdateFailureException.java
@@ -46,12 +46,12 @@
             .collect(toImmutableList());
   }
 
-  /** @return the names of the refs for which the update failed. */
+  /** Returns the names of the refs for which the update failed. */
   public ImmutableList<String> getFailedRefs() {
     return failures.stream().map(GitUpdateFailure::ref).collect(toImmutableList());
   }
 
-  /** @return the failures that caused this exception. */
+  /** Returns the failures that caused this exception. */
   @UsedAt(UsedAt.Project.GOOGLE)
   public ImmutableList<GitUpdateFailure> getFailures() {
     return failures;
diff --git a/java/com/google/gerrit/gpg/CheckResult.java b/java/com/google/gerrit/gpg/CheckResult.java
index 8655b2a..2743e74 100644
--- a/java/com/google/gerrit/gpg/CheckResult.java
+++ b/java/com/google/gerrit/gpg/CheckResult.java
@@ -62,22 +62,22 @@
     this.problems = problems;
   }
 
-  /** @return whether the result has status {@link Status#OK} or better. */
+  /** Returns whether the result has status {@link Status#OK} or better. */
   public boolean isOk() {
     return status.compareTo(Status.OK) >= 0;
   }
 
-  /** @return whether the result has status {@link Status#TRUSTED} or better. */
+  /** Returns whether the result has status {@link Status#TRUSTED} or better. */
   public boolean isTrusted() {
     return status.compareTo(Status.TRUSTED) >= 0;
   }
 
-  /** @return the status enum value associated with the object. */
+  /** Returns the status enum value associated with the object. */
   public Status getStatus() {
     return status;
   }
 
-  /** @return any problems encountered during checking. */
+  /** Returns any problems encountered during checking. */
   public List<String> getProblems() {
     return problems;
   }
diff --git a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
index 9477cb6..71dff97 100644
--- a/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
+++ b/java/com/google/gerrit/gpg/GerritPublicKeyChecker.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
@@ -62,17 +63,20 @@
     private final IdentifiedUser.GenericFactory userFactory;
     private final int maxTrustDepth;
     private final ImmutableMap<Long, Fingerprint> trusted;
+    private final ExternalIdKeyFactory externalIdKeyFactory;
 
     @Inject
     Factory(
         @GerritServerConfig Config cfg,
         Provider<InternalAccountQuery> accountQueryProvider,
         IdentifiedUser.GenericFactory userFactory,
-        DynamicItem<UrlFormatter> urlFormatter) {
+        DynamicItem<UrlFormatter> urlFormatter,
+        ExternalIdKeyFactory externalIdKeyFactory) {
       this.accountQueryProvider = accountQueryProvider;
       this.urlFormatter = urlFormatter;
       this.userFactory = userFactory;
       this.maxTrustDepth = cfg.getInt("receive", null, "maxTrustDepth", 0);
+      this.externalIdKeyFactory = externalIdKeyFactory;
 
       String[] strs = cfg.getStringList("receive", null, "trustedKey");
       if (strs.length != 0) {
@@ -103,6 +107,7 @@
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   private IdentifiedUser expectedUser;
 
@@ -113,6 +118,7 @@
     if (factory.trusted != null) {
       enableTrust(factory.maxTrustDepth, factory.trusted);
     }
+    this.externalIdKeyFactory = factory.externalIdKeyFactory;
   }
 
   /**
@@ -247,7 +253,8 @@
     return sb.toString();
   }
 
-  static ExternalId.Key toExtIdKey(PGPPublicKey key) {
-    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint()));
+  ExternalId.Key toExtIdKey(PGPPublicKey key) {
+    return externalIdKeyFactory.create(
+        SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint()));
   }
 }
diff --git a/java/com/google/gerrit/gpg/PushCertificateChecker.java b/java/com/google/gerrit/gpg/PushCertificateChecker.java
index 82b3892..36a4af7 100644
--- a/java/com/google/gerrit/gpg/PushCertificateChecker.java
+++ b/java/com/google/gerrit/gpg/PushCertificateChecker.java
@@ -154,8 +154,11 @@
   protected abstract Repository getRepository() throws IOException;
 
   /**
+   * Specifies whether this repository should be closed before returning froms {@link
+   * #check(PushCertificate)}
+   *
    * @param repo a repository previously returned by {@link #getRepository()}.
-   * @return whether this repository should be closed before returning from {@link
+   * @return true if this repository should be closed before returning from {@link
    *     #check(PushCertificate)}.
    */
   protected abstract boolean shouldClose(Repository repo);
diff --git a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
index 1be37f5..e0c921d 100644
--- a/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
+++ b/java/com/google/gerrit/gpg/server/DeleteGpgKey.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
 import com.google.inject.Inject;
@@ -53,6 +54,7 @@
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
   private final DeleteKeySender.Factory deleteKeySenderFactory;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   DeleteGpgKey(
@@ -60,12 +62,14 @@
       Provider<PublicKeyStore> storeProvider,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       ExternalIds externalIds,
-      DeleteKeySender.Factory deleteKeySenderFactory) {
+      DeleteKeySender.Factory deleteKeySenderFactory,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
     this.storeProvider = storeProvider;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
     this.deleteKeySenderFactory = deleteKeySenderFactory;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   @Override
@@ -73,7 +77,8 @@
       throws RestApiException, PGPException, IOException, ConfigInvalidException {
     PGPPublicKey key = rsrc.getKeyRing().getPublicKey();
     String fingerprint = BaseEncoding.base16().encode(key.getFingerprint());
-    Optional<ExternalId> extId = externalIds.get(ExternalId.Key.create(SCHEME_GPGKEY, fingerprint));
+    Optional<ExternalId> extId =
+        externalIds.get(externalIdKeyFactory.create(SCHEME_GPGKEY, fingerprint));
     if (!extId.isPresent()) {
       throw new ResourceNotFoundException(fingerprint);
     }
diff --git a/java/com/google/gerrit/gpg/server/PostGpgKeys.java b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
index 1b5e06a..d46b344 100644
--- a/java/com/google/gerrit/gpg/server/PostGpgKeys.java
+++ b/java/com/google/gerrit/gpg/server/PostGpgKeys.java
@@ -53,6 +53,8 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.mail.send.AddKeySender;
 import com.google.gerrit.server.mail.send.DeleteKeySender;
@@ -93,6 +95,8 @@
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final RetryHelper retryHelper;
+  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   PostGpgKeys(
@@ -105,7 +109,9 @@
       Provider<InternalAccountQuery> accountQueryProvider,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      RetryHelper retryHelper) {
+      RetryHelper retryHelper,
+      ExternalIdFactory externalIdFactory,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.serverIdent = serverIdent;
     this.self = self;
     this.storeProvider = storeProvider;
@@ -116,6 +122,8 @@
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.retryHelper = retryHelper;
+    this.externalIdFactory = externalIdFactory;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   @Override
@@ -140,7 +148,7 @@
             throw new ResourceConflictException("GPG key already associated with another account");
           }
         } else {
-          newExtIds.add(ExternalId.create(extIdKey, rsrc.getUser().getAccountId()));
+          newExtIds.add(externalIdFactory.create(extIdKey, rsrc.getUser().getAccountId()));
         }
       }
 
@@ -287,7 +295,7 @@
   }
 
   private ExternalId.Key toExtIdKey(byte[] fp) {
-    return ExternalId.Key.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
+    return externalIdKeyFactory.create(SCHEME_GPGKEY, BaseEncoding.base16().encode(fp));
   }
 
   private Account getAccountByExternalId(ExternalId.Key extIdKey) {
diff --git a/java/com/google/gerrit/gpg/testing/TestKeys.java b/java/com/google/gerrit/gpg/testing/TestKeys.java
index de66889..0423474 100644
--- a/java/com/google/gerrit/gpg/testing/TestKeys.java
+++ b/java/com/google/gerrit/gpg/testing/TestKeys.java
@@ -436,13 +436,13 @@
   /**
    * A key with an additional user ID.
    *
-   * <pre>
+   * <pre>{@code
    * pub   2048R/98C51DBF 2015-07-30
    *       Key fingerprint = 42B3 294D 1924 D7EB AF4A  A99F 5024 BB44 98C5 1DBF
    * uid                  foo:myId
    * uid                  Testuser Five <test5@example.com>
    * sub   2048R/C781A9E3 2015-07-30
-   * </pre>
+   * }</pre>
    */
   public static TestKey validKeyWithSecondUserId() {
     return new TestKey(
@@ -1033,13 +1033,13 @@
   /**
    * Master Key without expiration with subkey with expiration.
    *
-   * <pre>
+   * <pre>{@code
    * pub   rsa1024 2018-11-17 [C]
    *       5734 2C37 982A 843B 19C0  622B 6AAF 2D26 B481 02DB
    * uid            [ultimate] Testuser 10 <testuser10@example.com>
    * sub   rsa1024 2018-11-17 [S] [expires: 2065-11-05]
    *       0A4A 9660 1B96 2DFC E898  E686 4305 C92E 626E B485
-   * </pre>
+   * }</pre>
    */
   public static TestKey validKeyWithoutExpirationWithSubkeyWithExpiration() throws Exception {
     return new TestKey(
diff --git a/java/com/google/gerrit/httpd/BUILD b/java/com/google/gerrit/httpd/BUILD
index cd3ebb9..ea7c609 100644
--- a/java/com/google/gerrit/httpd/BUILD
+++ b/java/com/google/gerrit/httpd/BUILD
@@ -20,6 +20,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index f302095..5b62f96 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -22,8 +22,6 @@
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.httpd.WebSessionManager.Key;
-import com.google.gerrit.httpd.WebSessionManager.Val;
 import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
@@ -35,15 +33,13 @@
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Provider;
-import com.google.inject.servlet.RequestScoped;
 import java.util.EnumSet;
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.eclipse.jgit.http.server.GitSmartHttpTools;
 
-@RequestScoped
-public abstract class CacheBasedWebSession implements WebSession {
+public abstract class CacheBasedWebSession extends WebSession {
   @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
 
   @UsedAt(UsedAt.Project.PLUGIN_WEBSESSION_FLATFILE)
@@ -59,8 +55,8 @@
   private final AccountCache byIdCache;
   private Cookie outCookie;
 
-  private Key key;
-  private Val val;
+  private WebSessionManager.Key key;
+  private WebSessionManager.Val val;
   private CurrentUser user;
 
   protected CacheBasedWebSession(
@@ -104,7 +100,7 @@
   }
 
   private void authFromCookie(String cookie) {
-    key = new Key(cookie);
+    key = new WebSessionManager.Key(cookie);
     val = manager.get(key);
     String token = request.getHeader(XsrfConstants.XSRF_HEADER_NAME);
     if (val != null && token != null && token.equals(val.getAuth())) {
@@ -113,7 +109,7 @@
   }
 
   private void authFromQueryParameter(String accessToken) {
-    key = new Key(accessToken);
+    key = new WebSessionManager.Key(accessToken);
     val = manager.get(key);
     if (val != null) {
       okPaths.add(AccessPath.REST_API);
@@ -207,8 +203,8 @@
   /** Set the user account for this current request only. */
   @Override
   public void setUserAccountId(Account.Id id) {
-    key = new Key("id:" + id);
-    val = new Val(id, 0, false, null, 0, null, null);
+    key = new WebSessionManager.Key("id:" + id);
+    val = new WebSessionManager.Val(id, 0, false, null, 0, null, null);
     user = identified.runAs(id, user, PropertyMap.EMPTY);
   }
 
diff --git a/java/com/google/gerrit/httpd/GetUserFilter.java b/java/com/google/gerrit/httpd/GetUserFilter.java
index 2199411..68db98a 100644
--- a/java/com/google/gerrit/httpd/GetUserFilter.java
+++ b/java/com/google/gerrit/httpd/GetUserFilter.java
@@ -40,13 +40,13 @@
 
   public static final String USER_ATTR_KEY = "User";
 
-  public static class Module extends ServletModule {
+  public static class GetUserFilterModule extends ServletModule {
 
     private final boolean reqEnabled;
     private final boolean resEnabled;
 
     @Inject
-    Module(@GerritServerConfig Config cfg) {
+    GetUserFilterModule(@GerritServerConfig Config cfg) {
       reqEnabled = cfg.getBoolean("http", "addUserAsRequestAttribute", true);
       resEnabled = cfg.getBoolean("http", "addUserAsResponseHeader", false);
     }
diff --git a/java/com/google/gerrit/httpd/GitOverHttpServlet.java b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
index 06f96c5..56afe40 100644
--- a/java/com/google/gerrit/httpd/GitOverHttpServlet.java
+++ b/java/com/google/gerrit/httpd/GitOverHttpServlet.java
@@ -136,11 +136,11 @@
     URL_REGEX = url.toString();
   }
 
-  static class Module extends AbstractModule {
+  static class GitOverHttpServletModule extends AbstractModule {
 
     private final boolean enableReceive;
 
-    Module(boolean enableReceive) {
+    GitOverHttpServletModule(boolean enableReceive) {
       this.enableReceive = enableReceive;
     }
 
diff --git a/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
new file mode 100644
index 0000000..ee28df9
--- /dev/null
+++ b/java/com/google/gerrit/httpd/GitReferenceUpdatedTracker.java
@@ -0,0 +1,55 @@
+// 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;
+
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+
+/**
+ * Stores the updated refs whenever they are updated, so that we can export this information in the
+ * response headers.
+ *
+ * <p>This is only working for HTTP requests. {@link WebSession} is not bound outside of HTTP
+ * requests.
+ */
+@Singleton
+public class GitReferenceUpdatedTracker implements GitReferenceUpdatedListener {
+
+  private final DynamicItem<WebSession> webSession;
+
+  @Inject
+  GitReferenceUpdatedTracker(DynamicItem<WebSession> webSession) {
+    this.webSession = webSession;
+  }
+
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+    WebSession currentSession = null;
+    try {
+      currentSession = webSession.get();
+    } catch (ProvisionException ex) {
+      // We couldn't bind the current session properly. This is expected to happen at any point we
+      // perform ref updates without an HTTP request (git push for example).
+      // If we can't get a WebSession, we don't need to track the updated references.
+      return;
+    }
+    if (currentSession != null) {
+      currentSession.addRefUpdatedEvents(event);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/httpd/HttpdModule.java b/java/com/google/gerrit/httpd/HttpdModule.java
new file mode 100644
index 0000000..1f1ec2f
--- /dev/null
+++ b/java/com/google/gerrit/httpd/HttpdModule.java
@@ -0,0 +1,14 @@
+package com.google.gerrit.httpd;
+
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+
+public class HttpdModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(GitReferenceUpdatedListener.class)
+        .annotatedWith(Exports.named(GitReferenceUpdatedTracker.class.getSimpleName()))
+        .to(GitReferenceUpdatedTracker.class);
+  }
+}
diff --git a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
index de989ac..a421139 100644
--- a/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectBasicAuthFilter.java
@@ -76,17 +76,23 @@
   private final AccountCache accountCache;
   private final AccountManager accountManager;
   private final AuthConfig authConfig;
+  private final AuthRequest.Factory authRequestFactory;
+  private final PasswordVerifier passwordVerifier;
 
   @Inject
   ProjectBasicAuthFilter(
       DynamicItem<WebSession> session,
       AccountCache accountCache,
       AccountManager accountManager,
-      AuthConfig authConfig) {
+      AuthConfig authConfig,
+      AuthRequest.Factory authRequestFactory,
+      PasswordVerifier passwordVerifier) {
     this.session = session;
     this.accountCache = accountCache;
     this.accountManager = accountManager;
     this.authConfig = authConfig;
+    this.authRequestFactory = authRequestFactory;
+    this.passwordVerifier = passwordVerifier;
   }
 
   @Override
@@ -155,7 +161,7 @@
     GitBasicAuthPolicy gitBasicAuthPolicy = authConfig.getGitBasicAuthPolicy();
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP
         || gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP) {
-      if (PasswordVerifier.checkPassword(who.externalIds(), username, password)) {
+      if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
         logger.atFine().log(
             "HTTP:%s %s username/password authentication succeeded",
             req.getMethod(), req.getRequestURI());
@@ -167,7 +173,7 @@
       return failAuthentication(rsp, username, req);
     }
 
-    AuthRequest whoAuth = AuthRequest.forUser(username);
+    AuthRequest whoAuth = authRequestFactory.createForUser(username);
     whoAuth.setPassword(password);
 
     try {
@@ -177,7 +183,7 @@
           "HTTP:%s %s Realm authentication succeeded", req.getMethod(), req.getRequestURI());
       return true;
     } catch (NoSuchUserException e) {
-      if (PasswordVerifier.checkPassword(who.externalIds(), username, password)) {
+      if (passwordVerifier.checkPassword(who.externalIds(), username, password)) {
         return succeedAuthentication(who, null);
       }
       logger.atWarning().withCause(e).log(authenticationFailedMsg(username, req));
diff --git a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
index dab36c4..fa53053 100644
--- a/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
+++ b/java/com/google/gerrit/httpd/ProjectOAuthFilter.java
@@ -76,6 +76,7 @@
   private final AccountManager accountManager;
   private final String gitOAuthProvider;
   private final boolean userNameToLowerCase;
+  private final AuthRequest.Factory authRequestFactory;
 
   private String defaultAuthPlugin;
   private String defaultAuthProvider;
@@ -86,13 +87,15 @@
       DynamicMap<OAuthLoginProvider> pluginsProvider,
       AccountCache accountCache,
       AccountManager accountManager,
-      @GerritServerConfig Config gerritConfig) {
+      @GerritServerConfig Config gerritConfig,
+      AuthRequest.Factory authRequestFactory) {
     this.session = session;
     this.loginProviders = pluginsProvider;
     this.accountCache = accountCache;
     this.accountManager = accountManager;
     this.gitOAuthProvider = gerritConfig.getString("auth", null, "gitOAuthProvider");
     this.userNameToLowerCase = gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -162,7 +165,7 @@
     }
 
     Account account = who.get().account();
-    AuthRequest authRequest = AuthRequest.forExternalUser(authInfo.username);
+    AuthRequest authRequest = authRequestFactory.createForExternalUser(authInfo.username);
     authRequest.setEmailAddress(account.preferredEmail());
     authRequest.setDisplayName(account.fullName());
     authRequest.setPassword(authInfo.tokenOrSecret);
diff --git a/java/com/google/gerrit/httpd/RequestMetricsFilter.java b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
index c97b9ad..0ff1a79 100644
--- a/java/com/google/gerrit/httpd/RequestMetricsFilter.java
+++ b/java/com/google/gerrit/httpd/RequestMetricsFilter.java
@@ -55,17 +55,17 @@
       startedMemory = threadMxBean.getCurrentThreadAllocatedBytes();
     }
 
-    /** @return total CPU time in milliseconds for executing request */
+    /** Returns total CPU time in milliseconds for executing request */
     public long getTotalCpuTime() {
       return (threadMxBean.getCurrentThreadCpuTime() - startedTotalCpu) / 1_000_000;
     }
 
-    /** @return CPU time in user mode in milliseconds for executing request */
+    /** Returns CPU time in user mode in milliseconds for executing request */
     public long getUserCpuTime() {
       return (threadMxBean.getCurrentThreadUserTime() - startedUserCpu) / 1_000_000;
     }
 
-    /** @return memory allocated in bytes for executing request */
+    /** Returns memory allocated in bytes for executing request */
     public long getAllocatedMemory() {
       return startedMemory == -1
           ? -1
diff --git a/java/com/google/gerrit/httpd/RequireSslFilter.java b/java/com/google/gerrit/httpd/RequireSslFilter.java
index d8e6f84..a4a87e2 100644
--- a/java/com/google/gerrit/httpd/RequireSslFilter.java
+++ b/java/com/google/gerrit/httpd/RequireSslFilter.java
@@ -33,11 +33,11 @@
 /** Requires the connection to use SSL, redirects if not. */
 @Singleton
 public class RequireSslFilter implements Filter {
-  public static class Module extends ServletModule {
+  public static class RequireSslFilterModule extends ServletModule {
     private final boolean wantSsl;
 
     @Inject
-    Module(@Nullable @CanonicalWebUrl String canonicalUrl) {
+    RequireSslFilterModule(@Nullable @CanonicalWebUrl String canonicalUrl) {
       this.wantSsl = canonicalUrl != null && canonicalUrl.startsWith("https:");
     }
 
diff --git a/java/com/google/gerrit/httpd/RunAsFilter.java b/java/com/google/gerrit/httpd/RunAsFilter.java
index 135de42..b93f7ed 100644
--- a/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -49,7 +49,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final String RUN_AS = "X-Gerrit-RunAs";
 
-  static class Module extends ServletModule {
+  static class RunAsFilterModule extends ServletModule {
     @Override
     protected void configureServlets() {
       filter("/*").through(RunAsFilter.class);
diff --git a/java/com/google/gerrit/httpd/UrlModule.java b/java/com/google/gerrit/httpd/UrlModule.java
index ac73d22..029efba 100644
--- a/java/com/google/gerrit/httpd/UrlModule.java
+++ b/java/com/google/gerrit/httpd/UrlModule.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.httpd.RunAsFilter.RunAsFilterModule;
 import com.google.gerrit.httpd.raw.AuthorizationCheckServlet;
 import com.google.gerrit.httpd.raw.CatServlet;
 import com.google.gerrit.httpd.raw.SshInfoServlet;
@@ -79,7 +80,7 @@
 
     // Must be after RequireIdentifiedUserFilter so auth happens before checking
     // for RunAs capability.
-    install(new RunAsFilter.Module());
+    install(new RunAsFilterModule());
 
     serveRegex("^/(?:a/)?tools/(.*)$").with(ToolServlet.class);
 
diff --git a/java/com/google/gerrit/httpd/WebModule.java b/java/com/google/gerrit/httpd/WebModule.java
index e416075..da485cc 100644
--- a/java/com/google/gerrit/httpd/WebModule.java
+++ b/java/com/google/gerrit/httpd/WebModule.java
@@ -16,6 +16,7 @@
 
 import static com.google.gerrit.extensions.registration.PrivateInternals_DynamicTypes.registerInParentInjectors;
 
+import com.google.gerrit.httpd.GitOverHttpServlet.GitOverHttpServletModule;
 import com.google.gerrit.httpd.auth.become.BecomeAnyAccountModule;
 import com.google.gerrit.httpd.auth.container.HttpAuthModule;
 import com.google.gerrit.httpd.auth.container.HttpsClientSslCertModule;
@@ -27,7 +28,7 @@
 import com.google.gerrit.server.config.GerritOptions;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GitwebCgiConfig;
-import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
 import com.google.gerrit.server.util.GuiceRequestScopePropagator;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
@@ -57,13 +58,13 @@
       install(new UrlModule(authConfig));
     }
     install(new GerritRequestModule());
-    install(new GitOverHttpServlet.Module(options.enableMasterFeatures()));
+    install(new GitOverHttpServletModule(options.enableMasterFeatures()));
 
     if (gitwebCgiConfig.getGitwebCgi() != null) {
       install(new GitwebModule());
     }
 
-    install(new AsyncReceiveCommits.Module());
+    install(new AsyncReceiveCommitsModule());
 
     bind(SocketAddress.class)
         .annotatedWith(RemotePeer.class)
@@ -75,6 +76,10 @@
     listener().toInstance(registerInParentInjectors());
 
     install(UniversalWebLoginFilter.module());
+
+    // Static injection was unfortunately the best solution in this place. However, it is to be
+    // avoided if possible.
+    requestStaticInjection(WebSessionManager.Val.class);
   }
 
   private void installAuthModule() {
diff --git a/java/com/google/gerrit/httpd/WebSession.java b/java/com/google/gerrit/httpd/WebSession.java
index daf30ff..df8402e 100644
--- a/java/com/google/gerrit/httpd/WebSession.java
+++ b/java/com/google/gerrit/httpd/WebSession.java
@@ -16,30 +16,61 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AuthResult;
+import com.google.inject.servlet.RequestScoped;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 
-public interface WebSession {
-  boolean isSignedIn();
+/**
+ * A thread safe class that contains details about a specific user web session.
+ *
+ * <p>WARNING: All implementors must have {@link RequestScoped} annotation to maintain thread
+ * safety.
+ */
+public abstract class WebSession {
+  public abstract boolean isSignedIn();
 
   @Nullable
-  String getXGerritAuth();
+  public abstract String getXGerritAuth();
 
-  boolean isValidXGerritAuth(String keyIn);
+  public abstract boolean isValidXGerritAuth(String keyIn);
 
-  CurrentUser getUser();
+  public abstract CurrentUser getUser();
 
-  void login(AuthResult res, boolean rememberMe);
+  public abstract void login(AuthResult res, boolean rememberMe);
 
   /** Set the user account for this current request only. */
-  void setUserAccountId(Account.Id id);
+  public abstract void setUserAccountId(Account.Id id);
 
-  boolean isAccessPathOk(AccessPath path);
+  public abstract boolean isAccessPathOk(AccessPath path);
 
-  void setAccessPathOk(AccessPath path, boolean ok);
+  public abstract void setAccessPathOk(AccessPath path, boolean ok);
 
-  void logout();
+  public abstract void logout();
 
-  String getSessionId();
+  public abstract String getSessionId();
+
+  /**
+   * Store and return the ref updates in this session. This class is {@link RequestScoped}, hence
+   * this is thread safe.
+   *
+   * <p>The same session could perform separate requests one after another, so resetting the ref
+   * updates is necessary between requests.
+   */
+  private List<GitReferenceUpdatedListener.Event> refUpdatedEvents = new CopyOnWriteArrayList<>();
+
+  public List<GitReferenceUpdatedListener.Event> getRefUpdatedEvents() {
+    return refUpdatedEvents;
+  }
+
+  public void addRefUpdatedEvents(GitReferenceUpdatedListener.Event event) {
+    refUpdatedEvents.add(event);
+  }
+
+  public void resetRefUpdatedEvents() {
+    refUpdatedEvents.clear();
+  }
 }
diff --git a/java/com/google/gerrit/httpd/WebSessionManager.java b/java/com/google/gerrit/httpd/WebSessionManager.java
index c0900ec..87bf3a6 100644
--- a/java/com/google/gerrit/httpd/WebSessionManager.java
+++ b/java/com/google/gerrit/httpd/WebSessionManager.java
@@ -32,6 +32,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -186,6 +187,8 @@
   public static final class Val implements Serializable {
     static final long serialVersionUID = 2L;
 
+    @Inject private static transient ExternalIdKeyFactory externalIdKeyFactory;
+
     private transient Account.Id accountId;
     private transient long refreshCookieAt;
     private transient boolean persistentCookie;
@@ -295,7 +298,7 @@
             persistentCookie = readVarInt32(in) != 0;
             continue;
           case 4:
-            externalId = ExternalId.Key.parse(readString(in));
+            externalId = externalIdKeyFactory.parse(readString(in));
             continue;
           case 5:
             sessionId = readString(in);
diff --git a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
index 97bb44b..2f760f0 100644
--- a/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/become/BecomeAnyAccountLoginServlet.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.account.Accounts;
 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.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
@@ -62,6 +62,8 @@
   private final AccountManager accountManager;
   private final SiteHeaderFooter headers;
   private final Provider<InternalAccountQuery> queryProvider;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   BecomeAnyAccountLoginServlet(
@@ -70,13 +72,17 @@
       AccountCache ac,
       AccountManager am,
       SiteHeaderFooter shf,
-      Provider<InternalAccountQuery> qp) {
+      Provider<InternalAccountQuery> qp,
+      ExternalIdKeyFactory eikf,
+      AuthRequest.Factory arf) {
     webSession = ws;
     accounts = a;
     accountCache = ac;
     accountManager = am;
     headers = shf;
     queryProvider = qp;
+    externalIdKeyFactory = eikf;
+    authRequestFactory = arf;
   }
 
   @Override
@@ -220,7 +226,8 @@
   private AuthResult create() throws IOException {
     try {
       return accountManager.authenticate(
-          new AuthRequest(ExternalId.Key.create(SCHEME_UUID, UUID.randomUUID().toString())));
+          authRequestFactory.create(
+              externalIdKeyFactory.create(SCHEME_UUID, UUID.randomUUID().toString())));
     } catch (AccountException e) {
       getServletContext().log("cannot create new account", e);
       return null;
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
index e20c9b9..acb3282 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpAuthFilter.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.httpd.RemoteUserUtil;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.gerrit.util.http.RequestUtil;
@@ -64,10 +65,16 @@
   private final String emailHeader;
   private final String externalIdHeader;
   private final boolean userNameToLowerCase;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
-  HttpAuthFilter(DynamicItem<WebSession> webSession, AuthConfig authConfig) throws IOException {
+  HttpAuthFilter(
+      DynamicItem<WebSession> webSession,
+      AuthConfig authConfig,
+      ExternalIdKeyFactory externalIdKeyFactory)
+      throws IOException {
     this.sessionProvider = webSession;
+    this.externalIdKeyFactory = externalIdKeyFactory;
 
     final String pageName = "LoginRedirect.html";
     final String doc = HtmlDomUtil.readFile(getClass(), pageName);
@@ -124,9 +131,9 @@
     return false;
   }
 
-  private static boolean correctUser(String user, WebSession session) {
+  private boolean correctUser(String user, WebSession session) {
     Optional<ExternalId.Key> id = session.getUser().getLastLoginExternalIdKey();
-    return id.map(i -> i.equals(ExternalId.Key.create(SCHEME_GERRIT, user))).orElse(false);
+    return id.map(i -> i.equals(externalIdKeyFactory.create(SCHEME_GERRIT, user))).orElse(false);
   }
 
   String getRemoteUser(HttpServletRequest req) {
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
index 1b7e477..53f33b5 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpLoginServlet.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.util.http.CacheHeaders;
 import com.google.inject.Inject;
@@ -61,6 +61,8 @@
   private final AccountManager accountManager;
   private final HttpAuthFilter authFilter;
   private final AuthConfig authConfig;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   HttpLoginServlet(
@@ -68,12 +70,16 @@
       final CanonicalWebUrl urlProvider,
       final AccountManager accountManager,
       final HttpAuthFilter authFilter,
-      final AuthConfig authConfig) {
+      final AuthConfig authConfig,
+      final ExternalIdKeyFactory externalIdKeyFactory,
+      final AuthRequest.Factory authRequestFactory) {
     this.webSession = webSession;
     this.urlProvider = urlProvider;
     this.accountManager = accountManager;
     this.authFilter = authFilter;
     this.authConfig = authConfig;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -109,7 +115,7 @@
       return;
     }
 
-    final AuthRequest areq = AuthRequest.forUser(user);
+    final AuthRequest areq = authRequestFactory.createForUser(user);
     areq.setDisplayName(authFilter.getRemoteDisplayname(req));
     areq.setEmailAddress(authFilter.getRemoteEmail(req));
     final AuthResult arsp;
@@ -154,7 +160,7 @@
       throws AccountException, IOException, ConfigInvalidException {
     accountManager.updateLink(
         arsp.getAccountId(),
-        new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, remoteAuthToken)));
+        authRequestFactory.create(externalIdKeyFactory.create(SCHEME_EXTERNAL, remoteAuthToken)));
   }
 
   private void replace(Document doc, String name, String value) {
diff --git a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
index 40807c0..820c7a2 100644
--- a/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
+++ b/java/com/google/gerrit/httpd/auth/container/HttpsClientSslCertAuthFilter.java
@@ -42,12 +42,16 @@
 
   private final DynamicItem<WebSession> webSession;
   private final AccountManager accountManager;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   HttpsClientSslCertAuthFilter(
-      final DynamicItem<WebSession> webSession, AccountManager accountManager) {
+      final DynamicItem<WebSession> webSession,
+      AccountManager accountManager,
+      final AuthRequest.Factory authRequestFactory) {
     this.webSession = webSession;
     this.accountManager = accountManager;
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -70,7 +74,7 @@
     } else {
       throw new ServletException("Couldn't extract username from your certificate");
     }
-    final AuthRequest areq = AuthRequest.forUser(userName);
+    final AuthRequest areq = authRequestFactory.createForUser(userName);
     final AuthResult arsp;
     try {
       arsp = accountManager.authenticate(areq);
diff --git a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
index a09866e..6caa760 100644
--- a/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
+++ b/java/com/google/gerrit/httpd/auth/ldap/LdapLoginServlet.java
@@ -56,17 +56,20 @@
   private final DynamicItem<WebSession> webSession;
   private final CanonicalWebUrl urlProvider;
   private final SiteHeaderFooter headers;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   LdapLoginServlet(
       AccountManager accountManager,
       DynamicItem<WebSession> webSession,
       CanonicalWebUrl urlProvider,
-      SiteHeaderFooter headers) {
+      SiteHeaderFooter headers,
+      AuthRequest.Factory authRequestFactory) {
     this.accountManager = accountManager;
     this.webSession = webSession;
     this.urlProvider = urlProvider;
     this.headers = headers;
+    this.authRequestFactory = authRequestFactory;
   }
 
   private void sendForm(
@@ -115,7 +118,7 @@
       return;
     }
 
-    AuthRequest areq = AuthRequest.forUser(username);
+    AuthRequest areq = authRequestFactory.createForUser(username);
     areq.setPassword(password);
 
     AuthResult ares;
diff --git a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index 70ed79b..a3f8fbda 100644
--- a/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
@@ -65,6 +65,8 @@
   private Account.Id accountId;
   private String redirectToken;
   private boolean linkMode;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   OAuthSession(
@@ -72,13 +74,17 @@
       Provider<IdentifiedUser> identifiedUser,
       AccountManager accountManager,
       CanonicalWebUrl urlProvider,
-      OAuthTokenCache tokenCache) {
+      OAuthTokenCache tokenCache,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      AuthRequest.Factory authRequestFactory) {
     this.state = generateRandomState();
     this.identifiedUser = identifiedUser;
     this.webSession = webSession;
     this.accountManager = accountManager;
     this.urlProvider = urlProvider;
     this.tokenCache = tokenCache;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authRequestFactory = authRequestFactory;
   }
 
   boolean isLoggedIn() {
@@ -126,7 +132,7 @@
 
   private void authenticateAndRedirect(
       HttpServletRequest req, HttpServletResponse rsp, OAuthToken token) throws IOException {
-    AuthRequest areq = new AuthRequest(ExternalId.Key.parse(user.getExternalId()));
+    AuthRequest areq = authRequestFactory.create(externalIdKeyFactory.parse(user.getExternalId()));
     AuthResult arsp;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
diff --git a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index b987c68..df0062c 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -32,8 +32,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.servlet.SessionScoped;
@@ -63,18 +64,24 @@
   private OAuthUserInfo user;
   private String redirectToken;
   private boolean linkMode;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   OAuthSessionOverOpenID(
       DynamicItem<WebSession> webSession,
       Provider<IdentifiedUser> identifiedUser,
       AccountManager accountManager,
-      CanonicalWebUrl urlProvider) {
+      CanonicalWebUrl urlProvider,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      AuthRequest.Factory authRequestFactory) {
     this.state = generateRandomState();
     this.webSession = webSession;
     this.identifiedUser = identifiedUser;
     this.accountManager = accountManager;
     this.urlProvider = urlProvider;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authRequestFactory = authRequestFactory;
   }
 
   boolean isLoggedIn() {
@@ -117,8 +124,7 @@
   private void authenticateAndRedirect(HttpServletRequest req, HttpServletResponse rsp)
       throws IOException {
     com.google.gerrit.server.account.AuthRequest areq =
-        new com.google.gerrit.server.account.AuthRequest(
-            ExternalId.Key.parse(user.getExternalId()));
+        authRequestFactory.create(externalIdKeyFactory.parse(user.getExternalId()));
     AuthResult arsp;
     try {
       String claimedIdentifier = user.getClaimedIdentity();
diff --git a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index c655b6c..fcd16ae 100644
--- a/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -92,6 +92,8 @@
   private final ConsumerManager manager;
   private final List<OpenIdProviderPattern> allowedOpenIDs;
   private final List<String> openIdDomains;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private final com.google.gerrit.server.account.AuthRequest.Factory authRequestFactory;
 
   /** Maximum age, in seconds, before forcing re-authentication of account. */
   private final int papeMaxAuthAge;
@@ -104,7 +106,9 @@
       @GerritServerConfig Config config,
       AuthConfig ac,
       AccountManager am,
-      ProxyProperties proxyProperties) {
+      ProxyProperties proxyProperties,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      com.google.gerrit.server.account.AuthRequest.Factory authRequestFactory) {
 
     if (proxyProperties.getProxyUrl() != null) {
       final org.openid4java.util.ProxyProperties proxy = new org.openid4java.util.ProxyProperties();
@@ -132,6 +136,8 @@
                 "maxOpenIdSessionAge",
                 -1,
                 TimeUnit.SECONDS);
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authRequestFactory = authRequestFactory;
   }
 
   @SuppressWarnings("unchecked")
@@ -310,7 +316,7 @@
     }
 
     final com.google.gerrit.server.account.AuthRequest areq =
-        new com.google.gerrit.server.account.AuthRequest(ExternalId.Key.parse(openidIdentifier));
+        authRequestFactory.create(externalIdKeyFactory.parse(openidIdentifier));
 
     if (sregRsp != null) {
       areq.setDisplayName(sregRsp.getAttributeValue("fullname"));
@@ -388,8 +394,7 @@
         // was missing due to a bug in Gerrit. Link the claimed.
         //
         final com.google.gerrit.server.account.AuthRequest linkReq =
-            new com.google.gerrit.server.account.AuthRequest(
-                ExternalId.Key.parse(claimedIdentifier));
+            authRequestFactory.create(externalIdKeyFactory.parse(claimedIdentifier));
         linkReq.setDisplayName(areq.getDisplayName());
         linkReq.setEmailAddress(areq.getEmailAddress());
         accountManager.link(actualId.get(), linkReq);
@@ -425,8 +430,7 @@
           webSession.get().login(arsp, remember);
           if (arsp.isNew() && claimedIdentifier != null) {
             final com.google.gerrit.server.account.AuthRequest linkReq =
-                new com.google.gerrit.server.account.AuthRequest(
-                    ExternalId.Key.parse(claimedIdentifier));
+                authRequestFactory.create(externalIdKeyFactory.parse(claimedIdentifier));
             linkReq.setDisplayName(areq.getDisplayName());
             linkReq.setEmailAddress(areq.getEmailAddress());
             accountManager.link(arsp.getAccountId(), linkReq);
diff --git a/java/com/google/gerrit/httpd/init/BUILD b/java/com/google/gerrit/httpd/init/BUILD
index adfbdcc..990b5d7 100644
--- a/java/com/google/gerrit/httpd/init/BUILD
+++ b/java/com/google/gerrit/httpd/init/BUILD
@@ -6,7 +6,6 @@
     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",
diff --git a/java/com/google/gerrit/httpd/init/WebAppInitializer.java b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
index 2ed342f..68cf1b2 100644
--- a/java/com/google/gerrit/httpd/init/WebAppInitializer.java
+++ b/java/com/google/gerrit/httpd/init/WebAppInitializer.java
@@ -19,7 +19,6 @@
 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;
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -28,10 +27,11 @@
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.HttpdModule;
 import com.google.gerrit.httpd.RequestCleanupFilter;
 import com.google.gerrit.httpd.RequestContextFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
-import com.google.gerrit.httpd.RequireSslFilter;
+import com.google.gerrit.httpd.RequireSslFilter.RequireSslFilterModule;
 import com.google.gerrit.httpd.SetThreadNameFilter;
 import com.google.gerrit.httpd.WebModule;
 import com.google.gerrit.httpd.WebSshGlueModule;
@@ -45,23 +45,24 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
-import com.google.gerrit.pgm.util.LogFileCompressor;
+import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
-import com.google.gerrit.server.StartupChecks;
-import com.google.gerrit.server.account.AccountDeactivator;
-import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.StartupChecks.StartupChecksModule;
+import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
+import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
 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.ChangeCleanupRunner;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
-import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
@@ -71,44 +72,45 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SysExecutorModule;
-import com.google.gerrit.server.events.EventBroker;
-import com.google.gerrit.server.events.StreamEventsApiListener;
+import com.google.gerrit.server.events.EventBroker.EventBrokerModule;
+import com.google.gerrit.server.events.StreamEventsApiListener.StreamEventsApiListenerModule;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl.SearchingChangeCacheImplModule;
 import com.google.gerrit.server.git.SystemReaderInstaller;
-import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.options.AutoFlush;
-import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.receive.MailReceiver;
-import com.google.gerrit.server.mail.send.SmtpEmailSender;
+import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
+import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
+import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
-import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.project.DefaultProjectNameLockManager.DefaultProjectNameLockManagerModule;
 import com.google.gerrit.server.restapi.RestApiModule;
-import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore.JdbcAccountPatchReviewStoreModule;
 import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
-import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
-import com.google.gerrit.server.submit.SubscriptionGraph;
-import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
+import com.google.gerrit.server.submit.LocalMergeSuperSetComputation.LocalMergeSuperSetComputationModule;
+import com.google.gerrit.server.submit.SubscriptionGraph.SubscriptionGraphModule;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener.SuperprojectUpdateSubmissionListenerModule;
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.ExternalIdCommandsModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
-import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
 import com.google.inject.AbstractModule;
 import com.google.inject.CreationException;
 import com.google.inject.Guice;
@@ -122,6 +124,8 @@
 import com.google.inject.spi.Message;
 import com.google.inject.util.Providers;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
@@ -288,35 +292,35 @@
     modules.add(new AuthConfigModule());
     return cfgInjector.createChildInjector(
         ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE_TYPE)));
   }
 
   private Injector createSysInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressor.Module());
-    modules.add(new EventBroker.Module());
-    modules.add(new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(new LogFileCompressorModule());
+    modules.add(new EventBrokerModule());
+    modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new StreamEventsApiListener.Module());
+    modules.add(new StreamEventsApiListenerModule());
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new GerritApiModule());
     modules.add(new PluginApiModule());
-    modules.add(new SearchingChangeCacheImpl.Module());
-    modules.add(new InternalAccountDirectory.Module());
+    modules.add(new SearchingChangeCacheImplModule());
+    modules.add(new InternalAccountDirectoryModule());
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
-    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
-    modules.add(new SmtpEmailSender.Module());
-    modules.add(new SignedTokenEmailTokenVerifier.Module());
-    modules.add(new LocalMergeSuperSetComputation.Module());
+    modules.add(cfgInjector.getInstance(MailReceiverModule.class));
+    modules.add(new SmtpEmailSenderModule());
+    modules.add(new SignedTokenEmailTokenVerifierModule());
+    modules.add(new LocalMergeSuperSetComputationModule());
     modules.add(new AuditModule());
     modules.add(new GpgModule(config));
-    modules.add(new StartupChecks.Module());
+    modules.add(new StartupChecksModule());
 
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
@@ -324,13 +328,13 @@
 
     modules.add(new PluginModule());
     if (VersionManager.getOnlineUpgrade(config)) {
-      modules.add(new OnlineUpgrader.Module());
+      modules.add(new OnlineUpgraderModule());
     }
     modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
-    modules.add(new SubscriptionGraph.Module());
-    modules.add(new SuperprojectUpdateSubmissionListener.Module());
-    modules.add(new WorkQueue.Module());
+    modules.add(new SubscriptionGraphModule());
+    modules.add(new SuperprojectUpdateSubmissionListenerModule());
+    modules.add(new WorkQueueModule());
     modules.add(new GerritInstanceNameModule());
     modules.add(
         new CanonicalWebUrlModule() {
@@ -339,7 +343,7 @@
             return HttpCanonicalWebUrlProvider.class;
           }
         });
-    modules.add(new DefaultUrlFormatter.Module());
+    modules.add(new DefaultUrlFormatterModule());
 
     SshSessionFactoryInitializer.init(config);
     modules.add(SshKeyCacheImpl.module());
@@ -347,24 +351,36 @@
         new AbstractModule() {
           @Override
           protected void configure() {
-            bind(GerritOptions.class).toInstance(new GerritOptions(false, false, ""));
+            bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
             bind(GerritRuntime.class).toInstance(GerritRuntime.DAEMON);
           }
         });
     modules.add(new GarbageCollectionModule());
-    modules.add(new ChangeCleanupRunner.Module());
-    modules.add(new AccountDeactivator.Module());
-    modules.add(new DefaultProjectNameLockManager.Module());
+    modules.add(new ChangeCleanupRunnerModule());
+    modules.add(new AccountDeactivatorModule());
+    modules.add(new DefaultProjectNameLockManagerModule());
+    modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
     return dbInjector.createChildInjector(
         ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE)));
+            modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE)));
   }
 
   private Module createIndexModule() {
     if (indexType.isLucene()) {
       return LuceneIndexModule.latestVersion(false, AutoFlush.ENABLED);
-    } else if (indexType.isElasticsearch()) {
-      return ElasticIndexModule.latestVersion(false);
+    } else if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m = clazz.getMethod("latestVersion", boolean.class);
+        return (Module) m.invoke(null, false);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     } else {
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -382,9 +398,10 @@
         new DefaultCommandModule(
             false,
             sysInjector.getInstance(DownloadConfig.class),
-            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+            sysInjector.getInstance(LfsPluginAuthCommandModule.class)));
     modules.add(new IndexCommandsModule(sysInjector));
     modules.add(new SequenceCommandsModule());
+    modules.add(new ExternalIdCommandsModule());
     return sysInjector.createChildInjector(modules);
   }
 
@@ -394,11 +411,12 @@
     modules.add(RequestMetricsFilter.module());
     modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(HttpdModule.class));
     modules.add(RequestCleanupFilter.module());
     modules.add(SetThreadNameFilter.module());
     modules.add(AllRequestFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
-    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
+    modules.add(sysInjector.getInstance(RequireSslFilterModule.class));
     if (sshInjector != null) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
     } else {
@@ -415,7 +433,7 @@
     }
     modules.add(new AuthModule(authConfig));
 
-    modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
+    modules.add(sysInjector.getInstance(GetUserFilter.GetUserFilterModule.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
     GerritOptions opts = sysInjector.getInstance(GerritOptions.class);
diff --git a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
index 43eb3a0..ef37fc5 100644
--- a/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
+++ b/java/com/google/gerrit/httpd/plugins/HttpPluginServlet.java
@@ -358,6 +358,33 @@
     return cachedResource != null && cachedResource.isUnchanged(lastUpdateTime);
   }
 
+  private void appendPageAsSection(
+      PluginContentScanner scanner, PluginEntry pluginEntry, String sectionTitle, StringBuilder md)
+      throws IOException {
+    InputStreamReader isr = new InputStreamReader(scanner.getInputStream(pluginEntry), UTF_8);
+    StringBuilder content = new StringBuilder();
+    try (BufferedReader reader = new BufferedReader(isr)) {
+      String line;
+      while ((line = reader.readLine()) != null) {
+        line = StringUtils.stripEnd(line, null);
+        if (line.isEmpty()) {
+          content.append("\n");
+        } else {
+          content.append(line).append("\n");
+        }
+      }
+    }
+
+    // Only append the section if there was anything in it
+    if (content.toString().trim().length() > 0) {
+      md.append("## ");
+      md.append(sectionTitle);
+      md.append(" ##\n");
+      md.append("\n").append(content);
+      md.append("\n");
+    }
+  }
+
   private void appendEntriesSection(
       PluginContentScanner scanner,
       List<PluginEntry> entries,
@@ -400,6 +427,7 @@
     List<PluginEntry> restApis = new ArrayList<>();
     List<PluginEntry> docs = new ArrayList<>();
     PluginEntry about = null;
+    PluginEntry toc = null;
 
     Predicate<PluginEntry> filter =
         entry -> {
@@ -437,6 +465,14 @@
               "Plugin %s: Multiple 'about' documents found; using %s",
               pluginName, about.getName().substring(prefix.length()));
         }
+      } else if (name.startsWith("toc.")) {
+        if (toc == null) {
+          toc = entry;
+        } else {
+          logger.atWarning().log(
+              "Plugin %s: Multiple 'toc' documents found; using %s",
+              pluginName, toc.getName().substring(prefix.length()));
+        }
       } else {
         docs.add(entry);
       }
@@ -451,31 +487,17 @@
     appendPluginInfoTable(md, scanner.getManifest().getMainAttributes());
 
     if (about != null) {
-      InputStreamReader isr = new InputStreamReader(scanner.getInputStream(about), UTF_8);
-      StringBuilder aboutContent = new StringBuilder();
-      try (BufferedReader reader = new BufferedReader(isr)) {
-        String line;
-        while ((line = reader.readLine()) != null) {
-          line = StringUtils.stripEnd(line, null);
-          if (line.isEmpty()) {
-            aboutContent.append("\n");
-          } else {
-            aboutContent.append(line).append("\n");
-          }
-        }
-      }
-
-      // Only append the About section if there was anything in it
-      if (aboutContent.toString().trim().length() > 0) {
-        md.append("## About ##\n");
-        md.append("\n").append(aboutContent);
-      }
+      appendPageAsSection(scanner, about, "About", md);
     }
 
-    appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
-    appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
-    appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
-    appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
+    if (toc != null) {
+      appendPageAsSection(scanner, toc, "Documentaion", md);
+    } else {
+      appendEntriesSection(scanner, docs, "Documentation", md, prefix, 0);
+      appendEntriesSection(scanner, servlets, "Servlets", md, prefix, "servlet-".length());
+      appendEntriesSection(scanner, restApis, "REST APIs", md, prefix, "rest-api-".length());
+      appendEntriesSection(scanner, cmds, "Commands", md, prefix, "cmd-".length());
+    }
 
     sendMarkdownAsHtml(md.toString(), pluginName, cacheKey, res, lastModifiedTime);
   }
diff --git a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
index 8d52f5a..445a73a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexHtmlUtil.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.config.Server;
+import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.json.OutputFormat;
@@ -90,13 +91,13 @@
     switch (page) {
       case CHANGE:
         data.put(
-            "defaultChangeDetailHex", IndexPreloadingUtil.getDefaultChangeDetailOptionsAsHex());
+            "defaultChangeDetailHex", ListOption.toHex(IndexPreloadingUtil.CHANGE_DETAIL_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
         break;
       case DIFF:
-        data.put("defaultDiffDetailHex", IndexPreloadingUtil.getDefaultDiffDetailOptionsAsHex());
+        data.put("defaultDiffDetailHex", ListOption.toHex(IndexPreloadingUtil.DIFF_OPTIONS));
         data.put(
             "changeRequestsPath",
             IndexPreloadingUtil.computeChangeRequestsPath(requestedPath, page).get());
@@ -120,7 +121,7 @@
           serializeObject(GSON, accountApi.getEditPreferences()));
       data.put("userIsAuthenticated", true);
       if (page == RequestedPage.DASHBOARD) {
-        data.put("defaultDashboardHex", IndexPreloadingUtil.getDefaultDashboardHex(serverApi));
+        data.put("defaultDashboardHex", ListOption.toHex(IndexPreloadingUtil.DASHBOARD_OPTIONS));
         data.put("dashboardQuery", IndexPreloadingUtil.computeDashboardQueryList(serverApi));
       }
     } catch (AuthException e) {
diff --git a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
index f1da6b7..3bdcb1a 100644
--- a/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
+++ b/java/com/google/gerrit/httpd/raw/IndexPreloadingUtil.java
@@ -24,16 +24,13 @@
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.ListChangesOption;
-import com.google.gerrit.extensions.client.ListOption;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.Url;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.util.ArrayList;
-import java.util.EnumSet;
 import java.util.List;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
@@ -69,7 +66,7 @@
       "is:open owner:${user} -is:wip -is:ignored limit:25";
   public static final String DASHBOARD_INCOMING_QUERY =
       "is:open -owner:${user} -is:wip -is:ignored (reviewer:${user} OR assignee:${user}) limit:25";
-  public static final String CC_QUERY = "is:open -is:ignored cc:${user} limit:10";
+  public static final String CC_QUERY = "is:open -is:ignored -is:wip cc:${user} limit:10";
   public static final String DASHBOARD_RECENTLY_CLOSED_QUERY =
       "is:closed -is:ignored (-is:wip OR owner:self) "
           + "(owner:${user} OR reviewer:${user} OR assignee:${user} "
@@ -91,43 +88,26 @@
               NEW_USER)
           .map(query -> query.replaceAll("\\$\\{user}", "self"))
           .collect(toImmutableList());
+  public static final ImmutableSet<ListChangesOption> DASHBOARD_OPTIONS =
+      ImmutableSet.of(ListChangesOption.LABELS, ListChangesOption.DETAILED_ACCOUNTS);
 
-  public static String getDefaultChangeDetailOptionsAsHex() {
-    Set<ListChangesOption> options =
-        ImmutableSet.of(
-            ListChangesOption.ALL_COMMITS,
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.CHANGE_ACTIONS,
-            ListChangesOption.DETAILED_LABELS,
-            ListChangesOption.DOWNLOAD_COMMANDS,
-            ListChangesOption.MESSAGES,
-            ListChangesOption.SUBMITTABLE,
-            ListChangesOption.WEB_LINKS,
-            ListChangesOption.SKIP_DIFFSTAT);
+  public static final ImmutableSet<ListChangesOption> CHANGE_DETAIL_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.CHANGE_ACTIONS,
+          ListChangesOption.DETAILED_LABELS,
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.MESSAGES,
+          ListChangesOption.SUBMITTABLE,
+          ListChangesOption.WEB_LINKS,
+          ListChangesOption.SKIP_DIFFSTAT);
 
-    return ListOption.toHex(options);
-  }
-
-  public static String getDefaultDiffDetailOptionsAsHex() {
-    Set<ListChangesOption> options =
-        ImmutableSet.of(
-            ListChangesOption.ALL_COMMITS,
-            ListChangesOption.ALL_REVISIONS,
-            ListChangesOption.SKIP_DIFFSTAT);
-
-    return ListOption.toHex(options);
-  }
-
-  public static String getDefaultDashboardHex(Server serverApi) throws RestApiException {
-    Set<ListChangesOption> options = EnumSet.noneOf(ListChangesOption.class);
-    options.add(ListChangesOption.LABELS);
-    options.add(ListChangesOption.DETAILED_ACCOUNTS);
-
-    if (!isEnabledAttentionSet(serverApi)) {
-      options.add(ListChangesOption.REVIEWED);
-    }
-    return ListOption.toHex(options);
-  }
+  public static final ImmutableSet<ListChangesOption> DIFF_OPTIONS =
+      ImmutableSet.of(
+          ListChangesOption.ALL_COMMITS,
+          ListChangesOption.ALL_REVISIONS,
+          ListChangesOption.SKIP_DIFFSTAT);
 
   public static String getPath(@Nullable String requestedURL) throws URISyntaxException {
     if (requestedURL == null) {
diff --git a/java/com/google/gerrit/httpd/raw/StaticModule.java b/java/com/google/gerrit/httpd/raw/StaticModule.java
index bb1eb92..aa32169 100644
--- a/java/com/google/gerrit/httpd/raw/StaticModule.java
+++ b/java/com/google/gerrit/httpd/raw/StaticModule.java
@@ -225,8 +225,7 @@
         @GerritServerConfig Config cfg,
         GerritApi gerritApi,
         ExperimentFeatures experimentFeatures) {
-      String cdnPath =
-          options.useDevCdn() ? options.devCdn() : cfg.getString("gerrit", null, "cdnPath");
+      String cdnPath = options.devCdn().orElse(cfg.getString("gerrit", null, "cdnPath"));
       String faviconPath = cfg.getString("gerrit", null, "faviconPath");
       return new IndexServlet(canonicalUrl, cdnPath, faviconPath, gerritApi, experimentFeatures);
     }
@@ -273,7 +272,7 @@
         if (warFs == null) {
           unpackedWar = makeWarTempDir();
           development = true;
-        } else if (options.useDevCdn()) {
+        } else if (options.devCdn().isPresent()) {
           unpackedWar = null;
           development = true;
         } else {
diff --git a/java/com/google/gerrit/httpd/restapi/ParameterParser.java b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
index 3ab409e..315c9c8 100644
--- a/java/com/google/gerrit/httpd/restapi/ParameterParser.java
+++ b/java/com/google/gerrit/httpd/restapi/ParameterParser.java
@@ -56,9 +56,18 @@
 
 public class ParameterParser {
   public static final String TRACE_PARAMETER = "trace";
+  public static final String EXPERIMENT_PARAMETER = "experiment";
 
   private static final ImmutableSet<String> RESERVED_KEYS =
-      ImmutableSet.of("pp", "prettyPrint", "strict", "callback", "alt", "fields", TRACE_PARAMETER);
+      ImmutableSet.of(
+          "pp",
+          "prettyPrint",
+          "strict",
+          "callback",
+          "alt",
+          "fields",
+          TRACE_PARAMETER,
+          EXPERIMENT_PARAMETER);
 
   @AutoValue
   public abstract static class QueryParams {
diff --git a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
index d224f9d..57dc9f3 100644
--- a/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
+++ b/java/com/google/gerrit/httpd/restapi/RestApiServlet.java
@@ -30,6 +30,7 @@
 import static com.google.common.net.HttpHeaders.CONTENT_TYPE;
 import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.net.HttpHeaders.VARY;
+import static com.google.gerrit.server.experiments.ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG;
 import static java.math.RoundingMode.CEILING;
 import static java.nio.charset.StandardCharsets.ISO_8859_1;
 import static java.nio.charset.StandardCharsets.UTF_8;
@@ -45,6 +46,7 @@
 import static javax.servlet.http.HttpServletResponse.SC_NOT_MODIFIED;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 import static javax.servlet.http.HttpServletResponse.SC_PRECONDITION_FAILED;
+import static javax.servlet.http.HttpServletResponse.SC_REQUEST_TIMEOUT;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
@@ -65,6 +67,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.RawInputUtil;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -96,21 +99,30 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.WebSession;
 import com.google.gerrit.httpd.restapi.ParameterParser.QueryParams;
 import com.google.gerrit.json.OutputFormat;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CancellationMetrics;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DeadlineChecker;
 import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.ExceptionHook;
+import com.google.gerrit.server.InvalidDeadlineException;
 import com.google.gerrit.server.OptionUtil;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.audit.ExtendedHttpAuditEvent;
 import com.google.gerrit.server.cache.PerThreadCache;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateContext;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
 import com.google.gerrit.server.change.ChangeFinder;
+import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
 import com.google.gerrit.server.group.GroupAuditService;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogContext;
@@ -200,7 +212,12 @@
 
   private static final String FORM_TYPE = "application/x-www-form-urlencoded";
 
+  @VisibleForTesting public static final String X_GERRIT_DEADLINE = "X-Gerrit-Deadline";
   @VisibleForTesting public static final String X_GERRIT_TRACE = "X-Gerrit-Trace";
+  @VisibleForTesting public static final String X_GERRIT_UPDATED_REF = "X-Gerrit-UpdatedRef";
+
+  @VisibleForTesting
+  public static final String X_GERRIT_UPDATED_REF_ENABLED = "X-Gerrit-UpdatedRef-Enabled";
 
   private static final String X_REQUESTED_WITH = "X-Requested-With";
   private static final String X_GERRIT_AUTH = "X-Gerrit-Auth";
@@ -216,6 +233,7 @@
   public static final String XD_METHOD = "$m";
   public static final int SC_UNPROCESSABLE_ENTITY = 422;
   public static final int SC_TOO_MANY_REQUESTS = 429;
+  public static final int SC_CLIENT_CLOSED_REQUEST = 499;
 
   private static final int HEAP_EST_SIZE = 10 * 8 * 1024; // Presize 10 blocks.
   private static final String PLAIN_TEXT = "text/plain";
@@ -252,6 +270,9 @@
     final PluginSetContext<ExceptionHook> exceptionHooks;
     final Injector injector;
     final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
+    final ExperimentFeatures experimentFeatures;
+    final DeadlineChecker.Factory deadlineCheckerFactory;
+    final CancellationMetrics cancellationMetrics;
 
     @Inject
     Globals(
@@ -269,7 +290,10 @@
         RetryHelper retryHelper,
         PluginSetContext<ExceptionHook> exceptionHooks,
         Injector injector,
-        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
+        DynamicMap<DynamicOptions.DynamicBean> dynamicBeans,
+        ExperimentFeatures experimentFeatures,
+        DeadlineChecker.Factory deadlineCheckerFactory,
+        CancellationMetrics cancellationMetrics) {
       this.currentUser = currentUser;
       this.webSession = webSession;
       this.paramParser = paramParser;
@@ -286,6 +310,9 @@
       allowOrigin = makeAllowOrigin(config);
       this.injector = injector;
       this.dynamicBeans = dynamicBeans;
+      this.experimentFeatures = experimentFeatures;
+      this.deadlineCheckerFactory = deadlineCheckerFactory;
+      this.cancellationMetrics = cancellationMetrics;
     }
 
     private static Pattern makeAllowOrigin(Config cfg) {
@@ -332,9 +359,10 @@
     ViewData viewData = null;
 
     try (TraceContext traceContext = enableTracing(req, res)) {
-      List<IdString> path = splitPath(req);
+      String requestUri = requestUri(req);
 
       try (PerThreadCache ignored = PerThreadCache.create(req)) {
+        List<IdString> path = splitPath(req);
         RequestInfo requestInfo = createRequestInfo(traceContext, requestUri(req), path);
         globals.requestListeners.runEach(l -> l.onRequest(requestInfo));
 
@@ -343,8 +371,13 @@
         // plugins happens before the client sees the response. This is needed for being able to
         // test performance logging from an acceptance test (see
         // TraceIT#performanceLoggingForRestCall()).
-        try (PerformanceLogContext performanceLogContext =
-            new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
+        try (RequestStateContext requestStateContext =
+                RequestStateContext.open()
+                    .addRequestStateProvider(
+                        globals.deadlineCheckerFactory.create(
+                            requestInfo, req.getHeader(X_GERRIT_DEADLINE)));
+            PerformanceLogContext performanceLogContext =
+                new PerformanceLogContext(globals.config, globals.performanceLoggers)) {
           traceRequestData(req);
 
           if (isCorsPreflight(req)) {
@@ -586,6 +619,11 @@
             } else {
               throw new ResourceNotFoundException();
             }
+            String isUpdatedRefEnabled = req.getHeader(X_GERRIT_UPDATED_REF_ENABLED);
+            if (!Strings.isNullOrEmpty(isUpdatedRefEnabled)
+                && Boolean.valueOf(isUpdatedRefEnabled)) {
+              setXGerritUpdatedRefResponseHeaders(req, res);
+            }
 
             if (response instanceof Response.Redirect) {
               CacheHeaders.setNotCacheable(res);
@@ -692,31 +730,51 @@
                 messageOr(e, "Quota limit reached"),
                 e.caching(),
                 e);
+      } catch (InvalidDeadlineException e) {
+        cause = Optional.of(e);
+        responseBytes =
+            replyError(req, res, statusCode = SC_BAD_REQUEST, messageOr(e, "Bad Request"), e);
       } catch (Exception e) {
         cause = Optional.of(e);
-        statusCode = SC_INTERNAL_SERVER_ERROR;
 
-        Optional<ExceptionHook.Status> status = getStatus(e);
-        statusCode = status.map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
-
-        if (res.isCommitted()) {
-          responseBytes = 0;
-          if (statusCode == SC_INTERNAL_SERVER_ERROR) {
-            logger.atSevere().withCause(e).log(
-                "Error in %s %s, response already committed", req.getMethod(), uriForLogging(req));
-          } else {
-            logger.atWarning().log(
-                "Response for %s %s already committed, wanted to set status %d",
-                req.getMethod(), uriForLogging(req), statusCode);
-          }
+        Optional<RequestCancelledException> requestCancelledException =
+            RequestCancelledException.getFromCausalChain(e);
+        if (requestCancelledException.isPresent()) {
+          RequestStateProvider.Reason cancellationReason =
+              requestCancelledException.get().getCancellationReason();
+          globals.cancellationMetrics.countCancelledRequest(
+              RequestInfo.RequestType.REST, requestUri, cancellationReason);
+          statusCode = getCancellationStatusCode(cancellationReason);
+          responseBytes =
+              replyError(
+                  req, res, statusCode, getCancellationMessage(requestCancelledException.get()), e);
         } else {
-          res.reset();
-          traceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+          statusCode = SC_INTERNAL_SERVER_ERROR;
 
-          if (status.isPresent()) {
-            responseBytes = reply(req, res, e, status.get(), getUserMessages(traceContext, e));
+          Optional<ExceptionHook.Status> status = getStatus(e);
+          statusCode =
+              status.map(ExceptionHook.Status::statusCode).orElse(SC_INTERNAL_SERVER_ERROR);
+
+          if (res.isCommitted()) {
+            responseBytes = 0;
+            if (statusCode == SC_INTERNAL_SERVER_ERROR) {
+              logger.atSevere().withCause(e).log(
+                  "Error in %s %s, response already committed",
+                  req.getMethod(), uriForLogging(req));
+            } else {
+              logger.atWarning().log(
+                  "Response for %s %s already committed, wanted to set status %d",
+                  req.getMethod(), uriForLogging(req), statusCode);
+            }
           } else {
-            responseBytes = replyInternalServerError(req, res, e, getUserMessages(traceContext, e));
+            res.reset();
+            TraceContext.getTraceId().ifPresent(traceId -> res.addHeader(X_GERRIT_TRACE, traceId));
+
+            if (status.isPresent()) {
+              responseBytes = reply(req, res, e, status.get(), getUserMessages(e));
+            } else {
+              responseBytes = replyInternalServerError(req, res, e, getUserMessages(e));
+            }
           }
         }
       } finally {
@@ -747,6 +805,33 @@
     }
   }
 
+  /**
+   * Fill in the refs that were updated during this request in the response header. The updated refs
+   * will be in the form of "project~ref~updated_SHA-1".
+   */
+  private void setXGerritUpdatedRefResponseHeaders(
+      HttpServletRequest request, HttpServletResponse response) {
+    for (GitReferenceUpdatedListener.Event refUpdate :
+        globals.webSession.get().getRefUpdatedEvents()) {
+      String refUpdateFormat =
+          String.format(
+              "%s~%s~%s~%s",
+              // encode the project and ref names since they may contain `~`
+              Url.encode(refUpdate.getProjectName()),
+              Url.encode(refUpdate.getRefName()),
+              refUpdate.getOldObjectId(),
+              refUpdate.getNewObjectId());
+
+      if (isRead(request)) {
+        logger.atWarning().log(
+            "request %s performed a ref update %s although the request is a READ request",
+            request.getRequestURL().toString(), refUpdateFormat);
+      }
+      response.addHeader(X_GERRIT_UPDATED_REF, refUpdateFormat);
+    }
+    globals.webSession.get().resetRefUpdatedEvents();
+  }
+
   private String getEtagWithRetry(
       HttpServletRequest req,
       TraceContext traceContext,
@@ -775,6 +860,11 @@
         TraceContext.newTimer(
             "RestApiServlet#getEtagWithRetry:resource",
             Metadata.builder().restViewName(rsrc.getClass().getSimpleName()).build())) {
+      if (rsrc instanceof RevisionResource
+          && globals.experimentFeatures.isFeatureEnabled(
+              GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG)) {
+        return null;
+      }
       return invokeRestEndpointWithRetry(
           req,
           traceContext,
@@ -893,7 +983,7 @@
       throws Exception {
     RetryableAction<T> retryableAction = globals.retryHelper.action(actionType, caller, action);
     AtomicReference<Optional<String>> traceId = new AtomicReference<>(Optional.empty());
-    if (!traceContext.isTracing()) {
+    if (!TraceContext.isTracing()) {
       // enable automatic retry with tracing in case of non-recoverable failure
       retryableAction
           .retryWithTrace(t -> !(t instanceof RestApiException))
@@ -1056,7 +1146,7 @@
 
     if (rsrc instanceof RestResource.HasETag) {
       String have = req.getHeader(HttpHeaders.IF_NONE_MATCH);
-      if (have != null) {
+      if (!Strings.isNullOrEmpty(have)) {
         String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
         return have.equals(eTag);
       }
@@ -1134,7 +1224,9 @@
       res.setHeader(HttpHeaders.ETAG, eTag);
     } else if (rsrc instanceof RestResource.HasETag) {
       String eTag = getEtagWithRetry(req, traceContext, (RestResource.HasETag) rsrc);
-      res.setHeader(HttpHeaders.ETAG, eTag);
+      if (!Strings.isNullOrEmpty(eTag)) {
+        res.setHeader(HttpHeaders.ETAG, eTag);
+      }
     }
     if (rsrc instanceof RestResource.HasLastModified) {
       res.setDateHeader(
@@ -1334,7 +1426,6 @@
    * @param config config parameters for the JSON formatting
    * @param result the object that should be formatted as JSON
    * @return the length of the response
-   * @throws IOException
    */
   public static long replyJson(
       @Nullable HttpServletRequest req,
@@ -1724,6 +1815,10 @@
     logger.atFinest().log(
         "Received REST request: %s %s (parameters: %s)",
         req.getMethod(), req.getRequestURI(), getParameterNames(req));
+    Optional.ofNullable(req.getHeader(X_GERRIT_DEADLINE))
+        .ifPresent(
+            clientProvidedDeadline ->
+                logger.atFine().log("%s = %s", X_GERRIT_DEADLINE, clientProvidedDeadline));
     logger.atFinest().log("Calling user: %s", globals.currentUser.get().getLoggableName());
     logger.atFinest().log(
         "Groups: %s", lazy(() -> globals.currentUser.get().getEffectiveGroups().getKnownGroups()));
@@ -1779,9 +1874,9 @@
         .findFirst();
   }
 
-  private ImmutableList<String> getUserMessages(TraceContext traceContext, Throwable err) {
+  private ImmutableList<String> getUserMessages(Throwable err) {
     return globals.exceptionHooks.stream()
-        .flatMap(h -> h.getUserMessages(err, traceContext.getTraceId().orElse(null)).stream())
+        .flatMap(h -> h.getUserMessages(err, TraceContext.getTraceId().orElse(null)).stream())
         .collect(toImmutableList());
   }
 
@@ -1874,7 +1969,6 @@
    *     set to {@code true} if the reply may contain sensitive data
    * @param text the text reply
    * @return the length of the response
-   * @throws IOException
    */
   static long replyText(
       @Nullable HttpServletRequest req, HttpServletResponse res, boolean allowTracing, String text)
@@ -1888,6 +1982,28 @@
     return replyBinaryResult(req, res, BinaryResult.create(text).setContentType(PLAIN_TEXT));
   }
 
+  private static int getCancellationStatusCode(RequestStateProvider.Reason cancellationReason) {
+    switch (cancellationReason) {
+      case CLIENT_CLOSED_REQUEST:
+        return SC_CLIENT_CLOSED_REQUEST;
+      case CLIENT_PROVIDED_DEADLINE_EXCEEDED:
+      case SERVER_DEADLINE_EXCEEDED:
+        return SC_REQUEST_TIMEOUT;
+    }
+    logger.atSevere().log("Unexpected cancellation reason: %s", cancellationReason);
+    return SC_INTERNAL_SERVER_ERROR;
+  }
+
+  private static String getCancellationMessage(
+      RequestCancelledException requestCancelledException) {
+    StringBuilder msg = new StringBuilder(requestCancelledException.formatCancellationReason());
+    if (requestCancelledException.getCancellationMessage().isPresent()) {
+      msg.append("\n\n");
+      msg.append(requestCancelledException.getCancellationMessage().get());
+    }
+    return msg.toString();
+  }
+
   private static boolean acceptsGzip(HttpServletRequest req) {
     if (req != null) {
       String accepts = req.getHeader(HttpHeaders.ACCEPT_ENCODING);
diff --git a/java/com/google/gerrit/index/FieldDef.java b/java/com/google/gerrit/index/FieldDef.java
index 63f6887..76aa7cc 100644
--- a/java/com/google/gerrit/index/FieldDef.java
+++ b/java/com/google/gerrit/index/FieldDef.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.index;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.CharMatcher;
@@ -22,6 +23,7 @@
 import com.google.gerrit.exceptions.StorageException;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.Optional;
 
 /**
  * Definition of a field stored in the secondary index.
@@ -65,6 +67,11 @@
     T get(I input) throws IOException;
   }
 
+  @FunctionalInterface
+  public interface Setter<I, T> {
+    void set(I object, T value);
+  }
+
   public static class Builder<T> {
     private final FieldType<T> type;
     private final String name;
@@ -81,11 +88,20 @@
     }
 
     public <I> FieldDef<I, T> build(Getter<I, T> getter) {
-      return new FieldDef<>(name, type, stored, false, getter);
+      return new FieldDef<>(name, type, stored, false, getter, null);
+    }
+
+    public <I> FieldDef<I, T> build(Getter<I, T> getter, Setter<I, T> setter) {
+      return new FieldDef<>(name, type, stored, false, getter, setter);
     }
 
     public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
-      return new FieldDef<>(name, type, stored, true, getter);
+      return new FieldDef<>(name, type, stored, true, getter, null);
+    }
+
+    public <I> FieldDef<I, Iterable<T>> buildRepeatable(
+        Getter<I, Iterable<T>> getter, Setter<I, Iterable<T>> setter) {
+      return new FieldDef<>(name, type, stored, true, getter, setter);
     }
   }
 
@@ -96,9 +112,15 @@
 
   private final boolean repeatable;
   private final Getter<I, T> getter;
+  private final Optional<Setter<I, T>> setter;
 
   private FieldDef(
-      String name, FieldType<?> type, boolean stored, boolean repeatable, Getter<I, T> getter) {
+      String name,
+      FieldType<?> type,
+      boolean stored,
+      boolean repeatable,
+      Getter<I, T> getter,
+      @Nullable Setter<I, T> setter) {
     checkArgument(
         !(repeatable && type == FieldType.INTEGER_RANGE),
         "Range queries against repeated fields are unsupported");
@@ -107,6 +129,7 @@
     this.stored = stored;
     this.repeatable = repeatable;
     this.getter = requireNonNull(getter);
+    this.setter = Optional.ofNullable(setter);
   }
 
   private static String checkName(String name) {
@@ -115,17 +138,17 @@
     return name;
   }
 
-  /** @return name of the field. */
+  /** Returns name of the field. */
   public String getName() {
     return name;
   }
 
-  /** @return type of the field; for repeatable fields, the inner type, not the iterable type. */
+  /** Returns type of the field; for repeatable fields, the inner type, not the iterable type. */
   public FieldType<?> getType() {
     return type;
   }
 
-  /** @return whether the field should be stored in the index. */
+  /** Returns whether the field should be stored in the index. */
   public boolean isStored() {
     return stored;
   }
@@ -145,7 +168,42 @@
     }
   }
 
-  /** @return whether the field is repeatable. */
+  /**
+   * Set the field contents back to an object. Used to reconstruct fields from indexed values. No-op
+   * if the field can't be reconstructed.
+   *
+   * @param object input object.
+   * @param doc indexed document
+   * @return {@code true} if the field was set, {@code false} otherwise
+   */
+  @SuppressWarnings("unchecked")
+  public boolean setIfPossible(I object, StoredValue doc) {
+    if (!setter.isPresent()) {
+      return false;
+    }
+
+    if (FieldType.STRING_TYPES.stream().anyMatch(t -> t.getName().equals(getType().getName()))) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asStrings() : doc.asString()));
+      return true;
+    } else if (FieldType.INTEGER_TYPES.stream()
+        .anyMatch(t -> t.getName().equals(getType().getName()))) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asIntegers() : doc.asInteger()));
+      return true;
+    } else if (FieldType.LONG.getName().equals(getType().getName())) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asLongs() : doc.asLong()));
+      return true;
+    } else if (FieldType.STORED_ONLY.getName().equals(getType().getName())) {
+      setter.get().set(object, (T) (isRepeatable() ? doc.asByteArrays() : doc.asByteArray()));
+      return true;
+    } else if (FieldType.TIMESTAMP.getName().equals(getType().getName())) {
+      checkState(!isRepeatable(), "can't repeat timestamp values");
+      setter.get().set(object, (T) doc.asTimestamp());
+      return true;
+    }
+    return false;
+  }
+
+  /** Returns whether the field is repeatable. */
   public boolean isRepeatable() {
     return repeatable;
   }
diff --git a/java/com/google/gerrit/index/FieldType.java b/java/com/google/gerrit/index/FieldType.java
index 0db0284..c4c55f23 100644
--- a/java/com/google/gerrit/index/FieldType.java
+++ b/java/com/google/gerrit/index/FieldType.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.index;
 
+import com.google.common.collect.ImmutableList;
 import java.sql.Timestamp;
 
 /** Document field types supported by the secondary index system. */
@@ -42,6 +43,14 @@
   /** A field that is only stored as raw bytes and cannot be queried. */
   public static final FieldType<byte[]> STORED_ONLY = new FieldType<>("STORED_ONLY");
 
+  /** List of all types that are stored as {@link String} in the index. */
+  public static final ImmutableList<FieldType<String>> STRING_TYPES =
+      ImmutableList.of(EXACT, PREFIX, FULL_TEXT);
+
+  /** List of all types that are stored as {@link Integer} in the index. */
+  public static final ImmutableList<FieldType<Integer>> INTEGER_TYPES =
+      ImmutableList.of(INTEGER_RANGE, INTEGER);
+
   private final String name;
 
   private FieldType(String name) {
diff --git a/java/com/google/gerrit/index/Index.java b/java/com/google/gerrit/index/Index.java
index e662bc8..cc3117d 100644
--- a/java/com/google/gerrit/index/Index.java
+++ b/java/com/google/gerrit/index/Index.java
@@ -33,7 +33,7 @@
  * <p>Implementations must be thread-safe and should batch inserts/updates where appropriate.
  */
 public interface Index<K, V> {
-  /** @return the schema version used by this index. */
+  /** Returns the schema version used by this index. */
   Schema<V> getSchema();
 
   /** Close this index. */
@@ -145,4 +145,12 @@
    * @param ready whether the index is ready
    */
   void markReady(boolean ready);
+
+  /**
+   * Returns whether the index is enabled. {@code true} by default, but could be overridden by
+   * implementations.
+   */
+  default boolean isEnabled() {
+    return true;
+  }
 }
diff --git a/java/com/google/gerrit/index/IndexConfig.java b/java/com/google/gerrit/index/IndexConfig.java
index 29b8ea6..8676fb2 100644
--- a/java/com/google/gerrit/index/IndexConfig.java
+++ b/java/com/google/gerrit/index/IndexConfig.java
@@ -101,27 +101,27 @@
   }
 
   /**
-   * @return maximum limit supported by the underlying index, or limited for performance reasons.
+   * Returns maximum limit supported by the underlying index, or limited for performance reasons.
    */
   public abstract int maxLimit();
 
   /**
-   * @return maximum number of pages (limit / start) supported by the underlying index, or limited
-   *     for performance reasons.
+   * Returns maximum number of pages (limit / start) supported by the underlying index, or limited
+   * for performance reasons.
    */
   public abstract int maxPages();
 
   /**
-   * @return maximum number of total index query terms supported by the underlying index, or limited
-   *     for performance reasons.
+   * Returns maximum number of total index query terms supported by the underlying index, or limited
+   * for performance reasons.
    */
   public abstract int maxTerms();
 
-  /** @return index type. */
+  /** Returns index type. */
   public abstract String type();
 
   /**
-   * @return whether different subsets of changes may be stored in different physical sub-indexes.
+   * Returns whether different subsets of changes may be stored in different physical sub-indexes.
    */
   public abstract boolean separateChangeSubIndexes();
 }
diff --git a/java/com/google/gerrit/index/IndexType.java b/java/com/google/gerrit/index/IndexType.java
index ee44deb..75f8351 100644
--- a/java/com/google/gerrit/index/IndexType.java
+++ b/java/com/google/gerrit/index/IndexType.java
@@ -14,24 +14,58 @@
 
 package com.google.gerrit.index;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import java.util.Optional;
 
 /**
  * Index types supported by the secondary index.
  *
- * <p>The explicitly known index types are Lucene (the default) and Elasticsearch.
+ * <p>The explicitly known index types are Lucene (the default) and a fake index used in tests.
  *
  * <p>The third supported index type is any other type String value, deemed as custom. This is for
  * configuring index types that are internal or not to be disclosed. Supporting custom index types
  * allows to not break that case upon core implementation changes.
  */
 public class IndexType {
+  public static final String SYS_PROP = "gerrit.index.type";
+  private static final String ENV_VAR = "GERRIT_INDEX_TYPE";
+
   private static final String LUCENE = "lucene";
-  private static final String ELASTICSEARCH = "elasticsearch";
+  private static final String FAKE = "fake";
 
   private final String type;
 
+  /**
+   * Returns the index type in case it was set by an environment variable. This is useful to run
+   * tests against a certain index backend.
+   */
+  public static Optional<IndexType> fromEnvironment() {
+    String value = System.getenv(ENV_VAR);
+    if (Strings.isNullOrEmpty(value)) {
+      value = System.getProperty(SYS_PROP);
+    }
+    if (Strings.isNullOrEmpty(value)) {
+      return Optional.empty();
+    }
+    value = value.toUpperCase().replace("-", "_");
+    IndexType type = new IndexType(value);
+    if (!Strings.isNullOrEmpty(System.getenv(ENV_VAR))) {
+      checkArgument(
+          type != null, "Invalid value for env variable %s: %s", ENV_VAR, System.getenv(ENV_VAR));
+    } else {
+      checkArgument(
+          type != null,
+          "Invalid value for system property %s: %s",
+          SYS_PROP,
+          System.getProperty(SYS_PROP));
+    }
+    return Optional.of(type);
+  }
+
   public IndexType(@Nullable String type) {
     this.type = type == null ? getDefault() : type.toLowerCase();
   }
@@ -41,15 +75,15 @@
   }
 
   public static ImmutableSet<String> getKnownTypes() {
-    return ImmutableSet.of(LUCENE, ELASTICSEARCH);
+    return ImmutableSet.of(LUCENE, FAKE);
   }
 
   public boolean isLucene() {
     return type.equals(LUCENE);
   }
 
-  public boolean isElasticsearch() {
-    return type.equals(ELASTICSEARCH);
+  public boolean isFake() {
+    return type.equals(FAKE);
   }
 
   @Override
diff --git a/java/com/google/gerrit/index/Schema.java b/java/com/google/gerrit/index/Schema.java
index 3aa9de0..91c3f70 100644
--- a/java/com/google/gerrit/index/Schema.java
+++ b/java/com/google/gerrit/index/Schema.java
@@ -134,7 +134,7 @@
     return fields;
   }
 
-  /** @return all fields in this schema where {@link FieldDef#isStored()} is true. */
+  /** Returns all fields in this schema where {@link FieldDef#isStored()} is true. */
   public final ImmutableMap<String, FieldDef<T, ?>> getStoredFields() {
     return storedFields;
   }
diff --git a/java/com/google/gerrit/index/StoredValue.java b/java/com/google/gerrit/index/StoredValue.java
new file mode 100644
index 0000000..fe790c5
--- /dev/null
+++ b/java/com/google/gerrit/index/StoredValue.java
@@ -0,0 +1,50 @@
+// 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.index;
+
+import java.sql.Timestamp;
+
+/**
+ * Representation of a field stored on the index. Used to load field values from different index
+ * backends.
+ */
+public interface StoredValue {
+  /** Returns the {@link String} value of the field. */
+  String asString();
+
+  /** Returns the {@link String} values of the field. */
+  Iterable<String> asStrings();
+
+  /** Returns the {@link Integer} value of the field. */
+  Integer asInteger();
+
+  /** Returns the {@link Integer} values of the field. */
+  Iterable<Integer> asIntegers();
+
+  /** Returns the {@link Long} value of the field. */
+  Long asLong();
+
+  /** Returns the {@link Long} values of the field. */
+  Iterable<Long> asLongs();
+
+  /** Returns the {@link Timestamp} value of the field. */
+  Timestamp asTimestamp();
+
+  /** Returns the {@code byte[]} value of the field. */
+  byte[] asByteArray();
+
+  /** Returns the {@code byte[]} values of the field. */
+  Iterable<byte[]> asByteArrays();
+}
diff --git a/java/com/google/gerrit/index/query/DataSource.java b/java/com/google/gerrit/index/query/DataSource.java
index 2c2ba53..518d153 100644
--- a/java/com/google/gerrit/index/query/DataSource.java
+++ b/java/com/google/gerrit/index/query/DataSource.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.index.query;
 
 public interface DataSource<T> {
-  /** @return an estimate of the number of results from {@link #read()}. */
+  /** Returns an estimate of the number of results from {@link #read()}. */
   int getCardinality();
 
-  /** @return read from the database and return the results. */
+  /** Returns read from the database and return the results. */
   ResultSet<T> read();
 
-  /** @return read from the database and return the raw results. */
+  /** Returns read from the database and return the raw results. */
   ResultSet<FieldBundle> readRaw();
 }
diff --git a/java/com/google/gerrit/index/query/IndexPredicate.java b/java/com/google/gerrit/index/query/IndexPredicate.java
index aac6682..18d7fbc 100644
--- a/java/com/google/gerrit/index/query/IndexPredicate.java
+++ b/java/com/google/gerrit/index/query/IndexPredicate.java
@@ -14,11 +14,29 @@
 
 package com.google.gerrit.index.query;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.base.CharMatcher;
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.primitives.Ints;
+import com.google.common.primitives.Longs;
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.FieldType;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.StreamSupport;
 
 /** Predicate that is mapped to a field in the index. */
-public abstract class IndexPredicate<I> extends OperatorPredicate<I> {
+public abstract class IndexPredicate<I> extends OperatorPredicate<I> implements Matchable<I> {
+  /**
+   * Text segmentation to be applied to both the query string and the indexed field for full-text
+   * queries. This is inspired by http://unicode.org/reports/tr29/ which is what Lucene uses, but
+   * complexity was reduced to the bare minimum at the cost of small discrepancies to the Unicode
+   * spec.
+   */
+  private static final Splitter FULL_TEXT_SPLITTER = Splitter.on(CharMatcher.anyOf(" ,.-:\\/_\n"));
+
   private final FieldDef<I, ?> def;
 
   protected IndexPredicate(FieldDef<I, ?> def, String value) {
@@ -38,4 +56,69 @@
   public FieldType<?> getType() {
     return def.getType();
   }
+
+  /**
+   * This method matches documents without calling an index subsystem. For primitive fields (e.g.
+   * integer, long) , the matching logic is consistent across this method and all known index
+   * implementations. For text fields (i.e. prefix and full-text) the semantics vary between this
+   * implementation and known index implementations:
+   * <li>Prefix: Lucene as well as {@link #match(Object)} matches terms as true prefixes (prefix:foo
+   *     -> `foo bar` matches, but `baz foo bar` does not match). The index implementation at Google
+   *     tokenizes both the query and the indexed text and matches tokens individually (prefix:fo ba
+   *     -> `baz foo bar` matches).
+   * <li>Full text: Lucene uses a {@code PhraseQuery} to search for terms in full text fields
+   *     in-order. The index implementation at Google as well as {@link #match(Object)} tokenizes
+   *     both the query and the indexed text and matches tokens individually.
+   *
+   * @return true if the predicate matches the provided {@code I}.
+   */
+  @Override
+  public boolean match(I doc) {
+    if (getField().isRepeatable()) {
+      Iterable<?> values = (Iterable<?>) getField().get(doc);
+      for (Object v : values) {
+        if (matchesSingleObject(v)) {
+          return true;
+        }
+      }
+      return false;
+    }
+    return matchesSingleObject(getField().get(doc));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+
+  private boolean matchesSingleObject(Object fieldValueFromObject) {
+    String fieldTypeName = getField().getType().getName();
+    if (fieldTypeName.equals(FieldType.INTEGER.getName())) {
+      return Objects.equals(fieldValueFromObject, Ints.tryParse(value));
+    } else if (fieldTypeName.equals(FieldType.EXACT.getName())) {
+      return Objects.equals(fieldValueFromObject, value);
+    } else if (fieldTypeName.equals(FieldType.LONG.getName())) {
+      return Objects.equals(fieldValueFromObject, Longs.tryParse(value));
+    } else if (fieldTypeName.equals(FieldType.PREFIX.getName())) {
+      return String.valueOf(fieldValueFromObject).startsWith(value);
+    } else if (fieldTypeName.equals(FieldType.FULL_TEXT.getName())) {
+      Set<String> tokenizedField = tokenizeString(String.valueOf(fieldValueFromObject));
+      Set<String> tokenizedValue = tokenizeString(value);
+      return !tokenizedValue.isEmpty() && tokenizedField.containsAll(tokenizedValue);
+    } else if (fieldTypeName.equals(FieldType.STORED_ONLY.getName())) {
+      throw new IllegalStateException("can't filter for storedOnly field " + getField().getName());
+    } else if (fieldTypeName.equals(FieldType.TIMESTAMP.getName())) {
+      throw new IllegalStateException("timestamp queries must be handled in subclasses");
+    } else if (fieldTypeName.equals(FieldType.INTEGER_RANGE.getName())) {
+      throw new IllegalStateException("integer range queries must be handled in subclasses");
+    } else {
+      throw new IllegalStateException("unrecognized field " + fieldTypeName);
+    }
+  }
+
+  private static ImmutableSet<String> tokenizeString(String value) {
+    return StreamSupport.stream(FULL_TEXT_SPLITTER.split(value.toLowerCase()).spliterator(), false)
+        .filter(s -> !s.trim().isEmpty())
+        .collect(toImmutableSet());
+  }
 }
diff --git a/java/com/google/gerrit/index/query/IntegerRangePredicate.java b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
index 6780867..850c4a5 100644
--- a/java/com/google/gerrit/index/query/IntegerRangePredicate.java
+++ b/java/com/google/gerrit/index/query/IntegerRangePredicate.java
@@ -31,6 +31,7 @@
 
   protected abstract Integer getValueInt(T object);
 
+  @Override
   public boolean match(T object) {
     Integer valueInt = getValueInt(object);
     if (valueInt == null) {
diff --git a/java/com/google/gerrit/index/query/InternalQuery.java b/java/com/google/gerrit/index/query/InternalQuery.java
index 48e214e..5c003bc 100644
--- a/java/com/google/gerrit/index/query/InternalQuery.java
+++ b/java/com/google/gerrit/index/query/InternalQuery.java
@@ -42,7 +42,7 @@
  */
 public class InternalQuery<T, Q extends InternalQuery<T, Q>> {
   private final QueryProcessor<T> queryProcessor;
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+  protected final IndexCollection<?, T, ? extends Index<?, T>> indexes;
 
   protected final IndexConfig indexConfig;
 
diff --git a/java/com/google/gerrit/index/query/Matchable.java b/java/com/google/gerrit/index/query/Matchable.java
index 7a16ae8..f416149 100644
--- a/java/com/google/gerrit/index/query/Matchable.java
+++ b/java/com/google/gerrit/index/query/Matchable.java
@@ -18,6 +18,6 @@
   /** Does this predicate match this object? */
   boolean match(T object);
 
-  /** @return a cost estimate to run this predicate, higher figures cost more. */
+  /** Returns a cost estimate to run this predicate, higher figures cost more. */
   int getCost();
 }
diff --git a/java/com/google/gerrit/index/query/Predicate.java b/java/com/google/gerrit/index/query/Predicate.java
index b5ed82d..9dc7689 100644
--- a/java/com/google/gerrit/index/query/Predicate.java
+++ b/java/com/google/gerrit/index/query/Predicate.java
@@ -18,9 +18,12 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.collect.Iterables;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.LinkedList;
 import java.util.List;
+import java.util.Queue;
 
 /**
  * An abstract predicate tree for any form of query.
@@ -41,6 +44,32 @@
  * @param <T> type of object the predicate can evaluate in memory.
  */
 public abstract class Predicate<T> {
+  /** Query String that was used to create this predicate. Only set from the Antlr query parser. */
+  private String predicateString = null;
+
+  /**
+   * Boolean indicating if this predicate is a leaf predicate in a composite expression. Only set
+   * from the Antlr query parser.
+   */
+  private boolean isLeaf = false;
+
+  /** Sets the {@link #predicateString} field. This can only be set once. */
+  void setPredicateString(String predicateString) {
+    this.predicateString = this.predicateString == null ? predicateString : this.predicateString;
+  }
+
+  public String getPredicateString() {
+    return predicateString;
+  }
+
+  void setLeaf(boolean isLeaf) {
+    this.isLeaf = isLeaf;
+  }
+
+  public boolean isLeaf() {
+    return isLeaf;
+  }
+
   /** A predicate that matches any input, always, with no cost. */
   @SuppressWarnings("unchecked")
   public static <T> Predicate<T> any() {
@@ -120,6 +149,19 @@
     return leafCount;
   }
 
+  /** Returns a list of this predicate and all its descendants. */
+  public List<Predicate<T>> getFlattenedPredicateList() {
+    List<Predicate<T>> result = new ArrayList<>();
+    Queue<Predicate<T>> queue = new LinkedList<>();
+    queue.add(this);
+    while (!queue.isEmpty()) {
+      Predicate<T> current = queue.poll();
+      result.add(current);
+      current.getChildren().forEach(p -> queue.add(p));
+    }
+    return result;
+  }
+
   /** Create a copy of this predicate, with new children. */
   public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
 
@@ -127,13 +169,18 @@
     return this instanceof Matchable;
   }
 
+  /** Whether this predicate can be used in search queries. */
+  public boolean supportedForQueries() {
+    return true;
+  }
+
   @SuppressWarnings("unchecked")
   public Matchable<T> asMatchable() {
     checkState(isMatchable(), "not matchable");
     return (Matchable<T>) this;
   }
 
-  /** @return a cost estimate to run this predicate, higher figures cost more. */
+  /** Returns a cost estimate to run this predicate, higher figures cost more. */
   public int estimateCost() {
     if (!isMatchable()) {
       return 1;
diff --git a/java/com/google/gerrit/index/query/QueryBuilder.java b/java/com/google/gerrit/index/query/QueryBuilder.java
index 85dcf3e..ffa7ce4 100644
--- a/java/com/google/gerrit/index/query/QueryBuilder.java
+++ b/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -46,6 +46,9 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import org.antlr.runtime.CharStream;
+import org.antlr.runtime.CommonToken;
+import org.antlr.runtime.tree.CommonTree;
 import org.antlr.runtime.tree.Tree;
 
 /**
@@ -246,23 +249,62 @@
   }
 
   private Predicate<T> toPredicate(Tree r) throws QueryParseException, IllegalArgumentException {
+    Predicate<T> result;
     switch (r.getType()) {
       case AND:
-        return and(children(r));
+        result = and(children(r));
+        break;
       case OR:
-        return or(children(r));
+        result = or(children(r));
+        break;
       case NOT:
-        return not(toPredicate(onlyChildOf(r)));
+        result = not(toPredicate(onlyChildOf(r)));
+        break;
 
       case DEFAULT_FIELD:
-        return defaultField(concatenateChildText(r));
+        result = defaultField(concatenateChildText(r));
+        break;
 
       case FIELD_NAME:
-        return operator(r.getText(), concatenateChildText(r));
+        result = operator(r.getText(), concatenateChildText(r));
+        break;
 
       default:
         throw error("Unsupported operator: " + r);
     }
+    result.setPredicateString(getPredicateString(r));
+    return result;
+  }
+
+  /**
+   * Reconstruct the query sub-expression that was passed as input to the query parser from the tree
+   * input parameter.
+   */
+  private static String getPredicateString(Tree r) {
+    CommonTree ct = (CommonTree) r;
+    CommonToken token = (CommonToken) ct.getToken();
+    CharStream inputStream = token.getInputStream();
+    int leftIdx = getLeftIndex(r);
+    int rightIdx = getRightIndex(r);
+    if (inputStream == null) {
+      return "";
+    }
+    return inputStream.substring(leftIdx, rightIdx);
+  }
+
+  private static int getLeftIndex(Tree r) {
+    CommonTree ct = (CommonTree) r;
+    CommonToken token = (CommonToken) ct.getToken();
+    return token.getStartIndex();
+  }
+
+  private static int getRightIndex(Tree r) {
+    CommonTree ct = (CommonTree) r;
+    if (ct.getChildCount() == 0) {
+      CommonToken token = (CommonToken) ct.getToken();
+      return token.getStopIndex();
+    }
+    return getRightIndex(ct.getChild(ct.getChildCount() - 1));
   }
 
   private static String concatenateChildText(Tree r) throws QueryParseException {
@@ -367,7 +409,10 @@
     @Override
     public Predicate<T> create(Q builder, String value) throws QueryParseException {
       try {
-        return (Predicate<T>) method.invoke(builder, value);
+        Predicate<T> predicate = (Predicate<T>) method.invoke(builder, value);
+        // All operator predicates are leaf predicates.
+        predicate.setLeaf(true);
+        return predicate;
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
diff --git a/java/com/google/gerrit/index/query/QueryProcessor.java b/java/com/google/gerrit/index/query/QueryProcessor.java
index c05516b..ea23d91 100644
--- a/java/com/google/gerrit/index/query/QueryProcessor.java
+++ b/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -227,6 +227,7 @@
       List<DataSource<T>> sources = new ArrayList<>(cnt);
       int queryCount = 0;
       for (Predicate<T> q : queries) {
+        checkSupportedForQueries(q);
         int limit = getEffectiveLimit(q);
         limits.add(limit);
 
@@ -294,6 +295,15 @@
     return out;
   }
 
+  private void checkSupportedForQueries(Predicate<T> predicate) throws QueryParseException {
+    List<Predicate<T>> descendants = predicate.getFlattenedPredicateList();
+    for (Predicate<T> p : descendants) {
+      if (!p.supportedForQueries()) {
+        throw new QueryParseException(String.format("Operator '%s' cannot be used in queries", p));
+      }
+    }
+  }
+
   private static <T> ImmutableList<QueryResult<T>> disabledResults(
       List<String> queryStrings, List<Predicate<T>> queries) {
     return IntStream.range(0, queries.size())
diff --git a/java/com/google/gerrit/index/query/QueryResult.java b/java/com/google/gerrit/index/query/QueryResult.java
index 33fcef0..d03a68b 100644
--- a/java/com/google/gerrit/index/query/QueryResult.java
+++ b/java/com/google/gerrit/index/query/QueryResult.java
@@ -34,19 +34,19 @@
     return new AutoValue_QueryResult<>(query, predicate, ImmutableList.copyOf(entities), more);
   }
 
-  /** @return the original query string, or null if the query was created programmatically. */
+  /** Returns the original query string, or null if the query was created programmatically. */
   @Nullable
   public abstract String query();
 
-  /** @return the predicate after all rewriting and other modification by the query subsystem. */
+  /** Returns the predicate after all rewriting and other modification by the query subsystem. */
   public abstract Predicate<T> predicate();
 
-  /** @return the query results. */
+  /** Returns the query results. */
   public abstract ImmutableList<T> entities();
 
   /**
-   * @return whether the query could be retried with a higher start/limit to produce more results.
-   *     Never true if {@link #entities()} is empty.
+   * Returns whether the query could be retried with a higher start/limit to produce more results.
+   * Never true if {@link #entities()} is empty.
    */
   public abstract boolean more();
 }
diff --git a/java/com/google/gerrit/index/testing/AbstractFakeIndex.java b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
new file mode 100644
index 0000000..6ece45a
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/AbstractFakeIndex.java
@@ -0,0 +1,340 @@
+// 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.index.testing;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.InternalGroup;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.project.ProjectData;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.FieldBundle;
+import com.google.gerrit.index.query.ListResultSet;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.ResultSet;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.change.MergeabilityComputationBehavior;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.IndexUtils;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Fake secondary index implementation for usage in tests. All values are kept in-memory.
+ *
+ * <p>This class is thread-safe.
+ */
+public abstract class AbstractFakeIndex<K, V, D> implements Index<K, V> {
+  private final Schema<V> schema;
+  /**
+   * SitePaths (config files) are used to signal that an index is ready. This implementation is
+   * consistent with other index backends.
+   */
+  private final SitePaths sitePaths;
+
+  private final String indexName;
+  private final Map<K, D> indexedDocuments;
+
+  AbstractFakeIndex(Schema<V> schema, SitePaths sitePaths, String indexName) {
+    this.schema = schema;
+    this.sitePaths = sitePaths;
+    this.indexName = indexName;
+    this.indexedDocuments = new HashMap<>();
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    // No-op
+  }
+
+  @Override
+  public void replace(V doc) {
+    synchronized (indexedDocuments) {
+      indexedDocuments.put(keyFor(doc), docFor(doc));
+    }
+  }
+
+  @Override
+  public void delete(K key) {
+    synchronized (indexedDocuments) {
+      indexedDocuments.remove(key);
+    }
+  }
+
+  @Override
+  public void deleteAll() {
+    synchronized (indexedDocuments) {
+      indexedDocuments.clear();
+    }
+  }
+
+  @Override
+  public DataSource<V> getSource(Predicate<V> p, QueryOptions opts) {
+    List<V> results;
+    synchronized (indexedDocuments) {
+      results =
+          indexedDocuments.values().stream()
+              .map(doc -> valueFor(doc))
+              .filter(doc -> p.asMatchable().match(doc))
+              .sorted(sortingComparator())
+              .skip(opts.start())
+              .limit(opts.limit())
+              .collect(toImmutableList());
+    }
+    return new DataSource<V>() {
+      @Override
+      public int getCardinality() {
+        return results.size();
+      }
+
+      @Override
+      public ResultSet<V> read() {
+        return new ListResultSet<>(results);
+      }
+
+      @Override
+      public ResultSet<FieldBundle> readRaw() {
+        ImmutableList.Builder<FieldBundle> fieldBundles = ImmutableList.builder();
+        for (V result : results) {
+          ImmutableListMultimap.Builder<String, Object> fields = ImmutableListMultimap.builder();
+          for (FieldDef<V, ?> field : getSchema().getFields().values()) {
+            if (field.get(result) == null) {
+              continue;
+            }
+            if (field.isRepeatable()) {
+              fields.putAll(field.getName(), (Iterable<?>) field.get(result));
+            } else {
+              fields.put(field.getName(), field.get(result));
+            }
+          }
+          fieldBundles.add(new FieldBundle(fields.build()));
+        }
+        return new ListResultSet<>(fieldBundles.build());
+      }
+    };
+  }
+
+  @Override
+  public void markReady(boolean ready) {
+    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
+  }
+
+  /** Method to get a key from a document. */
+  protected abstract K keyFor(V doc);
+
+  /** Method to get a document the index should hold on to from a Gerrit Java data type. */
+  protected abstract D docFor(V value);
+
+  /** Method to a Gerrit Java data type from a document that the index was holding on to. */
+  protected abstract V valueFor(D doc);
+
+  /** Comparator representing the default search order. */
+  protected abstract Comparator<V> sortingComparator();
+
+  /**
+   * Fake implementation of {@link ChangeIndex} where all filtering happens in-memory.
+   *
+   * <p>This index is special in that ChangeData is a mutable object. Therefore we can't just hold
+   * onto the object that the caller wanted us to index. We also can't just create a new ChangeData
+   * from scratch because there are tests that assert that certain computations (e.g. diffs) are
+   * only done once. So we do what the prod indices do: We read and write fields using FieldDef.
+   */
+  public static class FakeChangeIndex
+      extends AbstractFakeIndex<Change.Id, ChangeData, Map<String, Object>> implements ChangeIndex {
+    private final ChangeData.Factory changeDataFactory;
+    private final boolean skipMergable;
+
+    @Inject
+    FakeChangeIndex(
+        SitePaths sitePaths,
+        ChangeData.Factory changeDataFactory,
+        @Assisted Schema<ChangeData> schema,
+        @GerritServerConfig Config cfg) {
+      super(schema, sitePaths, "changes");
+      this.changeDataFactory = changeDataFactory;
+      this.skipMergable = !MergeabilityComputationBehavior.fromConfig(cfg).includeInIndex();
+    }
+
+    @Override
+    protected Change.Id keyFor(ChangeData value) {
+      return value.getId();
+    }
+
+    @Override
+    protected Comparator<ChangeData> sortingComparator() {
+      Comparator<ChangeData> lastUpdated =
+          Comparator.comparing(cd -> cd.change().getLastUpdatedOn());
+      Comparator<ChangeData> merged =
+          Comparator.comparing(cd -> cd.getMergedOn().orElse(new Timestamp(0)));
+      Comparator<ChangeData> id = Comparator.comparing(cd -> cd.getId().get());
+      return lastUpdated.thenComparing(merged).thenComparing(id).reversed();
+    }
+
+    @Override
+    protected Map<String, Object> docFor(ChangeData value) {
+      ImmutableMap.Builder<String, Object> doc = ImmutableMap.builder();
+      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+        if (ChangeField.MERGEABLE.getName().equals(field.getName()) && skipMergable) {
+          continue;
+        }
+        Object docifiedValue = field.get(value);
+        if (docifiedValue != null) {
+          doc.put(field.getName(), field.get(value));
+        }
+      }
+      return doc.build();
+    }
+
+    @Override
+    protected ChangeData valueFor(Map<String, Object> doc) {
+      ChangeData cd =
+          changeDataFactory.create(
+              Project.nameKey((String) doc.get(ChangeField.PROJECT.getName())),
+              Change.id(Integer.valueOf((String) doc.get(ChangeField.LEGACY_ID_STR.getName()))));
+      for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+        field.setIfPossible(cd, new FakeStoredValue(doc.get(field.getName())));
+      }
+      return cd;
+    }
+
+    @Override
+    public void insert(ChangeData obj) {}
+  }
+
+  /** Fake implementation of {@link AccountIndex} where all filtering happens in-memory. */
+  public static class FakeAccountIndex
+      extends AbstractFakeIndex<Account.Id, AccountState, AccountState> implements AccountIndex {
+    @Inject
+    FakeAccountIndex(SitePaths sitePaths, @Assisted Schema<AccountState> schema) {
+      super(schema, sitePaths, "accounts");
+    }
+
+    @Override
+    protected Account.Id keyFor(AccountState value) {
+      return value.account().id();
+    }
+
+    @Override
+    protected AccountState docFor(AccountState value) {
+      return value;
+    }
+
+    @Override
+    protected AccountState valueFor(AccountState doc) {
+      return doc;
+    }
+
+    @Override
+    protected Comparator<AccountState> sortingComparator() {
+      return Comparator.comparing(a -> a.account().id().get());
+    }
+
+    @Override
+    public void insert(AccountState obj) {}
+  }
+
+  /** Fake implementation of {@link GroupIndex} where all filtering happens in-memory. */
+  public static class FakeGroupIndex
+      extends AbstractFakeIndex<AccountGroup.UUID, InternalGroup, InternalGroup>
+      implements GroupIndex {
+    @Inject
+    FakeGroupIndex(SitePaths sitePaths, @Assisted Schema<InternalGroup> schema) {
+      super(schema, sitePaths, "groups");
+    }
+
+    @Override
+    protected AccountGroup.UUID keyFor(InternalGroup value) {
+      return value.getGroupUUID();
+    }
+
+    @Override
+    protected InternalGroup docFor(InternalGroup value) {
+      return value;
+    }
+
+    @Override
+    protected InternalGroup valueFor(InternalGroup doc) {
+      return doc;
+    }
+
+    @Override
+    protected Comparator<InternalGroup> sortingComparator() {
+      return Comparator.comparing(g -> g.getId().get());
+    }
+
+    @Override
+    public void insert(InternalGroup obj) {}
+  }
+
+  /** Fake implementation of {@link ProjectIndex} where all filtering happens in-memory. */
+  public static class FakeProjectIndex
+      extends AbstractFakeIndex<Project.NameKey, ProjectData, ProjectData> implements ProjectIndex {
+    @Inject
+    FakeProjectIndex(SitePaths sitePaths, @Assisted Schema<ProjectData> schema) {
+      super(schema, sitePaths, "projects");
+    }
+
+    @Override
+    protected Project.NameKey keyFor(ProjectData value) {
+      return value.getProject().getNameKey();
+    }
+
+    @Override
+    protected ProjectData docFor(ProjectData value) {
+      return value;
+    }
+
+    @Override
+    protected ProjectData valueFor(ProjectData doc) {
+      return doc;
+    }
+
+    @Override
+    protected Comparator<ProjectData> sortingComparator() {
+      return Comparator.comparing(p -> p.getProject().getName());
+    }
+
+    @Override
+    public void insert(ProjectData obj) {}
+  }
+}
diff --git a/java/com/google/gerrit/index/testing/BUILD b/java/com/google/gerrit/index/testing/BUILD
new file mode 100644
index 0000000..a30eaca
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/BUILD
@@ -0,0 +1,27 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "testing",
+    testonly = True,
+    srcs = glob(
+        ["**/*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/exceptions",
+        "//java/com/google/gerrit/index",
+        "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/proto",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/logging",
+        "//lib:guava",
+        "//lib:jgit",
+        "//lib:protobuf",
+        "//lib/flogger:api",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+    ],
+)
diff --git a/java/com/google/gerrit/index/testing/FakeIndexModule.java b/java/com/google/gerrit/index/testing/FakeIndexModule.java
new file mode 100644
index 0000000..126ff10
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeIndexModule.java
@@ -0,0 +1,69 @@
+// 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.index.testing;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.server.index.AbstractIndexModule;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.group.GroupIndex;
+import java.util.Map;
+
+/** Module to bind {@link FakeIndexModule}. */
+public class FakeIndexModule extends AbstractIndexModule {
+  public static FakeIndexModule singleVersionAllLatest(int threads, boolean secondary) {
+    return new FakeIndexModule(ImmutableMap.of(), threads, secondary);
+  }
+
+  public static FakeIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads, boolean secondary) {
+    return new FakeIndexModule(versions, threads, secondary);
+  }
+
+  public static FakeIndexModule latestVersion(boolean secondary) {
+    return new FakeIndexModule(null, -1 /* direct executor */, secondary);
+  }
+
+  private FakeIndexModule(Map<String, Integer> singleVersions, int threads, boolean secondary) {
+    super(singleVersions, threads, secondary);
+  }
+
+  @Override
+  protected Class<? extends AccountIndex> getAccountIndex() {
+    return AbstractFakeIndex.FakeAccountIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ChangeIndex> getChangeIndex() {
+    return AbstractFakeIndex.FakeChangeIndex.class;
+  }
+
+  @Override
+  protected Class<? extends GroupIndex> getGroupIndex() {
+    return AbstractFakeIndex.FakeGroupIndex.class;
+  }
+
+  @Override
+  protected Class<? extends ProjectIndex> getProjectIndex() {
+    return AbstractFakeIndex.FakeProjectIndex.class;
+  }
+
+  @Override
+  protected Class<? extends VersionManager> getVersionManager() {
+    return FakeIndexVersionManager.class;
+  }
+}
diff --git a/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java b/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
similarity index 65%
rename from java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
rename to java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
index f086ab1..75d8de2 100644
--- a/java/com/google/gerrit/pgm/init/index/elasticsearch/ElasticIndexModuleOnInit.java
+++ b/java/com/google/gerrit/index/testing/FakeIndexModuleOnInit.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 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,30 +12,26 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.pgm.init.index.elasticsearch;
+package com.google.gerrit.index.testing;
 
-import com.google.gerrit.elasticsearch.ElasticAccountIndex;
-import com.google.gerrit.elasticsearch.ElasticGroupIndex;
-import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
+import com.google.gerrit.index.testing.AbstractFakeIndex.FakeAccountIndex;
+import com.google.gerrit.index.testing.AbstractFakeIndex.FakeGroupIndex;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.inject.AbstractModule;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 
-public class ElasticIndexModuleOnInit extends AbstractModule {
-
+public class FakeIndexModuleOnInit extends AbstractModule {
   @Override
   protected void configure() {
     install(
         new FactoryModuleBuilder()
-            .implement(AccountIndex.class, ElasticAccountIndex.class)
+            .implement(AccountIndex.class, FakeAccountIndex.class)
             .build(AccountIndex.Factory.class));
 
     install(
         new FactoryModuleBuilder()
-            .implement(GroupIndex.class, ElasticGroupIndex.class)
+            .implement(GroupIndex.class, FakeGroupIndex.class)
             .build(GroupIndex.Factory.class));
-
-    install(new IndexModuleOnInit());
   }
 }
diff --git a/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
new file mode 100644
index 0000000..5044e38
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeIndexVersionManager.java
@@ -0,0 +1,75 @@
+// 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.index.testing;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.GerritIndexStatus;
+import com.google.gerrit.server.index.OnlineUpgradeListener;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
+
+/** Fake version manager for {@link AbstractFakeIndex}. */
+@Singleton
+public class FakeIndexVersionManager extends VersionManager {
+
+  @Inject
+  FakeIndexVersionManager(
+      @GerritServerConfig Config cfg,
+      SitePaths sitePaths,
+      PluginSetContext<OnlineUpgradeListener> listeners,
+      Collection<IndexDefinition<?, ?, ?>> defs) {
+    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
+  }
+
+  @Override
+  protected <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = new TreeMap<>();
+    for (Schema<V> schema : def.getSchemas().values()) {
+      int v = schema.getVersion();
+      boolean exists = versions.containsKey(v);
+      versions.put(v, new Version<>(schema, v, exists, cfg.getReady(def.getName(), v)));
+    }
+    return versions;
+  }
+
+  @Override
+  protected <K, V, I extends Index<K, V>> void initIndex(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    // Set latest versions ready.
+    if (def.getSchemas().isEmpty()) {
+      super.initIndex(def, cfg);
+      return;
+    }
+    Schema<V> schema = Iterables.getLast(def.getSchemas().values());
+    try {
+      cfg.setReady(def.getName(), schema.getVersion(), true);
+      cfg.save();
+    } catch (Exception e) {
+      throw new IllegalStateException(e);
+    }
+    super.initIndex(def, cfg);
+  }
+}
diff --git a/java/com/google/gerrit/index/testing/FakeStoredValue.java b/java/com/google/gerrit/index/testing/FakeStoredValue.java
new file mode 100644
index 0000000..5091068
--- /dev/null
+++ b/java/com/google/gerrit/index/testing/FakeStoredValue.java
@@ -0,0 +1,76 @@
+// 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.index.testing;
+
+import com.google.gerrit.index.StoredValue;
+import java.sql.Timestamp;
+
+/** Bridge to recover fields from the fake index. */
+public class FakeStoredValue implements StoredValue {
+  private final Object field;
+
+  public FakeStoredValue(Object field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return (String) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<String> asStrings() {
+    return (Iterable<String>) field;
+  }
+
+  @Override
+  public Integer asInteger() {
+    return (Integer) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<Integer> asIntegers() {
+    return (Iterable<Integer>) field;
+  }
+
+  @Override
+  public Long asLong() {
+    return (Long) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<Long> asLongs() {
+    return (Iterable<Long>) field;
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return (Timestamp) field;
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return (byte[]) field;
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public Iterable<byte[]> asByteArrays() {
+    return (Iterable<byte[]>) field;
+  }
+}
diff --git a/java/com/google/gerrit/json/OutputFormat.java b/java/com/google/gerrit/json/OutputFormat.java
index 3e7c319..c5504bb 100644
--- a/java/com/google/gerrit/json/OutputFormat.java
+++ b/java/com/google/gerrit/json/OutputFormat.java
@@ -42,12 +42,12 @@
    */
   JSON_COMPACT;
 
-  /** @return true when the format is either JSON or JSON_COMPACT. */
+  /** Returns true when the format is either JSON or JSON_COMPACT. */
   public boolean isJson() {
     return this == JSON_COMPACT || this == JSON;
   }
 
-  /** @return a new Gson instance configured according to the format. */
+  /** Returns a new Gson instance configured according to the format. */
   public GsonBuilder newGsonBuilder() {
     if (!isJson()) {
       throw new IllegalStateException(String.format("%s is not JSON", this));
@@ -63,7 +63,7 @@
     return gb;
   }
 
-  /** @return a new Gson instance configured according to the format. */
+  /** Returns a new Gson instance configured according to the format. */
   public Gson newGson() {
     return newGsonBuilder().create();
   }
diff --git a/java/com/google/gerrit/lifecycle/LifecycleManager.java b/java/com/google/gerrit/lifecycle/LifecycleManager.java
index 4f09a09..42123d7 100644
--- a/java/com/google/gerrit/lifecycle/LifecycleManager.java
+++ b/java/com/google/gerrit/lifecycle/LifecycleManager.java
@@ -107,7 +107,7 @@
       LifecycleListener obj = listeners.get(i).get();
       try {
         obj.stop();
-      } catch (Throwable err) {
+      } catch (RuntimeException err) {
         logger.atWarning().withCause(err).log("Failed to stop %s", obj.getClass());
       }
       startedIndex = i - 1;
diff --git a/java/com/google/gerrit/lifecycle/LifecycleModule.java b/java/com/google/gerrit/lifecycle/LifecycleModule.java
index 0fb4653..efe1518 100644
--- a/java/com/google/gerrit/lifecycle/LifecycleModule.java
+++ b/java/com/google/gerrit/lifecycle/LifecycleModule.java
@@ -24,13 +24,16 @@
 /** Module to support registering a unique LifecyleListener. */
 public abstract class LifecycleModule extends FactoryModule {
   /**
-   * @return a unique listener binding.
-   *     <p>To create a listener binding use:
-   *     <pre>
+   * Returns a unique listener binding.
+   *
+   * <p>To create a listener binding use:
+   *
+   * <pre>
    * listener().to(MyListener.class);
    * </pre>
-   *     where {@code MyListener} is a {@link Singleton} implementing the {@link LifecycleListener}
-   *     interface.
+   *
+   * where {@code MyListener} is a {@link Singleton} implementing the {@link LifecycleListener}
+   * interface.
    */
   protected LinkedBindingBuilder<LifecycleListener> listener() {
     final Annotation id = UniqueAnnotations.create();
diff --git a/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index b1141d9..e7d47d0 100644
--- a/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.lucene;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
@@ -24,11 +23,8 @@
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Throwables;
-import com.google.common.collect.Collections2;
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
@@ -38,25 +34,19 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.converter.ChangeProtoConverter;
-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.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;
 import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.index.query.ResultSet;
 import com.google.gerrit.proto.Protos;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.change.MergeabilityComputationBehavior;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
@@ -66,7 +56,6 @@
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.options.AutoFlush;
-import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.inject.Inject;
@@ -74,16 +63,13 @@
 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;
 import java.util.List;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
-import java.util.function.Consumer;
 import java.util.function.Function;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.IndexWriter;
@@ -120,34 +106,10 @@
   private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
-  private static final String ADDED_FIELD = ChangeField.ADDED.getName();
-  private static final String APPROVAL_FIELD = ChangeField.APPROVAL.getName();
   private static final String CHANGE_FIELD = ChangeField.CHANGE.getName();
-  private static final String DELETED_FIELD = ChangeField.DELETED.getName();
-  private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
-  private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
-  private static final String PENDING_REVIEWER_FIELD = ChangeField.PENDING_REVIEWER.getName();
-  private static final String PENDING_REVIEWER_BY_EMAIL_FIELD =
-      ChangeField.PENDING_REVIEWER_BY_EMAIL.getName();
-  private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
-  private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
-  private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
-  private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
-  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
-  private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
-  private static final String STAR_FIELD = ChangeField.STAR.getName();
-  private static final String SUBMIT_RECORD_LENIENT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_LENIENT.getName();
-  private static final String SUBMIT_RECORD_STRICT_FIELD =
-      ChangeField.STORED_SUBMIT_RECORD_STRICT.getName();
-  private static final String TOTAL_COMMENT_COUNT_FIELD = ChangeField.TOTAL_COMMENT_COUNT.getName();
-  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 {
+  interface IdTerm {
     Term get(String name, int id);
   }
 
@@ -160,7 +122,7 @@
   }
 
   @FunctionalInterface
-  static interface ChangeIdExtractor {
+  interface ChangeIdExtractor {
     Change.Id extract(IndexableField f);
   }
 
@@ -542,231 +504,14 @@
       cd = changeDataFactory.create(Project.nameKey(project.stringValue()), extractor.extract(f));
     }
 
-    // Any decoding that is done here must also be done in {@link ElasticChangeIndex}.
-
-    if (fields.contains(PATCH_SET_FIELD)) {
-      decodePatchSets(doc, cd);
+    for (FieldDef<ChangeData, ?> field : getSchema().getFields().values()) {
+      if (fields.contains(field.getName()) && doc.get(field.getName()) != null) {
+        field.setIfPossible(cd, new LuceneStoredValue(doc.get(field.getName())));
+      }
     }
-    if (fields.contains(APPROVAL_FIELD)) {
-      decodeApprovals(doc, cd);
-    }
-    if (fields.contains(ADDED_FIELD) && fields.contains(DELETED_FIELD)) {
-      decodeChangedLines(doc, cd);
-    }
-    if (fields.contains(MERGEABLE_FIELD)) {
-      decodeMergeable(doc, cd);
-    }
-    if (fields.contains(REVIEWEDBY_FIELD)) {
-      decodeReviewedBy(doc, cd);
-    }
-    if (fields.contains(HASHTAG_FIELD)) {
-      decodeHashtags(doc, cd);
-    }
-    if (fields.contains(STAR_FIELD)) {
-      decodeStar(doc, cd);
-    }
-    if (fields.contains(REVIEWER_FIELD)) {
-      decodeReviewers(doc, cd);
-    }
-    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
-      decodeReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_FIELD)) {
-      decodePendingReviewers(doc, cd);
-    }
-    if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
-      decodePendingReviewersByEmail(doc, cd);
-    }
-    if (fields.contains(ATTENTION_SET_FULL_FIELD)) {
-      decodeAttentionSet(doc, cd);
-    }
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
-    decodeSubmitRecords(
-        doc, SUBMIT_RECORD_LENIENT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_LENIENT, cd);
-    if (fields.contains(REF_STATE_FIELD)) {
-      decodeRefStates(doc, cd);
-    }
-    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);
     return cd;
   }
 
-  private void decodePatchSets(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    List<PatchSet> patchSets = decodeProtos(doc, PATCH_SET_FIELD, PatchSetProtoConverter.INSTANCE);
-    if (!patchSets.isEmpty()) {
-      // Will be an empty list for schemas prior to when this field was stored;
-      // this cannot be valid since a change needs at least one patch set.
-      cd.setPatchSets(patchSets);
-    }
-  }
-
-  private void decodeApprovals(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setCurrentApprovals(
-        decodeProtos(doc, APPROVAL_FIELD, PatchSetApprovalProtoConverter.INSTANCE));
-  }
-
-  private void decodeChangedLines(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField added = Iterables.getFirst(doc.get(ADDED_FIELD), null);
-    IndexableField deleted = Iterables.getFirst(doc.get(DELETED_FIELD), null);
-    if (added != null && deleted != null) {
-      cd.setChangedLines(added.numericValue().intValue(), deleted.numericValue().intValue());
-    } else {
-      // No ChangedLines stored, likely due to failure during reindexing, for
-      // example due to LargeObjectException. But we know the field was
-      // requested, so update ChangeData to prevent callers from trying to
-      // lazily load it, as that would probably also fail.
-      cd.setNoChangedLines();
-    }
-  }
-
-  private void decodeMergeable(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    IndexableField f = Iterables.getFirst(doc.get(MERGEABLE_FIELD), null);
-    if (f != null && !skipFields.contains(MERGEABLE_FIELD)) {
-      String mergeable = f.stringValue();
-      if ("1".equals(mergeable)) {
-        cd.setMergeable(true);
-      } else if ("0".equals(mergeable)) {
-        cd.setMergeable(false);
-      }
-    }
-  }
-
-  private void decodeReviewedBy(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> reviewedBy = doc.get(REVIEWEDBY_FIELD);
-    if (!reviewedBy.isEmpty()) {
-      Set<Account.Id> accounts = Sets.newHashSetWithExpectedSize(reviewedBy.size());
-      for (IndexableField r : reviewedBy) {
-        int id = r.numericValue().intValue();
-        if (reviewedBy.size() == 1 && id == ChangeField.NOT_REVIEWED) {
-          break;
-        }
-        accounts.add(Account.id(id));
-      }
-      cd.setReviewedBy(accounts);
-    }
-  }
-
-  private void decodeHashtags(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> hashtag = doc.get(HASHTAG_FIELD);
-    Set<String> hashtags = Sets.newHashSetWithExpectedSize(hashtag.size());
-    for (IndexableField r : hashtag) {
-      hashtags.add(r.binaryValue().utf8ToString());
-    }
-    cd.setHashtags(hashtags);
-  }
-
-  private void decodeStar(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    Collection<IndexableField> star = doc.get(STAR_FIELD);
-    ListMultimap<Account.Id, String> stars = MultimapBuilder.hashKeys().arrayListValues().build();
-    for (IndexableField r : star) {
-      StarredChangesUtil.StarField starField = StarredChangesUtil.StarField.parse(r.stringValue());
-      if (starField != null) {
-        stars.put(starField.accountId(), starField.label());
-      }
-    }
-    cd.setStars(stars);
-  }
-
-  private void decodeReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewers(
-        ChangeField.parseReviewerFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
-  }
-
-  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewers(
-        ChangeField.parseReviewerFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodePendingReviewersByEmail(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setPendingReviewersByEmail(
-        ChangeField.parseReviewerByEmailFieldValues(
-            cd.getId(),
-            FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
-                .transform(IndexableField::stringValue)));
-  }
-
-  private void decodeAttentionSet(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    ChangeField.parseAttentionSet(
-        doc.get(ATTENTION_SET_FULL_FIELD).stream()
-            .map(field -> field.binaryValue().utf8ToString())
-            .collect(toImmutableSet()),
-        cd);
-  }
-
-  private void decodeSubmitRecords(
-      ListMultimap<String, IndexableField> doc,
-      String field,
-      SubmitRuleOptions opts,
-      ChangeData cd) {
-    ChangeField.parseSubmitRecords(
-        Collections2.transform(doc.get(field), f -> f.binaryValue().utf8ToString()), opts, cd);
-  }
-
-  private void decodeRefStates(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStates(RefState.parseStates(copyAsBytes(doc.get(REF_STATE_FIELD))));
-  }
-
-  private void decodeRefStatePatterns(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    cd.setRefStatePatterns(copyAsBytes(doc.get(REF_STATE_PATTERN_FIELD)));
-  }
-
-  private void decodeUnresolvedCommentCount(
-      ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    decodeIntField(doc, UNRESOLVED_COMMENT_COUNT_FIELD, cd::setUnresolvedCommentCount);
-  }
-
-  private void decodeTotalCommentCount(ListMultimap<String, IndexableField> doc, ChangeData cd) {
-    decodeIntField(doc, TOTAL_COMMENT_COUNT_FIELD, cd::setTotalCommentCount);
-  }
-
-  private static void decodeIntField(
-      ListMultimap<String, IndexableField> doc, String fieldName, Consumer<Integer> consumer) {
-    IndexableField f = Iterables.getFirst(doc.get(fieldName), null);
-    if (f != null && f.numericValue() != null) {
-      consumer.accept(f.numericValue().intValue());
-    }
-  }
-
-  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()
-        .map(IndexableField::binaryValue)
-        .map(bytesRef -> parseProtoFrom(bytesRef, converter))
-        .collect(toImmutableList());
-  }
-
   private static <P extends MessageLite, T> T parseProtoFrom(
       BytesRef bytesRef, ProtoConverter<P, T> converter) {
     P message =
@@ -774,16 +519,4 @@
             converter.getParser(), bytesRef.bytes, bytesRef.offset, bytesRef.length);
     return converter.fromProto(message);
   }
-
-  private static List<byte[]> copyAsBytes(Collection<IndexableField> fields) {
-    return fields.stream()
-        .map(
-            f -> {
-              BytesRef ref = f.binaryValue();
-              byte[] b = new byte[ref.length];
-              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
-              return b;
-            })
-        .collect(toList());
-  }
 }
diff --git a/java/com/google/gerrit/lucene/LuceneIndexModule.java b/java/com/google/gerrit/lucene/LuceneIndexModule.java
index 73256b1..3aa9c6e 100644
--- a/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.project.ProjectIndex;
+import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.VersionManager;
@@ -29,6 +30,7 @@
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
+@ModuleImpl(name = AbstractIndexModule.INDEX_MODULE)
 public class LuceneIndexModule extends AbstractIndexModule {
   private final AutoFlush autoFlush;
 
diff --git a/java/com/google/gerrit/lucene/LuceneStoredValue.java b/java/com/google/gerrit/lucene/LuceneStoredValue.java
new file mode 100644
index 0000000..efe489b
--- /dev/null
+++ b/java/com/google/gerrit/lucene/LuceneStoredValue.java
@@ -0,0 +1,95 @@
+// 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.lucene;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.StoredValue;
+import java.sql.Timestamp;
+import java.util.List;
+import org.apache.lucene.index.IndexableField;
+import org.apache.lucene.util.BytesRef;
+
+/** Bridge to recover fields from the lucene index. */
+public class LuceneStoredValue implements StoredValue {
+  /**
+   * Lucene represents repeated fields as a list of {@link IndexableField}, so we hold onto a list
+   * here to cover both repeated and non-repeated fields.
+   */
+  private final List<IndexableField> field;
+
+  LuceneStoredValue(List<IndexableField> field) {
+    this.field = field;
+  }
+
+  @Override
+  public String asString() {
+    return Iterables.getFirst(asStrings(), null);
+  }
+
+  @Override
+  public Iterable<String> asStrings() {
+    return field.stream().map(f -> f.stringValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Integer asInteger() {
+    return Iterables.getFirst(asIntegers(), null);
+  }
+
+  @Override
+  public Iterable<Integer> asIntegers() {
+    return field.stream().map(f -> f.numericValue().intValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Long asLong() {
+    return Iterables.getFirst(asLongs(), null);
+  }
+
+  @Override
+  public Iterable<Long> asLongs() {
+    return field.stream().map(f -> f.numericValue().longValue()).collect(toImmutableList());
+  }
+
+  @Override
+  public Timestamp asTimestamp() {
+    return asLong() == null ? null : new Timestamp(asLong());
+  }
+
+  @Override
+  public byte[] asByteArray() {
+    return Iterables.getFirst(asByteArrays(), null);
+  }
+
+  @Override
+  public Iterable<byte[]> asByteArrays() {
+    return copyAsBytes(field);
+  }
+
+  private static List<byte[]> copyAsBytes(List<IndexableField> fields) {
+    return fields.stream()
+        .map(
+            f -> {
+              BytesRef ref = f.binaryValue();
+              byte[] b = new byte[ref.length];
+              System.arraycopy(ref.bytes, ref.offset, b, 0, ref.length);
+              return b;
+            })
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/lucene/WrappableSearcherManager.java b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
index 4044b90..c164b29 100644
--- a/java/com/google/gerrit/lucene/WrappableSearcherManager.java
+++ b/java/com/google/gerrit/lucene/WrappableSearcherManager.java
@@ -53,7 +53,6 @@
  * {@link #maybeRefresh}. Finally, be sure to call {@link #close} once you are done.
  *
  * @see SearcherFactory
- * @lucene.experimental
  */
 // This file was copied from:
 // https://github.com/apache/lucene-solr/blob/lucene_solr_5_0/lucene/core/src/java/org/apache/lucene/search/SearcherManager.java
diff --git a/java/com/google/gerrit/mail/HtmlParser.java b/java/com/google/gerrit/mail/HtmlParser.java
index ba73bdd..97fe06e 100644
--- a/java/com/google/gerrit/mail/HtmlParser.java
+++ b/java/com/google/gerrit/mail/HtmlParser.java
@@ -143,7 +143,7 @@
           if (!Strings.isNullOrEmpty(content)) {
             ParserUtil.appendOrAddNewComment(
                 new MailComment(
-                    content, null, null, MailComment.CommentType.CHANGE_MESSAGE, isLink),
+                    content, null, null, MailComment.CommentType.PATCHSET_LEVEL, isLink),
                 parsedComments);
           }
         } else if (lastEncounteredComment == null) {
diff --git a/java/com/google/gerrit/mail/MailComment.java b/java/com/google/gerrit/mail/MailComment.java
index 3e7da10..cea856c 100644
--- a/java/com/google/gerrit/mail/MailComment.java
+++ b/java/com/google/gerrit/mail/MailComment.java
@@ -20,7 +20,7 @@
 /** A comment parsed from inbound email */
 public class MailComment {
   public enum CommentType {
-    CHANGE_MESSAGE,
+    PATCHSET_LEVEL,
     FILE_COMMENT,
     INLINE_COMMENT
   }
diff --git a/java/com/google/gerrit/mail/TextParser.java b/java/com/google/gerrit/mail/TextParser.java
index a33c66f..c43d200 100644
--- a/java/com/google/gerrit/mail/TextParser.java
+++ b/java/com/google/gerrit/mail/TextParser.java
@@ -30,7 +30,7 @@
   /**
    * Parses comments from plaintext email.
    *
-   * @param email @param email the message as received from the email service
+   * @param email the message as received from the email service
    * @param comments list of {@link HumanComment}s previously persisted on the change that caused
    *     the original notification email to be sent out. Ordering must be the same as in the
    *     outbound email
@@ -77,7 +77,7 @@
         // This is not a comment, try to advance the file/comment pointers and
         // add previous comment to list if applicable
         if (currentComment != null) {
-          if (currentComment.type == MailComment.CommentType.CHANGE_MESSAGE) {
+          if (currentComment.type == MailComment.CommentType.PATCHSET_LEVEL) {
             currentComment.message = ParserUtil.trimQuotation(currentComment.message);
           }
           if (!Strings.isNullOrEmpty(currentComment.message)) {
@@ -115,7 +115,7 @@
           if (lastEncounteredComment == null) {
             if (lastEncounteredFileName == null) {
               // Change message
-              currentComment.type = MailComment.CommentType.CHANGE_MESSAGE;
+              currentComment.type = MailComment.CommentType.PATCHSET_LEVEL;
             } else {
               // File comment not sent in reply to another comment
               currentComment.type = MailComment.CommentType.FILE_COMMENT;
diff --git a/java/com/google/gerrit/metrics/BUILD b/java/com/google/gerrit/metrics/BUILD
index 3cc056b..0cb0275 100644
--- a/java/com/google/gerrit/metrics/BUILD
+++ b/java/com/google/gerrit/metrics/BUILD
@@ -8,6 +8,7 @@
         "//java/com/google/gerrit/common:server",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/lifecycle",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
         "//lib:guava",
         "//lib:jgit",
diff --git a/java/com/google/gerrit/metrics/Description.java b/java/com/google/gerrit/metrics/Description.java
index 10568bc..f5963af 100644
--- a/java/com/google/gerrit/metrics/Description.java
+++ b/java/com/google/gerrit/metrics/Description.java
@@ -133,27 +133,27 @@
     return this;
   }
 
-  /** @return true if the metric value never changes after startup. */
+  /** Returns true if the metric value never changes after startup. */
   public boolean isConstant() {
     return TRUE_VALUE.equals(annotations.get(CONSTANT));
   }
 
-  /** @return true if the metric may be interpreted as a rate over time. */
+  /** Returns true if the metric may be interpreted as a rate over time. */
   public boolean isRate() {
     return TRUE_VALUE.equals(annotations.get(RATE));
   }
 
-  /** @return true if the metric is an instantaneous sample. */
+  /** Returns true if the metric is an instantaneous sample. */
   public boolean isGauge() {
     return TRUE_VALUE.equals(annotations.get(GAUGE));
   }
 
-  /** @return true if the metric accumulates over the lifespan of the process. */
+  /** Returns true if the metric accumulates over the lifespan of the process. */
   public boolean isCumulative() {
     return TRUE_VALUE.equals(annotations.get(CUMULATIVE));
   }
 
-  /** @return the suggested field ordering. */
+  /** Returns the suggested field ordering. */
   public FieldOrdering getFieldOrdering() {
     String o = annotations.get(FIELD_ORDERING);
     return o != null ? FieldOrdering.valueOf(o) : FieldOrdering.AT_END;
@@ -187,7 +187,7 @@
     return u;
   }
 
-  /** @return immutable copy of all annotations (configurable properties). */
+  /** Returns an immutable copy of all annotations (configurable properties). */
   public ImmutableMap<String, String> getAnnotations() {
     return ImmutableMap.copyOf(annotations);
   }
diff --git a/java/com/google/gerrit/metrics/Field.java b/java/com/google/gerrit/metrics/Field.java
index bdae854..5508819 100644
--- a/java/com/google/gerrit/metrics/Field.java
+++ b/java/com/google/gerrit/metrics/Field.java
@@ -102,19 +102,19 @@
         .metadataMapper(metadataMapper);
   }
 
-  /** @return name of this field within the metric. */
+  /** Returns name of this field within the metric. */
   public abstract String name();
 
-  /** @return type of value used within the field. */
+  /** Returns type of value used within the field. */
   public abstract Class<T> valueType();
 
-  /** @return mapper that maps a field value to a field in the {@link Metadata} class. */
+  /** Returns mapper that maps a field value to a field in the {@link Metadata} class. */
   public abstract BiConsumer<Metadata.Builder, T> metadataMapper();
 
-  /** @return description text for the field explaining its range of values. */
+  /** Returns description text for the field explaining its range of values. */
   public abstract Optional<String> description();
 
-  /** @return formatter to format field values. */
+  /** Returns formatter to format field values. */
   public abstract Function<T, String> formatter();
 
   @AutoValue.Builder
diff --git a/java/com/google/gerrit/metrics/Timer0.java b/java/com/google/gerrit/metrics/Timer0.java
index d0033a4..72ebc67 100644
--- a/java/com/google/gerrit/metrics/Timer0.java
+++ b/java/com/google/gerrit/metrics/Timer0.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.PerformanceLogRecord;
 import java.util.concurrent.TimeUnit;
@@ -48,6 +49,8 @@
     }
   }
 
+  private boolean suppressLogging;
+
   protected final String name;
 
   public Timer0(String name) {
@@ -60,6 +63,7 @@
    * @return timer context
    */
   public Context start() {
+    RequestStateContext.abortIfCancelled();
     return new Context(this);
   }
 
@@ -71,10 +75,21 @@
    */
   public final void record(long value, TimeUnit unit) {
     long durationMs = unit.toMillis(value);
-    LoggingContext.getInstance()
-        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
-    logger.atFinest().log("%s took %dms", name, durationMs);
+
+    if (!suppressLogging) {
+      LoggingContext.getInstance()
+          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs));
+      logger.atFinest().log("%s took %dms", name, durationMs);
+    }
+
     doRecord(value, unit);
+    RequestStateContext.abortIfCancelled();
+  }
+
+  /** Suppress logging (debug log and performance log) when values are recorded. */
+  public final Timer0 suppressLogging() {
+    this.suppressLogging = true;
+    return this;
   }
 
   /**
diff --git a/java/com/google/gerrit/metrics/Timer1.java b/java/com/google/gerrit/metrics/Timer1.java
index a8fb1a2..eefd462 100644
--- a/java/com/google/gerrit/metrics/Timer1.java
+++ b/java/com/google/gerrit/metrics/Timer1.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogRecord;
@@ -53,6 +54,8 @@
     }
   }
 
+  private boolean suppressLogging;
+
   protected final String name;
   protected final Field<F1> field;
 
@@ -68,6 +71,7 @@
    * @return timer context
    */
   public Context<F1> start(F1 fieldValue) {
+    RequestStateContext.abortIfCancelled();
     return new Context<>(this, fieldValue);
   }
 
@@ -85,11 +89,20 @@
     field.metadataMapper().accept(metadataBuilder, fieldValue);
     Metadata metadata = metadataBuilder.build();
 
-    LoggingContext.getInstance()
-        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+    if (!suppressLogging) {
+      LoggingContext.getInstance()
+          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+      logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
+    }
 
-    logger.atFinest().log("%s (%s = %s) took %dms", name, field.name(), fieldValue, durationMs);
     doRecord(fieldValue, value, unit);
+    RequestStateContext.abortIfCancelled();
+  }
+
+  /** Suppress logging (debug log and performance log) when values are recorded. */
+  public final Timer1<F1> suppressLogging() {
+    this.suppressLogging = true;
+    return this;
   }
 
   /**
diff --git a/java/com/google/gerrit/metrics/Timer2.java b/java/com/google/gerrit/metrics/Timer2.java
index 8a4a793..09878ad 100644
--- a/java/com/google/gerrit/metrics/Timer2.java
+++ b/java/com/google/gerrit/metrics/Timer2.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogRecord;
@@ -56,6 +57,8 @@
     }
   }
 
+  private boolean suppressLogging;
+
   protected final String name;
   protected final Field<F1> field1;
   protected final Field<F2> field2;
@@ -74,6 +77,7 @@
    * @return timer context
    */
   public Context<F1, F2> start(F1 fieldValue1, F2 fieldValue2) {
+    RequestStateContext.abortIfCancelled();
     return new Context<>(this, fieldValue1, fieldValue2);
   }
 
@@ -93,13 +97,22 @@
     field2.metadataMapper().accept(metadataBuilder, fieldValue2);
     Metadata metadata = metadataBuilder.build();
 
-    LoggingContext.getInstance()
-        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+    if (!suppressLogging) {
+      LoggingContext.getInstance()
+          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+      logger.atFinest().log(
+          "%s (%s = %s, %s = %s) took %dms",
+          name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
+    }
 
-    logger.atFinest().log(
-        "%s (%s = %s, %s = %s) took %dms",
-        name, field1.name(), fieldValue1, field2.name(), fieldValue2, durationMs);
     doRecord(fieldValue1, fieldValue2, value, unit);
+    RequestStateContext.abortIfCancelled();
+  }
+
+  /** Suppress logging (debug log and performance log) when values are recorded. */
+  public final Timer2<F1, F2> suppressLogging() {
+    this.suppressLogging = true;
+    return this;
   }
 
   /**
diff --git a/java/com/google/gerrit/metrics/Timer3.java b/java/com/google/gerrit/metrics/Timer3.java
index 2044da6..5d5c424 100644
--- a/java/com/google/gerrit/metrics/Timer3.java
+++ b/java/com/google/gerrit/metrics/Timer3.java
@@ -18,6 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.registration.RegistrationHandle;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.logging.LoggingContext;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.PerformanceLogRecord;
@@ -59,6 +60,8 @@
     }
   }
 
+  private boolean suppressLogging;
+
   protected final String name;
   protected final Field<F1> field1;
   protected final Field<F2> field2;
@@ -80,6 +83,7 @@
    * @return timer context
    */
   public Context<F1, F2, F3> start(F1 fieldValue1, F2 fieldValue2, F3 fieldValue3) {
+    RequestStateContext.abortIfCancelled();
     return new Context<>(this, fieldValue1, fieldValue2, fieldValue3);
   }
 
@@ -102,20 +106,29 @@
     field3.metadataMapper().accept(metadataBuilder, fieldValue3);
     Metadata metadata = metadataBuilder.build();
 
-    LoggingContext.getInstance()
-        .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+    if (!suppressLogging) {
+      LoggingContext.getInstance()
+          .addPerformanceLogRecord(() -> PerformanceLogRecord.create(name, durationMs, metadata));
+      logger.atFinest().log(
+          "%s (%s = %s, %s = %s, %s = %s) took %dms",
+          name,
+          field1.name(),
+          fieldValue1,
+          field2.name(),
+          fieldValue2,
+          field3.name(),
+          fieldValue3,
+          durationMs);
+    }
 
-    logger.atFinest().log(
-        "%s (%s = %s, %s = %s, %s = %s) took %dms",
-        name,
-        field1.name(),
-        fieldValue1,
-        field2.name(),
-        fieldValue2,
-        field3.name(),
-        fieldValue3,
-        durationMs);
     doRecord(fieldValue1, fieldValue2, fieldValue3, value, unit);
+    RequestStateContext.abortIfCancelled();
+  }
+
+  /** Suppress logging (debug log and performance log) when values are recorded. */
+  public final Timer3<F1, F2, F3> suppressLogging() {
+    this.suppressLogging = true;
+    return this;
   }
 
   /**
diff --git a/java/com/google/gerrit/metrics/TimerContext.java b/java/com/google/gerrit/metrics/TimerContext.java
index 62eb030..a3754c5 100644
--- a/java/com/google/gerrit/metrics/TimerContext.java
+++ b/java/com/google/gerrit/metrics/TimerContext.java
@@ -29,7 +29,7 @@
    */
   public abstract void record(long elapsed);
 
-  /** @return the start time in system time nanoseconds. */
+  /** Returns the start time in system time nanoseconds. */
   public long getStartTime() {
     return startNanos;
   }
diff --git a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
index e8611b3..d64bd19 100644
--- a/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
+++ b/java/com/google/gerrit/metrics/proc/JGitMetricModule.java
@@ -184,7 +184,9 @@
                         + "having most data in the cache.")
                 .setGauge()
                 .setUnit("byte"),
-            Field.ofString("repository_name", Metadata.Builder::projectName).build());
+            Field.ofString("repository_name", Metadata.Builder::projectName)
+                .description("The name of the repository.")
+                .build());
     metrics.newTrigger(
         repoEnt,
         () -> {
diff --git a/java/com/google/gerrit/pgm/BUILD b/java/com/google/gerrit/pgm/BUILD
index 387ff2d..3a1de5e 100644
--- a/java/com/google/gerrit/pgm/BUILD
+++ b/java/com/google/gerrit/pgm/BUILD
@@ -10,7 +10,6 @@
         "//java/com/google/gerrit/auth",
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
new file mode 100644
index 0000000..2b7f23e
--- /dev/null
+++ b/java/com/google/gerrit/pgm/ChangeExternalIdCaseSensitivity.java
@@ -0,0 +1,156 @@
+// 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.pgm;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.kohsuke.args4j.Option;
+
+/**
+ * Changes the case sensitivity of `username:` and `gerrit:` external IDs by recomputing the SHA-1
+ * sums used as note names.
+ */
+public class ChangeExternalIdCaseSensitivity extends SiteProgram {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  @Option(name = "--batch", usage = "Don't ask for confirmation before migrating.")
+  private boolean batch;
+
+  @Option(name = "--dryrun", usage = "Do a dryrun of the migration.")
+  private boolean dryrun;
+
+  private final LifecycleManager manager = new LifecycleManager();
+  private final TextProgressMonitor monitor = new TextProgressMonitor();
+
+  private Config globalConfig;
+  private boolean isUserNameCaseInsensitive;
+  private ConsoleUI ui;
+
+  @Inject private ExternalIds externalIds;
+  @Inject private ExternalIdCaseSensitivityMigrator.Factory migratorFactory;
+
+  @Override
+  public int run() throws Exception {
+    mustHaveValidSite();
+    ui = ConsoleUI.getInstance(batch);
+
+    Injector dbInjector = createDbInjector();
+    manager.add(dbInjector, dbInjector.createChildInjector(NoteDbSchemaVersionCheck.module()));
+    dbInjector
+        .createChildInjector(
+            new FactoryModule() {
+              @Override
+              protected void configure() {
+                bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
+                install(
+                    new FactoryModuleBuilder()
+                        .build(ExternalIdCaseSensitivityMigrator.Factory.class));
+                factory(MetaDataUpdate.InternalFactory.class);
+                DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
+
+                // The ChangeExternalIdCaseSensitivity program needs to access all external IDs only
+                // once to update them. After the update they are not accessed again. Hence the
+                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
+                // the external ID cache can be disabled.
+                install(DisabledExternalIdCache.module());
+              }
+            })
+        .injectMembers(this);
+    globalConfig = dbInjector.getInstance(Key.get(Config.class, GerritServerConfig.class));
+
+    this.isUserNameCaseInsensitive =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitive", false);
+
+    String message =
+        "auth.userNameCaseInsensitive is set to %b. "
+            + "External IDs will be migrated to be case %ssensitive. Continue?";
+    if (!ui.yesno(
+        true, message, isUserNameCaseInsensitive, isUserNameCaseInsensitive ? "" : "in")) {
+      return 0;
+    }
+
+    Collection<ExternalId> todo = externalIds.all();
+    monitor.beginTask("Converting external ID note names", todo.size());
+
+    manager.start();
+    try {
+      migratorFactory
+          .create(!isUserNameCaseInsensitive, dryrun)
+          .migrate(todo, () -> monitor.update(1));
+    } finally {
+      manager.stop();
+      monitor.endTask();
+    }
+
+    int exitCode;
+    if (!dryrun) {
+      updateGerritConfig();
+
+      exitCode = reindexAccounts();
+    } else {
+      exitCode = 0;
+    }
+    return exitCode;
+  }
+
+  private void updateGerritConfig() throws IOException, ConfigInvalidException {
+    logger.atInfo().log("Setting auth.userNameCaseInsensitive to true in gerrit.config.");
+    FileBasedConfig config =
+        new FileBasedConfig(
+            globalConfig, getSitePath().resolve("etc/gerrit.config").toFile(), FS.DETECTED);
+    config.load();
+    config.setBoolean("auth", null, "userNameCaseInsensitive", !isUserNameCaseInsensitive);
+    config.save();
+  }
+
+  private int reindexAccounts() throws Exception {
+    monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
+    String[] reindexArgs = {
+      "--site-path", getSitePath().toString(), "--index", AccountSchemaDefinitions.NAME
+    };
+    logger.atInfo().log(
+        "Migration complete, reindexing accounts with: reindex %s", String.join(" ", reindexArgs));
+    Reindex reindexPgm = new Reindex();
+    int exitCode = reindexPgm.main(reindexArgs);
+    monitor.endTask();
+    return exitCode;
+  }
+}
diff --git a/java/com/google/gerrit/pgm/Daemon.java b/java/com/google/gerrit/pgm/Daemon.java
index 266eb6f..d77daa1 100644
--- a/java/com/google/gerrit/pgm/Daemon.java
+++ b/java/com/google/gerrit/pgm/Daemon.java
@@ -23,19 +23,19 @@
 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;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
 import com.google.gerrit.httpd.GerritAuthModule;
-import com.google.gerrit.httpd.GetUserFilter;
+import com.google.gerrit.httpd.GetUserFilter.GetUserFilterModule;
 import com.google.gerrit.httpd.GitOverHttpModule;
 import com.google.gerrit.httpd.H2CacheBasedWebSession;
 import com.google.gerrit.httpd.HttpCanonicalWebUrlProvider;
+import com.google.gerrit.httpd.HttpdModule;
 import com.google.gerrit.httpd.RequestCleanupFilter;
 import com.google.gerrit.httpd.RequestContextFilter;
 import com.google.gerrit.httpd.RequestMetricsFilter;
-import com.google.gerrit.httpd.RequireSslFilter;
+import com.google.gerrit.httpd.RequireSslFilter.RequireSslFilterModule;
 import com.google.gerrit.httpd.SetThreadNameFilter;
 import com.google.gerrit.httpd.WebModule;
 import com.google.gerrit.httpd.WebSshGlueModule;
@@ -50,28 +50,29 @@
 import com.google.gerrit.metrics.dropwizard.DropWizardMetricMaker;
 import com.google.gerrit.pgm.http.jetty.JettyEnv;
 import com.google.gerrit.pgm.http.jetty.JettyModule;
-import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter;
+import com.google.gerrit.pgm.http.jetty.ProjectQoSFilter.ProjectQoSFilterModule;
 import com.google.gerrit.pgm.util.ErrorLogFile;
-import com.google.gerrit.pgm.util.LogFileCompressor;
+import com.google.gerrit.pgm.util.LogFileCompressor.LogFileCompressorModule;
 import com.google.gerrit.pgm.util.RuntimeShutdown;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.LibModuleType;
 import com.google.gerrit.server.ModuleOverloader;
-import com.google.gerrit.server.StartupChecks;
-import com.google.gerrit.server.account.AccountDeactivator;
-import com.google.gerrit.server.account.InternalAccountDirectory;
+import com.google.gerrit.server.StartupChecks.StartupChecksModule;
+import com.google.gerrit.server.account.AccountDeactivator.AccountDeactivatorModule;
+import com.google.gerrit.server.account.InternalAccountDirectory.InternalAccountDirectoryModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
 import com.google.gerrit.server.api.GerritApiModule;
 import com.google.gerrit.server.api.PluginApiModule;
 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.ChangeCleanupRunner;
+import com.google.gerrit.server.change.ChangeCleanupRunner.ChangeCleanupRunnerModule;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.AuthConfigModule;
 import com.google.gerrit.server.config.CanonicalWebUrlModule;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
-import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.DownloadConfig;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
@@ -80,27 +81,28 @@
 import com.google.gerrit.server.config.GerritRuntime;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SysExecutorModule;
-import com.google.gerrit.server.events.EventBroker;
-import com.google.gerrit.server.events.StreamEventsApiListener;
+import com.google.gerrit.server.events.EventBroker.EventBrokerModule;
+import com.google.gerrit.server.events.StreamEventsApiListener.StreamEventsApiListenerModule;
 import com.google.gerrit.server.git.GarbageCollectionModule;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.group.PeriodicGroupIndexer;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl.SearchingChangeCacheImplModule;
+import com.google.gerrit.server.git.WorkQueue.WorkQueueModule;
+import com.google.gerrit.server.group.PeriodicGroupIndexer.PeriodicGroupIndexerModule;
+import com.google.gerrit.server.index.AbstractIndexModule;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.OnlineUpgrader.OnlineUpgraderModule;
 import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.index.options.AutoFlush;
-import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
-import com.google.gerrit.server.mail.receive.MailReceiver;
-import com.google.gerrit.server.mail.send.SmtpEmailSender;
+import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
+import com.google.gerrit.server.mail.receive.MailReceiver.MailReceiverModule;
+import com.google.gerrit.server.mail.send.SmtpEmailSender.SmtpEmailSenderModule;
 import com.google.gerrit.server.mime.MimeUtil2Module;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
-import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.project.DefaultProjectNameLockManager.DefaultProjectNameLockManagerModule;
 import com.google.gerrit.server.restapi.RestApiModule;
-import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
+import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore.JdbcAccountPatchReviewStoreModule;
 import com.google.gerrit.server.schema.NoteDbSchemaVersionCheck;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -109,17 +111,18 @@
 import com.google.gerrit.server.ssh.NoSshKeyCache;
 import com.google.gerrit.server.ssh.NoSshModule;
 import com.google.gerrit.server.ssh.SshAddressesModule;
-import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
-import com.google.gerrit.server.submit.SubscriptionGraph;
-import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
+import com.google.gerrit.server.submit.LocalMergeSuperSetComputation.LocalMergeSuperSetComputationModule;
+import com.google.gerrit.server.submit.SubscriptionGraph.SubscriptionGraphModule;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener.SuperprojectUpdateSubmissionListenerModule;
 import com.google.gerrit.sshd.SshHostKeyModule;
 import com.google.gerrit.sshd.SshKeyCacheImpl;
 import com.google.gerrit.sshd.SshModule;
 import com.google.gerrit.sshd.SshSessionFactoryInitializer;
 import com.google.gerrit.sshd.commands.DefaultCommandModule;
+import com.google.gerrit.sshd.commands.ExternalIdCommandsModule;
 import com.google.gerrit.sshd.commands.IndexCommandsModule;
 import com.google.gerrit.sshd.commands.SequenceCommandsModule;
-import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -128,6 +131,8 @@
 import com.google.inject.Provider;
 import com.google.inject.Stage;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -178,7 +183,7 @@
   private String devCdn = "";
 
   @Option(name = "--dev-cdn", usage = "Use specified cdn for serving static content.")
-  private void setDevCdn(String cdn) {
+  void setDevCdn(String cdn) {
     if (cdn == null) {
       cdn = "";
     }
@@ -207,7 +212,7 @@
   private Injector httpdInjector;
   private Path runFile;
   private boolean inMemoryTest;
-  private AbstractModule luceneModule;
+  private AbstractModule indexModule;
   private Module emailModule;
   private List<Module> testSysModules = new ArrayList<>();
   private List<Module> testSshModules = new ArrayList<>();
@@ -307,7 +312,7 @@
         RuntimeShutdown.waitFor();
       }
       return 0;
-    } catch (Throwable err) {
+    } catch (RuntimeException err) {
       logger.atSevere().withCause(err).log("Unable to start daemon");
       return 1;
     }
@@ -336,9 +341,13 @@
   }
 
   @VisibleForTesting
-  public void setLuceneModule(LuceneIndexModule m) {
-    luceneModule = m;
-    inMemoryTest = true;
+  public void setIndexModule(AbstractIndexModule m) {
+    indexModule = m;
+  }
+
+  @VisibleForTesting
+  public void setInMemory(boolean inMemory) {
+    this.inMemoryTest = inMemory;
   }
 
   @VisibleForTesting
@@ -420,18 +429,18 @@
     final List<Module> modules = new ArrayList<>();
     modules.add(NoteDbSchemaVersionCheck.module());
     modules.add(new DropWizardMetricMaker.RestModule());
-    modules.add(new LogFileCompressor.Module());
+    modules.add(new LogFileCompressorModule());
 
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
 
-    modules.add(new SubscriptionGraph.Module());
-    modules.add(new SuperprojectUpdateSubmissionListener.Module());
-    modules.add(new WorkQueue.Module());
-    modules.add(new StreamEventsApiListener.Module());
-    modules.add(new EventBroker.Module());
-    modules.add(new JdbcAccountPatchReviewStore.Module(config));
+    modules.add(new SubscriptionGraphModule());
+    modules.add(new SuperprojectUpdateSubmissionListenerModule());
+    modules.add(new WorkQueueModule());
+    modules.add(new StreamEventsApiListenerModule());
+    modules.add(new EventBrokerModule());
+    modules.add(new JdbcAccountPatchReviewStoreModule(config));
     modules.add(new SysExecutorModule());
     modules.add(new DiffExecutorModule());
     modules.add(new MimeUtil2Module());
@@ -439,31 +448,31 @@
     modules.add(new GerritApiModule());
     modules.add(new PluginApiModule());
 
-    modules.add(new SearchingChangeCacheImpl.Module(replica));
-    modules.add(new InternalAccountDirectory.Module());
+    modules.add(new SearchingChangeCacheImplModule(replica));
+    modules.add(new InternalAccountDirectoryModule());
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
-    modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
+    modules.add(cfgInjector.getInstance(MailReceiverModule.class));
     if (emailModule != null) {
       modules.add(emailModule);
     } else {
-      modules.add(new SmtpEmailSender.Module());
+      modules.add(new SmtpEmailSenderModule());
     }
     if (auditEventModule != null) {
       modules.add(auditEventModule);
     } else {
       modules.add(new AuditModule());
     }
-    modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new SignedTokenEmailTokenVerifierModule());
     modules.add(new PluginModule());
     if (VersionManager.getOnlineUpgrade(config)) {
-      modules.add(new OnlineUpgrader.Module());
+      modules.add(new OnlineUpgraderModule());
     }
     modules.add(new OAuthRestModule());
     modules.add(new RestApiModule());
     modules.add(new GpgModule(config));
-    modules.add(new StartupChecks.Module());
+    modules.add(new StartupChecksModule());
     modules.add(new GerritInstanceNameModule());
     modules.add(new GerritInstanceIdModule());
     if (MoreObjects.firstNonNull(httpd, true)) {
@@ -483,7 +492,7 @@
             }
           });
     }
-    modules.add(new DefaultUrlFormatter.Module());
+    modules.add(new DefaultUrlFormatterModule());
     SshSessionFactoryInitializer.init(config);
     if (sshd) {
       modules.add(SshKeyCacheImpl.module());
@@ -505,32 +514,47 @@
         });
     modules.add(new GarbageCollectionModule());
     if (replica) {
-      modules.add(new PeriodicGroupIndexer.Module());
+      modules.add(new PeriodicGroupIndexerModule());
     } else {
-      modules.add(new AccountDeactivator.Module());
-      modules.add(new ChangeCleanupRunner.Module());
+      modules.add(new AccountDeactivatorModule());
+      modules.add(new ChangeCleanupRunnerModule());
     }
-    modules.add(new LocalMergeSuperSetComputation.Module());
-    modules.add(new DefaultProjectNameLockManager.Module());
+    modules.add(new LocalMergeSuperSetComputationModule());
+    modules.add(new DefaultProjectNameLockManagerModule());
 
-    List<Module> libModules = LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE);
+    List<Module> libModules =
+        LibModuleLoader.loadModules(cfgInjector, LibModuleType.SYS_MODULE_TYPE);
+    libModules.addAll(LibModuleLoader.loadModules(cfgInjector, LibModuleType.INDEX_MODULE_TYPE));
     libModules.addAll(testSysModules);
 
     AuthConfig authConfig = cfgInjector.getInstance(AuthConfig.class);
     modules.add(new AuthModule(authConfig));
 
+    modules.add(new ExternalIdCaseSensitivityMigrator.ExternalIdCaseSensitivityMigratorModule());
+
     return cfgInjector.createChildInjector(ModuleOverloader.override(modules, libModules));
   }
 
   private Module createIndexModule() {
-    if (luceneModule != null) {
-      return luceneModule;
+    if (indexModule != null) {
+      return indexModule;
     }
     if (indexType.isLucene()) {
       return LuceneIndexModule.latestVersion(replica, AutoFlush.ENABLED);
     }
-    if (indexType.isElasticsearch()) {
-      return ElasticIndexModule.latestVersion(replica);
+    if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m = clazz.getMethod("latestVersion", boolean.class);
+        return (Module) m.invoke(null, replica);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     }
     throw new IllegalStateException("unsupported index.type = " + indexType);
   }
@@ -551,12 +575,13 @@
         new DefaultCommandModule(
             replica,
             sysInjector.getInstance(DownloadConfig.class),
-            sysInjector.getInstance(LfsPluginAuthCommand.Module.class)));
+            sysInjector.getInstance(LfsPluginAuthCommandModule.class)));
 
     modules.addAll(testSshModules);
     if (!replica) {
       modules.add(new IndexCommandsModule(sysInjector));
       modules.add(new SequenceCommandsModule());
+      modules.add(new ExternalIdCommandsModule());
     }
     return sysInjector.createChildInjector(modules);
   }
@@ -581,14 +606,15 @@
     modules.add(H2CacheBasedWebSession.module());
     modules.add(sysInjector.getInstance(GerritAuthModule.class));
     modules.add(sysInjector.getInstance(GitOverHttpModule.class));
+    modules.add(sysInjector.getInstance(HttpdModule.class));
     if (sshd) {
-      modules.add(new ProjectQoSFilter.Module());
+      modules.add(new ProjectQoSFilterModule());
     }
     modules.add(RequestCleanupFilter.module());
     modules.add(AllRequestFilter.module());
     modules.add(SetThreadNameFilter.module());
     modules.add(sysInjector.getInstance(WebModule.class));
-    modules.add(sysInjector.getInstance(RequireSslFilter.Module.class));
+    modules.add(sysInjector.getInstance(RequireSslFilterModule.class));
     modules.add(new HttpPluginModule());
     if (sshd) {
       modules.add(sshInjector.getInstance(WebSshGlueModule.class));
@@ -604,7 +630,7 @@
       modules.add(new OAuthModule());
     }
 
-    modules.add(sysInjector.getInstance(GetUserFilter.Module.class));
+    modules.add(sysInjector.getInstance(GetUserFilterModule.class));
 
     // StaticModule contains a "/*" wildcard, place it last.
     GerritOptions opts = sysInjector.getInstance(GerritOptions.class);
diff --git a/java/com/google/gerrit/pgm/DeleteZombieDrafts.java b/java/com/google/gerrit/pgm/DeleteZombieDrafts.java
index c08e999..57f8394 100644
--- a/java/com/google/gerrit/pgm/DeleteZombieDrafts.java
+++ b/java/com/google/gerrit/pgm/DeleteZombieDrafts.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs;
-import com.google.gerrit.server.notedb.DeleteZombieCommentsRefs.Factory;
 import com.google.gerrit.server.schema.SchemaModule;
 import com.google.gerrit.server.securestore.SecureStoreClassName;
 import com.google.inject.AbstractModule;
@@ -54,7 +53,7 @@
     mustHaveValidSite();
     Injector sysInjector = getSysInjector();
     DeleteZombieCommentsRefs cleanup =
-        sysInjector.getInstance(Factory.class).create(cleanupPercentage);
+        sysInjector.getInstance(DeleteZombieCommentsRefs.Factory.class).create(cleanupPercentage);
     cleanup.execute();
     return 0;
   }
diff --git a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index 8e2f70f..f651994 100644
--- a/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllUsersName;
@@ -52,6 +53,7 @@
   @Inject private AllUsersName allUsersName;
   @Inject private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
   @Inject private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
   @Inject private ExternalIds externalIds;
 
   @Override
@@ -105,7 +107,7 @@
       String localUserLowerCase = localUser.toLowerCase(Locale.US);
       if (!localUser.equals(localUserLowerCase)) {
         ExternalId extIdLowerCase =
-            ExternalId.create(
+            externalIdFactory.create(
                 SCHEME_GERRIT,
                 localUserLowerCase,
                 extId.accountId(),
diff --git a/java/com/google/gerrit/pgm/Reindex.java b/java/com/google/gerrit/pgm/Reindex.java
index 701c94b..c4e185d 100644
--- a/java/com/google/gerrit/pgm/Reindex.java
+++ b/java/com/google/gerrit/pgm/Reindex.java
@@ -20,7 +20,6 @@
 import com.google.common.cache.Cache;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
-import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.index.Index;
@@ -31,6 +30,8 @@
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
 import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.server.LibModuleLoader;
+import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.cache.CacheDisplay;
 import com.google.gerrit.server.cache.CacheInfo;
 import com.google.gerrit.server.change.ChangeResource;
@@ -49,6 +50,8 @@
 import com.google.inject.multibindings.OptionalBinder;
 import java.io.StringWriter;
 import java.io.Writer;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -178,9 +181,21 @@
       indexModule =
           LuceneIndexModule.singleVersionWithExplicitVersions(
               versions, threads, replica, AutoFlush.DISABLED);
-    } else if (indexType.isElasticsearch()) {
-      indexModule =
-          ElasticIndexModule.singleVersionWithExplicitVersions(versions, threads, replica);
+    } else if (indexType.isFake()) {
+      // Use Reflection so that we can omit the fake index binary in production code. Test code does
+      // compile the component in.
+      try {
+        Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModule");
+        Method m =
+            clazz.getMethod(
+                "singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+        indexModule = (Module) m.invoke(null, versions, threads, replica);
+      } catch (NoSuchMethodException
+          | ClassNotFoundException
+          | IllegalAccessException
+          | InvocationTargetException e) {
+        throw new IllegalStateException("can't create index", e);
+      }
     } else {
       throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -204,7 +219,9 @@
           }
         });
 
-    return dbInjector.createChildInjector(modules);
+    return dbInjector.createChildInjector(
+        ModuleOverloader.override(
+            modules, LibModuleLoader.loadReindexModules(cfgInjector, versions, threads, replica)));
   }
 
   private void overrideConfig() {
diff --git a/java/com/google/gerrit/pgm/SetPasswd.java b/java/com/google/gerrit/pgm/SetPasswd.java
index c6ece21..c3f6a7b 100644
--- a/java/com/google/gerrit/pgm/SetPasswd.java
+++ b/java/com/google/gerrit/pgm/SetPasswd.java
@@ -16,13 +16,12 @@
 
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.pgm.init.api.Section.Factory;
 import com.google.inject.Inject;
 
 public class SetPasswd {
 
   private ConsoleUI ui;
-  private Factory sections;
+  private Section.Factory sections;
 
   @Inject
   public SetPasswd(ConsoleUI ui, Section.Factory sections) {
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 89b4228..6f3514f 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -547,7 +547,7 @@
           filterHolder.setInitParameters(initParams);
         }
         app.addFilter(filterHolder, "/*", EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC));
-      } catch (Throwable e) {
+      } catch (Exception e) {
         throw new IllegalArgumentException(
             "Unable to instantiate front-end HTTP Filter " + filterClassName, e);
       }
diff --git a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index 1cca789..1a5b4c4 100644
--- a/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -71,7 +71,7 @@
   private static final String FILTER_RE = "^/(.*)/(git-upload-pack|git-receive-pack)$";
   private static final Pattern URI_PATTERN = Pattern.compile(FILTER_RE);
 
-  public static class Module extends ServletModule {
+  public static class ProjectQoSFilterModule extends ServletModule {
     @Override
     protected void configureServlets() {
       bind(QueueProvider.class).to(CommandExecutorQueueProvider.class);
diff --git a/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
index 536ddcd..d9e3a6a 100644
--- a/java/com/google/gerrit/pgm/init/AccountsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -22,9 +22,9 @@
 import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountProperties;
 import com.google.gerrit.server.account.Accounts;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import java.io.File;
@@ -69,7 +69,7 @@
 
       Config accountConfig = new Config();
       AccountProperties.writeToAccountConfig(
-          InternalAccountUpdate.builder()
+          AccountDelta.builder()
               .setActive(!account.inactive())
               .setFullName(account.fullName())
               .setPreferredEmail(account.preferredEmail())
diff --git a/java/com/google/gerrit/pgm/init/BUILD b/java/com/google/gerrit/pgm/init/BUILD
index 62c9526..5849711 100644
--- a/java/com/google/gerrit/pgm/init/BUILD
+++ b/java/com/google/gerrit/pgm/init/BUILD
@@ -9,7 +9,6 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/common:server",
-        "//java/com/google/gerrit/elasticsearch",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/exceptions",
         "//java/com/google/gerrit/extensions:api",
diff --git a/java/com/google/gerrit/pgm/init/BaseInit.java b/java/com/google/gerrit/pgm/init/BaseInit.java
index 62ff66a..c1f5753 100644
--- a/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.pgm.init.api.InstallPlugins;
 import com.google.gerrit.pgm.init.api.LibraryDownload;
 import com.google.gerrit.pgm.init.index.IndexManagerOnInit;
-import com.google.gerrit.pgm.init.index.elasticsearch.ElasticIndexModuleOnInit;
+import com.google.gerrit.pgm.init.index.IndexModuleOnInit;
 import com.google.gerrit.pgm.init.index.lucene.LuceneIndexModuleOnInit;
 import com.google.gerrit.pgm.util.SiteProgram;
 import com.google.gerrit.server.config.GerritServerConfigModule;
@@ -57,6 +57,7 @@
 import com.google.inject.util.Providers;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.lang.reflect.InvocationTargetException;
 import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -162,7 +163,6 @@
    * Invoked before site init is called.
    *
    * @param init initializer instance.
-   * @throws Exception
    */
   protected boolean beforeInit(SiteInit init) throws Exception {
     return false;
@@ -172,7 +172,6 @@
    * Invoked after site init is called.
    *
    * @param run completed run instance.
-   * @throws Exception
    */
   protected void afterInit(SiteRun run) throws Exception {}
 
@@ -416,8 +415,19 @@
       IndexType indexType = IndexModule.getIndexType(dbInjector);
       if (indexType.isLucene()) {
         modules.add(new LuceneIndexModuleOnInit());
-      } else if (indexType.isElasticsearch()) {
-        modules.add(new ElasticIndexModuleOnInit());
+      } else if (indexType.isFake()) {
+        try {
+          Class<?> clazz = Class.forName("com.google.gerrit.index.testing.FakeIndexModuleOnInit");
+          Module indexOnInitModule = (Module) clazz.getDeclaredConstructor().newInstance();
+          modules.add(indexOnInitModule);
+        } catch (InstantiationException
+            | IllegalAccessException
+            | ClassNotFoundException
+            | NoSuchMethodException
+            | InvocationTargetException e) {
+          throw new IllegalStateException("unable to create fake index", e);
+        }
+        modules.add(new IndexModuleOnInit());
       } else {
         throw new IllegalStateException("unsupported index.type = " + indexType);
       }
diff --git a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 9519653..9b4d5bb 100644
--- a/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -18,8 +18,10 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -39,12 +41,21 @@
   private final InitFlags flags;
   private final SitePaths site;
   private final AllUsersName allUsers;
+  private final ExternalIdFactory externalIdFactory;
+  private final AuthConfig authConfig;
 
   @Inject
-  public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+  public ExternalIdsOnInit(
+      InitFlags flags,
+      SitePaths site,
+      AllUsersNameOnInitProvider allUsers,
+      ExternalIdFactory externalIdFactory,
+      AuthConfig authConfig) {
     this.flags = flags;
     this.site = site;
     this.allUsers = new AllUsersName(allUsers.get());
+    this.externalIdFactory = externalIdFactory;
+    this.authConfig = authConfig;
   }
 
   public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
@@ -52,7 +63,12 @@
     File path = getPath();
     if (path != null) {
       try (Repository allUsersRepo = new FileRepository(path)) {
-        ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, allUsersRepo);
+        ExternalIdNotes extIdNotes =
+            ExternalIdNotes.loadNoCacheUpdate(
+                allUsers,
+                allUsersRepo,
+                externalIdFactory,
+                authConfig.isUserNameCaseInsensitiveMigrationMode());
         extIdNotes.insert(extIds);
         try (MetaDataUpdate metaDataUpdate =
             new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, allUsersRepo)) {
diff --git a/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
index 95572b6..2f12abb 100644
--- a/java/com/google/gerrit/pgm/init/GroupsOnInit.java
+++ b/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -34,8 +34,8 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import java.io.File;
 import java.io.IOException;
@@ -139,9 +139,9 @@
     InternalGroup group =
         groupConfig.getLoadedGroup().orElseThrow(() -> new NoSuchGroupException(groupUuid));
 
-    InternalGroupUpdate groupUpdate = getMemberAdditionUpdate(account);
+    GroupDelta groupDelta = getMemberAdditionDelta(account);
     AuditLogFormatter auditLogFormatter = getAuditLogFormatter(account);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     commit(repository, groupConfig, group.getCreatedOn());
   }
@@ -153,8 +153,8 @@
     return RepositoryCache.FileKey.resolve(basePath.resolve(allUsers.get()).toFile(), FS.DETECTED);
   }
 
-  private static InternalGroupUpdate getMemberAdditionUpdate(Account account) {
-    return InternalGroupUpdate.builder()
+  private static GroupDelta getMemberAdditionDelta(Account account) {
+    return GroupDelta.builder()
         .setMemberModification(members -> Sets.union(members, ImmutableSet.of(account.id())))
         .build();
   }
diff --git a/java/com/google/gerrit/pgm/init/InitAdminUser.java b/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 2e32066..d6a0133 100644
--- a/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndex;
@@ -52,6 +53,7 @@
   private final ExternalIdsOnInit externalIds;
   private final SequencesOnInit sequencesOnInit;
   private final GroupsOnInit groupsOnInit;
+  private final ExternalIdFactory externalIdFactory;
   private AccountIndexCollection accountIndexCollection;
   private GroupIndexCollection groupIndexCollection;
 
@@ -63,7 +65,8 @@
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
       ExternalIdsOnInit externalIds,
       SequencesOnInit sequencesOnInit,
-      GroupsOnInit groupsOnInit) {
+      GroupsOnInit groupsOnInit,
+      ExternalIdFactory externalIdFactory) {
     this.flags = flags;
     this.ui = ui;
     this.accounts = accounts;
@@ -71,6 +74,7 @@
     this.externalIds = externalIds;
     this.sequencesOnInit = sequencesOnInit;
     this.groupsOnInit = groupsOnInit;
+    this.externalIdFactory = externalIdFactory;
   }
 
   @Override
@@ -107,10 +111,10 @@
         String email = readEmail(sshKey);
 
         List<ExternalId> extIds = new ArrayList<>(2);
-        extIds.add(ExternalId.createUsername(username, id, httpPassword));
+        extIds.add(externalIdFactory.createUsername(username, id, httpPassword));
 
         if (email != null) {
-          extIds.add(ExternalId.createEmail(id, email));
+          extIds.add(externalIdFactory.createEmail(id, email));
         }
         externalIds.insert("Add external IDs for initial admin user", extIds);
 
diff --git a/java/com/google/gerrit/pgm/init/InitAuth.java b/java/com/google/gerrit/pgm/init/InitAuth.java
index c15cff3..948ec49 100644
--- a/java/com/google/gerrit/pgm/init/InitAuth.java
+++ b/java/com/google/gerrit/pgm/init/InitAuth.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
+import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.mail.SignedToken;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -42,11 +43,13 @@
   private final Section ldap;
   private final Section receive;
   private final InitFlags flags;
+  private final SitePaths site;
 
   @Inject
-  InitAuth(InitFlags flags, ConsoleUI ui, Section.Factory sections) {
+  InitAuth(InitFlags flags, ConsoleUI ui, final SitePaths site, Section.Factory sections) {
     this.flags = flags;
     this.ui = ui;
+    this.site = site;
     this.auth = sections.get("auth", null);
     this.ldap = sections.get("ldap", null);
     this.receive = sections.get(RECEIVE, null);
@@ -62,6 +65,10 @@
     }
 
     initSignedPush();
+
+    if (site.isNew) {
+      initUserNameCaseSensitivity();
+    }
   }
 
   private void initAuthType() {
@@ -156,4 +163,9 @@
     boolean enable = ui.yesno(def, "Enable signed push support");
     receive.set("enableSignedPush", Boolean.toString(enable));
   }
+
+  private void initUserNameCaseSensitivity() {
+    boolean enableCaseInsensitivity = ui.yesno(true, "Use case insensitive usernames");
+    auth.set("userNameCaseInsensitive", Boolean.toString(enableCaseInsensitivity));
+  }
 }
diff --git a/java/com/google/gerrit/pgm/init/InitIndex.java b/java/com/google/gerrit/pgm/init/InitIndex.java
index 83d9261..a6254fd 100644
--- a/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -39,7 +39,6 @@
   private final SitePaths site;
   private final InitFlags initFlags;
   private final Section gerrit;
-  private final Section.Factory sections;
 
   @Inject
   InitIndex(ConsoleUI ui, Section.Factory sections, SitePaths site, InitFlags initFlags) {
@@ -48,7 +47,6 @@
     this.gerrit = sections.get("gerrit", null);
     this.site = site;
     this.initFlags = initFlags;
-    this.sections = sections;
   }
 
   @Override
@@ -58,13 +56,6 @@
         new IndexType(
             index.select("Type", "type", IndexType.getDefault(), IndexType.getKnownTypes()));
 
-    if (type.isElasticsearch()) {
-      Section elasticsearch = sections.get("elasticsearch", null);
-      elasticsearch.string("Index Prefix", "prefix", "gerrit_");
-      elasticsearch.string("Server", "server", "http://localhost:9200");
-      index.string("Result window size", "maxLimit", "10000");
-    }
-
     if ((site.isNew || isEmptySite()) && type.isLucene()) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
         IndexUtils.setReady(site, def.getName(), def.getLatest().getVersion(), true);
diff --git a/java/com/google/gerrit/pgm/init/InitJGitConfig.java b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
index bad55b4..b68e9f7 100644
--- a/java/com/google/gerrit/pgm/init/InitJGitConfig.java
+++ b/java/com/google/gerrit/pgm/init/InitJGitConfig.java
@@ -53,7 +53,8 @@
             ConfigConstants.CONFIG_RECEIVE_SECTION, null, ConfigConstants.CONFIG_KEY_AUTOGC, false);
         jgitConfig.save();
         ui.error(
-            "Auto-configured \"receive.autogc = false\" to disable auto-gc after git-receive-pack.");
+            "Auto-configured \"receive.autogc = false\" to disable auto-gc after"
+                + " git-receive-pack.");
       } else if (jgitConfig.getBoolean(
           ConfigConstants.CONFIG_RECEIVE_SECTION, ConfigConstants.CONFIG_KEY_AUTOGC, true)) {
         ui.error(
@@ -72,12 +73,9 @@
                 ConfigConstants.CONFIG_PROTOCOL_SECTION, null, ConfigConstants.CONFIG_KEY_VERSION);
         if (!TransferConfig.ProtocolVersion.V2.version().equals(version)) {
           ui.error(
-              String.format(
-                  "HINT: JGit option \"%s.%s = %s\". It's recommended to activate git\n"
-                      + "wire protocol version 2 to improve git fetch performance.",
-                  ConfigConstants.CONFIG_PROTOCOL_SECTION,
-                  ConfigConstants.CONFIG_KEY_VERSION,
-                  version));
+              "HINT: JGit option \"%s.%s = %s\". It's recommended to activate git\n"
+                  + "wire protocol version 2 to improve git fetch performance.",
+              ConfigConstants.CONFIG_PROTOCOL_SECTION, ConfigConstants.CONFIG_KEY_VERSION, version);
         }
       }
     } catch (IOException e) {
diff --git a/java/com/google/gerrit/pgm/init/PluginsDistribution.java b/java/com/google/gerrit/pgm/init/PluginsDistribution.java
index 73720c4..65c96ec 100644
--- a/java/com/google/gerrit/pgm/init/PluginsDistribution.java
+++ b/java/com/google/gerrit/pgm/init/PluginsDistribution.java
@@ -24,6 +24,8 @@
 
   public interface Processor {
     /**
+     * Processes the plugin
+     *
      * @param pluginName the name of the plugin (without the .jar extension)
      * @param in the content of the plugin .jar file. Implementors don't have to close this stream.
      * @throws IOException implementations will typically propagate any IOException caused by
diff --git a/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index ddc4f79..236d185 100644
--- a/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.pgm.init.api.Section.Factory;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.inject.Binding;
@@ -46,7 +45,7 @@
   private final InitFlags flags;
   private final SitePaths site;
   private final List<InitStep> steps;
-  private final Factory sectionFactory;
+  private final Section.Factory sectionFactory;
   private final SecureStoreInitData secureStoreInitData;
 
   @Inject
diff --git a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
index 5ca239e..abd7d43 100644
--- a/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
+++ b/java/com/google/gerrit/pgm/init/api/AllProjectsConfig.java
@@ -15,15 +15,16 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.project.GroupList;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
@@ -34,16 +35,18 @@
 public class AllProjectsConfig extends VersionedMetaDataOnInit {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  @Nullable private final StoredConfig baseConfig;
+  private final Optional<StoredConfig> baseConfig;
   private Config cfg;
   private GroupList groupList;
 
   @Inject
-  AllProjectsConfig(AllProjectsNameOnInitProvider allProjects, SitePaths site, InitFlags flags) {
+  AllProjectsConfig(
+      AllProjectsNameOnInitProvider allProjects,
+      AllProjectsConfigProvider allProjectsConfigProvider,
+      SitePaths site,
+      InitFlags flags) {
     super(flags, site, allProjects.get(), RefNames.REFS_CONFIG);
-    this.baseConfig =
-        ProjectConfig.Factory.getBaseConfig(
-            site, new AllProjectsName(allProjects.get()), Project.nameKey(allProjects.get()));
+    this.baseConfig = allProjectsConfigProvider.get(new AllProjectsName(allProjects.get()));
   }
 
   public Config getConfig() {
@@ -62,8 +65,8 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    if (baseConfig != null) {
-      baseConfig.load();
+    if (baseConfig.isPresent()) {
+      baseConfig.get().load();
     }
     groupList = readGroupList();
     cfg = readConfig(ProjectConfig.PROJECT_CONFIG, baseConfig);
diff --git a/java/com/google/gerrit/pgm/init/api/BUILD b/java/com/google/gerrit/pgm/init/api/BUILD
index 693d319..733b9e3 100644
--- a/java/com/google/gerrit/pgm/init/api/BUILD
+++ b/java/com/google/gerrit/pgm/init/api/BUILD
@@ -11,6 +11,7 @@
         "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index ea39a44..dffdde7 100644
--- a/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.pgm.init.api;
 
+import com.google.errorprone.annotations.FormatMethod;
+import com.google.errorprone.annotations.FormatString;
 import com.google.gerrit.common.Die;
 import java.io.Console;
 import java.util.EnumSet;
@@ -37,7 +39,7 @@
     return new Die("aborted by user");
   }
 
-  /** @return true if this is a batch UI that has no user interaction. */
+  /** Returns true if this is a batch UI that has no user interaction. */
   public abstract boolean isBatch();
 
   /** Display a header message before a series of prompts. */
@@ -75,6 +77,7 @@
   public abstract String password(String fmt, Object... args);
 
   /** Display an error message on the system stderr. */
+  @FormatMethod
   public void error(String format, Object... args) {
     System.err.println(String.format(format, args));
     System.err.flush();
@@ -97,6 +100,7 @@
     }
 
     @Override
+    @FormatMethod
     public boolean yesno(Boolean def, String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
@@ -135,7 +139,8 @@
     }
 
     @Override
-    public String readString(String def, String fmt, Object... args) {
+    @FormatMethod
+    public String readString(String def, @FormatString String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       String r;
       if (def != null) {
@@ -154,7 +159,9 @@
     }
 
     @Override
-    public String readString(String def, Set<String> allowedValues, String fmt, Object... args) {
+    @FormatMethod
+    public String readString(
+        String def, Set<String> allowedValues, @FormatString String fmt, Object... args) {
       for (; ; ) {
         String r = readString(def, fmt, args);
         if (allowedValues.contains(r.toLowerCase())) {
@@ -171,6 +178,7 @@
     }
 
     @Override
+    @FormatMethod
     public String password(String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
       for (; ; ) {
@@ -195,6 +203,7 @@
     }
 
     @Override
+    @FormatMethod
     public <T extends Enum<?>, A extends EnumSet<? extends T>> T readEnum(
         T def, A options, String fmt, Object... args) {
       final String prompt = String.format(fmt, args);
diff --git a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
index a937c4b..8e69eb9 100644
--- a/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
+++ b/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.init.api;
 
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
@@ -41,6 +42,18 @@
   }
 
   @Override
+  public Status getRepositoryStatus(NameKey name) {
+    try {
+      openRepository(name);
+    } catch (RepositoryNotFoundException e) {
+      return Status.NON_EXISTENT;
+    } catch (IOException e) {
+      return Status.UNAVAILABLE;
+    }
+    return Status.ACTIVE;
+  }
+
+  @Override
   public Repository openRepository(Project.NameKey name)
       throws RepositoryNotFoundException, IOException {
     return new FileRepository(getPath(name));
diff --git a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
index 80de1e5..d1d0729 100644
--- a/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
+++ b/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -50,8 +50,7 @@
 
   @Override
   protected void configure() {
-    // The AccountIndex implementations (LuceneAccountIndex and
-    // ElasticAccountIndex) need AccountCache only for reading from the index.
+    // The LuceneAccountIndex needs AccountCache only for reading from the index.
     // On init we only want to write to the index, hence we don't need the
     // account cache.
     bind(AccountCache.class).toProvider(Providers.of(null));
@@ -63,8 +62,8 @@
 
     bind(AccountIndexCollection.class);
 
-    // The GroupIndex implementations (LuceneGroupIndex and ElasticGroupIndex)
-    // need GroupCache only for reading from the index. On init we only want to
+    // The LuceneGroupIndex needs GroupCache only for reading from the index. On init we only want
+    // to
     // write to the index, hence we don't need the group cache.
     bind(GroupCache.class).toProvider(Providers.of(null));
 
diff --git a/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 71aec5b..9bd3067 100644
--- a/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -39,7 +39,8 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
-import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
+import com.google.gerrit.server.approval.ApprovalCacheImpl;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -52,7 +53,7 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.CanonicalWebUrlProvider;
 import com.google.gerrit.server.config.DefaultPreferencesCacheImpl;
-import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecordProvider;
 import com.google.gerrit.server.config.GitReceivePackGroups;
@@ -69,6 +70,7 @@
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
+import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.permissions.SectionSortCache;
@@ -77,12 +79,16 @@
 import com.google.gerrit.server.project.CommitResource;
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.restapi.group.GroupModule;
-import com.google.gerrit.server.rules.DefaultSubmitRule;
-import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
+import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -112,7 +118,8 @@
     modules.add(new SysExecutorModule());
     modules.add(BatchUpdate.module());
     modules.add(PatchListCacheImpl.module());
-    modules.add(new DefaultUrlFormatter.Module());
+    modules.add(new DefaultUrlFormatterModule());
+    modules.add(DiffOperationsImpl.module());
 
     // There is the concept of LifecycleModule, in Gerrit's own extension to Guice, which has these:
     //  listener().to(SomeClassImplementingLifecycleListener.class);
@@ -165,10 +172,12 @@
     modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
-    modules.add(new ExternalIdModule());
+    modules.add(new ExternalIdCacheModule());
     modules.add(new GroupModule());
     modules.add(new NoteDbModule());
     modules.add(AccountCacheImpl.module());
+    modules.add(ApprovalCacheImpl.module());
+    modules.add(ConflictsCacheImpl.module());
     modules.add(DefaultPreferencesCacheImpl.module());
     modules.add(GroupCacheImpl.module());
     modules.add(GroupIncludeCacheImpl.module());
@@ -179,17 +188,23 @@
     modules.add(ServiceUserClassifierImpl.module());
     modules.add(TagCache.module());
     modules.add(PureRevertCache.module());
+    modules.add(new ApprovalModule());
+    modules.add(SubmitRequirementsEvaluatorImpl.module());
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
     factory(ProjectState.Factory.class);
 
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeIsOperandFactory.class);
+
     // Submit rules
     DynamicSet.setOf(binder(), SubmitRule.class);
     factory(SubmitRuleEvaluator.Factory.class);
     modules.add(new PrologModule());
-    modules.add(new DefaultSubmitRule.Module());
-    modules.add(new IgnoreSelfApprovalRule.Module());
+    modules.add(new DefaultSubmitRuleModule());
+    modules.add(new IgnoreSelfApprovalRuleModule());
 
     bind(ChangeJson.Factory.class).toProvider(Providers.of(null));
     bind(EventUtil.class).toProvider(Providers.of(null));
@@ -199,7 +214,8 @@
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
 
     ModuleOverloader.override(
-            modules, LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE))
+            modules,
+            LibModuleLoader.loadModules(parentInjector, LibModuleType.SYS_BATCH_MODULE_TYPE))
         .stream()
         .forEach(this::install);
   }
diff --git a/java/com/google/gerrit/pgm/util/LogFileCompressor.java b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
index 413e0fa..5e49312 100644
--- a/java/com/google/gerrit/pgm/util/LogFileCompressor.java
+++ b/java/com/google/gerrit/pgm/util/LogFileCompressor.java
@@ -42,7 +42,7 @@
 public class LogFileCompressor implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends LifecycleModule {
+  public static class LogFileCompressorModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(Lifecycle.class);
diff --git a/java/com/google/gerrit/pgm/util/SiteProgram.java b/java/com/google/gerrit/pgm/util/SiteProgram.java
index c3be0a4..ff0b31e 100644
--- a/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,7 +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.experiments.ConfigExperimentFeatures.ConfigExperimentFeaturesModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
 import com.google.gerrit.server.git.SystemReaderInstaller;
 import com.google.gerrit.server.schema.SchemaModule;
@@ -52,7 +52,7 @@
       name = "--site-path",
       aliases = {"-d"},
       usage = "Local directory containing site data")
-  private void setSitePath(String path) {
+  void setSitePath(String path) {
     sitePath = Paths.get(path).normalize();
   }
 
@@ -64,7 +64,7 @@
     this.sitePath = sitePath.normalize();
   }
 
-  /** @return the site path specified on the command line. */
+  /** Returns the site path specified on the command line. */
   protected Path getSitePath() {
     return sitePath;
   }
@@ -76,12 +76,12 @@
     }
   }
 
-  /** @return provides database connectivity and site path. */
+  /** Provides database connectivity and site path. */
   protected Injector createDbInjector() {
     return createDbInjector(false);
   }
 
-  /** @return provides database connectivity and site path. */
+  /** Provides database connectivity and site path. */
   protected Injector createDbInjector(boolean enableMetrics) {
     List<Module> modules = new ArrayList<>();
 
@@ -131,13 +131,13 @@
     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());
+    modules.add(new ConfigExperimentFeaturesModule());
 
     try {
       return Guice.createInjector(
           PRODUCTION,
           ModuleOverloader.override(
-              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE)));
+              modules, LibModuleLoader.loadModules(cfgInjector, LibModuleType.DB_MODULE_TYPE)));
     } catch (CreationException ce) {
       Message first = ce.getErrorMessages().iterator().next();
       Throwable why = first.getCause();
diff --git a/java/com/google/gerrit/server/BUILD b/java/com/google/gerrit/server/BUILD
index 404906d..7080417 100644
--- a/java/com/google/gerrit/server/BUILD
+++ b/java/com/google/gerrit/server/BUILD
@@ -55,6 +55,7 @@
         "//java/com/google/gerrit/proto",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/serialize/entities",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/data",
         "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
@@ -131,6 +132,7 @@
         "//lib/ow2:ow2-asm-util",
         "//lib/prolog:runtime",
         "//proto:cache_java_proto",
+        "//proto:entities_java_proto",
     ],
 )
 
diff --git a/java/com/google/gerrit/server/CancellationMetrics.java b/java/com/google/gerrit/server/CancellationMetrics.java
new file mode 100644
index 0000000..f534ccb
--- /dev/null
+++ b/java/com/google/gerrit/server/CancellationMetrics.java
@@ -0,0 +1,117 @@
+// 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;
+
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+/** Metrics for request cancellations and deadlines. */
+@Singleton
+public class CancellationMetrics {
+  private final Counter3<String, String, String> advisoryDeadlineCount;
+  private final Counter3<String, String, RequestStateProvider.Reason> cancelledRequestsCount;
+  private final Counter1<String> receiveTimeoutCount;
+
+  @Inject
+  CancellationMetrics(MetricMaker metrics) {
+    this.advisoryDeadlineCount =
+        metrics.newCounter(
+            "cancellation/advisory_deadline_count",
+            new Description("Exceeded advisory deadlines by request").setRate(),
+            Field.ofString("request_type", Metadata.Builder::requestType)
+                .description("The type of the request to which the advisory deadline applied.")
+                .build(),
+            Field.ofString("request_uri", Metadata.Builder::restViewName)
+                .description(
+                    "The redacted URI of the request to which the advisory deadline applied"
+                        + " (only set for request_type = REST).")
+                .build(),
+            Field.ofString("deadline_id", (metadataBuilder, resolveAllUsers) -> {})
+                .description("The ID of the advisory deadline.")
+                .build());
+
+    this.cancelledRequestsCount =
+        metrics.newCounter(
+            "cancellation/cancelled_requests_count",
+            new Description("Number of request cancellations by request").setRate(),
+            Field.ofString("request_type", Metadata.Builder::requestType)
+                .description("The type of the request that was cancelled.")
+                .build(),
+            Field.ofString("request_uri", Metadata.Builder::restViewName)
+                .description(
+                    "The redacted URI of the request that was cancelled"
+                        + " (only set for request_type = REST).")
+                .build(),
+            Field.ofEnum(
+                    RequestStateProvider.Reason.class,
+                    "cancellation_reason",
+                    Metadata.Builder::cancellationReason)
+                .description("The reason why the request was cancelled.")
+                .build());
+
+    this.receiveTimeoutCount =
+        metrics.newCounter(
+            "cancellation/receive_timeout_count",
+            new Description(
+                    "Number of requests that are cancelled because receive.timout is exceeded")
+                .setRate(),
+            Field.ofString("cancellation_type", (metadataBuilder, resolveAllUsers) -> {})
+                .description("The cancellation type (graceful or forceful).")
+                .build());
+  }
+
+  public void countAdvisoryDeadline(RequestInfo requestInfo, String deadlineId) {
+    advisoryDeadlineCount.increment(
+        requestInfo.requestType(), requestInfo.redactedRequestUri().orElse(""), deadlineId);
+  }
+
+  public void countCancelledRequest(
+      RequestInfo requestInfo, RequestStateProvider.Reason cancellationReason) {
+    cancelledRequestsCount.increment(
+        requestInfo.requestType(), requestInfo.redactedRequestUri().orElse(""), cancellationReason);
+  }
+
+  public void countCancelledRequest(
+      RequestInfo.RequestType requestType,
+      String requestUri,
+      RequestStateProvider.Reason cancellationReason) {
+    cancelledRequestsCount.increment(
+        requestType.name(), RequestInfo.redactRequestUri(requestUri), cancellationReason);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public void countCancelledRequest(
+      String requestType,
+      String redactedRequestUri,
+      RequestStateProvider.Reason cancellationReason) {
+    cancelledRequestsCount.increment(requestType, redactedRequestUri, cancellationReason);
+  }
+
+  public void countGracefulReceiveTimeout() {
+    receiveTimeoutCount.increment("graceful");
+  }
+
+  public void countForcefulReceiveTimeout() {
+    receiveTimeoutCount.increment("forceful");
+  }
+}
diff --git a/java/com/google/gerrit/server/ChangeMessagesUtil.java b/java/com/google/gerrit/server/ChangeMessagesUtil.java
index 32edadb..8366b09 100644
--- a/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,8 +14,7 @@
 
 package com.google.gerrit.server;
 
-import static com.google.common.base.Preconditions.checkState;
-import static java.util.Objects.requireNonNull;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
@@ -26,12 +25,12 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
 import java.util.List;
-import java.util.Objects;
 
-/** Utility functions to manipulate ChangeMessages. */
+/** Utility functions to manipulate {@link ChangeMessage}. */
 @Singleton
 public class ChangeMessagesUtil {
   public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
@@ -68,41 +67,50 @@
   public static final String TAG_UPLOADED_WIP_PATCH_SET =
       AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "newWipPatchSet";
 
-  public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
-    return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
+  private final AccountTemplateUtil accountTemplateUtil;
+
+  @Inject
+  ChangeMessagesUtil(AccountTemplateUtil accountTemplateUtil) {
+    this.accountTemplateUtil = accountTemplateUtil;
   }
 
-  public static ChangeMessage newMessage(
-      PatchSet.Id psId, CurrentUser user, Timestamp when, String body, @Nullable String tag) {
-    requireNonNull(psId);
-    Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
-    ChangeMessage m =
-        new ChangeMessage(
-            ChangeMessage.key(psId.changeId(), ChangeUtil.messageUuid()), accountId, when, psId);
-    m.setMessage(body);
-    m.setTag(tag);
-    user.updateRealAccountId(m::setRealAuthor);
-    return m;
+  /**
+   * Sets {@code messageTemplate} and {@code tag}, that should be applied by the {@code update}.
+   *
+   * <p>The {@code messageTemplate} is persisted in storage and should not contain user identifiable
+   * information. See {@link ChangeMessage}.
+   *
+   * @param update update that sets {@code messageTemplate}.
+   * @param messageTemplate message in template form, that should be applied by the update.
+   * @param tag tag that should be applied by the update.
+   * @return message built from {@code messageTemplate}. Templates are replaced, so it might contain
+   *     user identifiable information.
+   */
+  public String setChangeMessage(
+      ChangeUpdate update, String messageTemplate, @Nullable String tag) {
+    update.setChangeMessage(messageTemplate);
+    update.setTag(tag);
+    return accountTemplateUtil.replaceTemplates(messageTemplate);
+  }
+
+  /** See {@link #setChangeMessage(ChangeUpdate, String, String)}. */
+  public String setChangeMessage(ChangeContext ctx, String messageTemplate, @Nullable String tag) {
+    return setChangeMessage(
+        ctx.getUpdate(ctx.getChange().currentPatchSetId()), messageTemplate, tag);
   }
 
   public static String uploadedPatchSetTag(boolean workInProgress) {
     return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
   }
 
+  /**
+   * Returns {@link ChangeMessage}s from {@link ChangeNotes}, loads {@link ChangeNotes} from data
+   * storage (cache or NoteDB), if it was not loaded yet.
+   */
   public List<ChangeMessage> byChange(ChangeNotes notes) {
     return notes.load().getChangeMessages();
   }
 
-  public void addChangeMessage(ChangeUpdate update, ChangeMessage changeMessage) {
-    checkState(
-        Objects.equals(changeMessage.getAuthor(), update.getNullableAccountId()),
-        "cannot store change message by %s in update by %s",
-        changeMessage.getAuthor(),
-        update.getNullableAccountId());
-    update.setChangeMessage(changeMessage.getMessage());
-    update.setTag(changeMessage.getTag());
-  }
-
   /**
    * Replace an existing change message with the provided new message.
    *
@@ -119,8 +127,9 @@
   }
 
   /**
+   * Determines whether the tag starts with the autogenerated prefix
+   *
    * @param tag value of a tag, or null.
-   * @return whether the tag starts with the autogenerated prefix.
    */
   public static boolean isAutogenerated(@Nullable String tag) {
     return tag != null && tag.startsWith(AUTOGENERATED_TAG_PREFIX);
@@ -144,6 +153,25 @@
     if (realAuthor != null) {
       cmi.realAuthor = accountLoader.get(realAuthor);
     }
+    cmi.accountsInMessage =
+        AccountTemplateUtil.parseTemplates(message.getMessage()).stream()
+            .map(accountLoader::get)
+            .collect(toImmutableSet());
     return cmi;
   }
+
+  /**
+   * {@link ChangeMessage} is served in template form to {@link
+   * com.google.gerrit.extensions.api.changes.ChangeApi}. Serve message with replaced templates to
+   * the legacy {@link com.google.gerrit.extensions.api.changes.ChangeMessageApi} endpoints.
+   * TODO(mariasavtchouk): remove this, after {@link
+   * com.google.gerrit.extensions.api.changes.ChangeMessageApi} is deprecated (gate with
+   * experiment).
+   */
+  public ChangeMessageInfo createChangeMessageInfoWithReplacedTemplates(
+      ChangeMessage message, AccountLoader accountLoader) {
+    ChangeMessageInfo changeMessageInfo = createChangeMessageInfo(message, accountLoader);
+    changeMessageInfo.message = accountTemplateUtil.replaceTemplates(message.getMessage());
+    return changeMessageInfo;
+  }
 }
diff --git a/java/com/google/gerrit/server/ChangeUtil.java b/java/com/google/gerrit/server/ChangeUtil.java
index 46e8d33..d9edf42 100644
--- a/java/com/google/gerrit/server/ChangeUtil.java
+++ b/java/com/google/gerrit/server/ChangeUtil.java
@@ -53,7 +53,7 @@
   public static final Ordering<PatchSet> PS_ID_ORDER =
       Ordering.from(comparingInt(PatchSet::number));
 
-  /** @return a new unique identifier for change message entities. */
+  /** Returns a new unique identifier for change message entities. */
   public static String messageUuid() {
     byte[] buf = new byte[8];
     UUID_RANDOM.nextBytes(buf);
diff --git a/java/com/google/gerrit/server/CommentsUtil.java b/java/com/google/gerrit/server/CommentsUtil.java
index b752791..ba9f6d6 100644
--- a/java/com/google/gerrit/server/CommentsUtil.java
+++ b/java/com/google/gerrit/server/CommentsUtil.java
@@ -42,8 +42,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -52,6 +53,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -108,21 +110,21 @@
 
   private static final Ordering<Comparable<?>> NULLS_FIRST = Ordering.natural().nullsFirst();
 
+  private final DiffOperations diffOperations;
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final String serverId;
-  private final PatchListCache patchListCache;
 
   @Inject
   CommentsUtil(
+      DiffOperations diffOperations,
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
-      @GerritServerId String serverId,
-      PatchListCache patchListCache) {
+      @GerritServerId String serverId) {
+    this.diffOperations = diffOperations;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.serverId = serverId;
-    this.patchListCache = patchListCache;
   }
 
   public HumanComment newHumanComment(
@@ -411,7 +413,7 @@
         int parentNumber = Math.abs(side);
         return resolveParentCommit(change.getProject(), patchset, parentNumber);
       }
-      return Optional.of(resolveAutoMergeCommit(change, patchset));
+      return Optional.ofNullable(resolveAutoMergeCommit(change, patchset));
     }
     return Optional.of(patchset.commitId());
   }
@@ -429,12 +431,18 @@
     }
   }
 
+  @Nullable
   private ObjectId resolveAutoMergeCommit(Change change, PatchSet patchset) {
     try {
       // TODO(ghareeb): Adjust after the auto-merge code was moved out of the diff caches. Also
       // unignore the test in PortedCommentsIT.
-      return patchListCache.getOldId(change, patchset, null);
-    } catch (PatchListNotAvailableException e) {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              change.getProject(), patchset.commitId(), /* parentNum= */ 0);
+      return modifiedFiles.isEmpty()
+          ? null
+          : modifiedFiles.values().iterator().next().oldCommitId();
+    } catch (DiffNotAvailableException e) {
       throw new StorageException(e);
     }
   }
@@ -442,8 +450,6 @@
   /**
    * Get NoteDb draft refs for a change.
    *
-   * <p>Works if NoteDb is not enabled, but the results are not meaningful.
-   *
    * <p>This is just a simple ref scan, so the results may potentially include refs for zombie draft
    * comments. A zombie draft is one which has been published but the write to delete the draft ref
    * from All-Users failed.
diff --git a/java/com/google/gerrit/server/CurrentUser.java b/java/com/google/gerrit/server/CurrentUser.java
index 7012944..0b5600d 100644
--- a/java/com/google/gerrit/server/CurrentUser.java
+++ b/java/com/google/gerrit/server/CurrentUser.java
@@ -103,7 +103,7 @@
     return Optional.empty();
   }
 
-  /** @return unique name of the user for logging, never {@code null} */
+  /** Returns unique name of the user for logging, never {@code null} */
   public String getLoggableName() {
     return getUserName().orElseGet(() -> getClass().getSimpleName());
   }
diff --git a/java/com/google/gerrit/server/DeadlineChecker.java b/java/com/google/gerrit/server/DeadlineChecker.java
new file mode 100644
index 0000000..f41b1e3
--- /dev/null
+++ b/java/com/google/gerrit/server/DeadlineChecker.java
@@ -0,0 +1,336 @@
+// 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;
+
+import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.MINUTES;
+import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.primitives.Longs;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * {@link RequestStateProvider} that checks whether a client provided deadline is exceeded.
+ *
+ * <p>Should be registered at most once per request.
+ */
+public class DeadlineChecker implements RequestStateProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static String SECTION_DEADLINE = "deadline";
+
+  /**
+   * Creates a formatter that formats a timeout as {@code <TIMEOUT_NAME>=<TIMEOUT><TIME_UNIT>}.
+   *
+   * <p>If the timeout is 1 minute or greater, minutes is used as a time unit. Otherwise
+   * milliseconds is just as a time unit.
+   *
+   * @param timeoutName the name of the timeout
+   */
+  public static Function<Long, String> getTimeoutFormatter(String timeoutName) {
+    requireNonNull(timeoutName, "timeoutName");
+    return timeout -> {
+      String formattedTimeout = MILLISECONDS.convert(timeout, NANOSECONDS) + "ms";
+      long timeoutInMinutes = MINUTES.convert(timeout, NANOSECONDS);
+      if (timeoutInMinutes > 0) {
+        formattedTimeout = timeoutInMinutes + "m";
+      }
+      return String.format("%s=%s", timeoutName, formattedTimeout);
+    };
+  }
+
+  public interface Factory {
+    DeadlineChecker create(RequestInfo requestInfo, @Nullable String clientProvidedTimeoutValue)
+        throws InvalidDeadlineException;
+
+    DeadlineChecker create(
+        long start, RequestInfo requestInfo, @Nullable String clientProvidedTimeoutValue)
+        throws InvalidDeadlineException;
+  }
+
+  private final CancellationMetrics cancellationsMetrics;
+
+  /** The start time of the request in nanoseconds. */
+  private final long start;
+
+  private final RequestInfo requestInfo;
+  private final RequestStateProvider.Reason cancellationReason;
+  private final String timeoutName;
+
+  /**
+   * Timeout in nanoseconds after which the request should be aborted.
+   *
+   * <p>{@code 0} means that no timeout should be applied.
+   */
+  private final long timeout;
+
+  /**
+   * The deadline in nanoseconds after which a request should be aborted.
+   *
+   * <p>deadline = start + timeout
+   *
+   * <p>{@link Optional#empty()} if no timeout was set.
+   */
+  private final Optional<Long> deadline;
+
+  /**
+   * Matching server side deadlines that have been configured as as advisory.
+   *
+   * <p>If any of these deadlines is exceeded the request is not be aborted. Instead the {@code
+   * cancellation/advisory_deadline_count} metric is incremented and a log is written.
+   */
+  private final Map<String, ServerDeadline> advisoryDeadlines;
+
+  /**
+   * Creates a {@link DeadlineChecker}.
+   *
+   * <p>No deadline is enforced if the client provided deadline value is {@code null} or {@code 0}.
+   *
+   * @param requestInfo the request that was received from a user
+   * @param clientProvidedTimeoutValue the timeout value that the client provided, must represent a
+   *     numerical time unit (e.g. "5m"), if no time unit is specified milliseconds are assumed, may
+   *     be {@code null}
+   * @throws InvalidDeadlineException thrown if the client provided deadline value cannot be parsed,
+   *     e.g. because it uses a bad time unit
+   */
+  @AssistedInject
+  DeadlineChecker(
+      @GerritServerConfig Config serverConfig,
+      CancellationMetrics cancellationsMetrics,
+      @Assisted RequestInfo requestInfo,
+      @Assisted @Nullable String clientProvidedTimeoutValue)
+      throws InvalidDeadlineException {
+    this(
+        serverConfig,
+        cancellationsMetrics,
+        TimeUtil.nowNanos(),
+        requestInfo,
+        clientProvidedTimeoutValue);
+  }
+
+  /**
+   * Creates a {@link DeadlineChecker}.
+   *
+   * <p>No deadline is enforced if the client provided deadline value is {@code null} or {@code 0}.
+   *
+   * @param start the start time of the request in nanoseconds
+   * @param requestInfo the request that was received from a user
+   * @param clientProvidedTimeoutValue the timeout value that the client provided, must represent a
+   *     numerical time unit (e.g. "5m"), if no time unit is specified milliseconds are assumed, may
+   *     be {@code null}
+   * @throws InvalidDeadlineException thrown if the client provided deadline value cannot be parsed,
+   *     e.g. because it uses a bad time unit
+   */
+  @AssistedInject
+  DeadlineChecker(
+      @GerritServerConfig Config serverConfig,
+      CancellationMetrics cancellationsMetrics,
+      @Assisted long start,
+      @Assisted RequestInfo requestInfo,
+      @Assisted @Nullable String clientProvidedTimeoutValue)
+      throws InvalidDeadlineException {
+    this.cancellationsMetrics = cancellationsMetrics;
+    this.start = start;
+    this.requestInfo = requestInfo;
+
+    ImmutableList<RequestConfig> deadlineConfigs =
+        RequestConfig.parseConfigs(serverConfig, SECTION_DEADLINE);
+    advisoryDeadlines = getAdvisoryDeadlines(deadlineConfigs, requestInfo);
+    Optional<ServerDeadline> serverSideDeadline =
+        getServerSideDeadline(deadlineConfigs, requestInfo);
+    Optional<Long> clientedProvidedTimeout = parseTimeout(clientProvidedTimeoutValue);
+    logDeadlines(serverSideDeadline, clientedProvidedTimeout);
+
+    this.cancellationReason =
+        clientedProvidedTimeout.isPresent()
+            ? RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED
+            : RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED;
+    this.timeoutName =
+        clientedProvidedTimeout
+            .map(clientTimeout -> "client.timeout")
+            .orElse(
+                serverSideDeadline
+                    .map(serverDeadline -> serverDeadline.id() + ".timeout")
+                    .orElse("timeout"));
+    this.timeout =
+        clientedProvidedTimeout.orElse(serverSideDeadline.map(ServerDeadline::timeout).orElse(0L));
+    this.deadline = timeout > 0 ? Optional.of(start + timeout) : Optional.empty();
+  }
+
+  private void logDeadlines(
+      Optional<ServerDeadline> serverSideDeadline, Optional<Long> clientedProvidedTimeout) {
+    if (serverSideDeadline.isPresent()) {
+      if (clientedProvidedTimeout.isPresent()) {
+        logger.atFine().log(
+            "client provided deadline (timeout=%sms) overrides server deadline %s (timeout=%sms)",
+            TimeUnit.MILLISECONDS.convert(clientedProvidedTimeout.get(), TimeUnit.NANOSECONDS),
+            serverSideDeadline.get().id(),
+            TimeUnit.MILLISECONDS.convert(
+                serverSideDeadline.get().timeout(), TimeUnit.NANOSECONDS));
+      } else {
+        logger.atFine().log(
+            "applying server deadline %s (timeout = %sms)",
+            serverSideDeadline.get().id(),
+            TimeUnit.MILLISECONDS.convert(
+                serverSideDeadline.get().timeout(), TimeUnit.NANOSECONDS));
+      }
+    } else if (clientedProvidedTimeout.isPresent()) {
+      logger.atFine().log(
+          "applying client provided deadline (timeout = %sms)",
+          TimeUnit.MILLISECONDS.convert(clientedProvidedTimeout.get(), TimeUnit.NANOSECONDS));
+    }
+  }
+
+  private Optional<ServerDeadline> getServerSideDeadline(
+      ImmutableList<RequestConfig> deadlineConfigs, RequestInfo requestInfo) {
+    return deadlineConfigs.stream()
+        .filter(deadlineConfig -> deadlineConfig.matches(requestInfo))
+        .map(ServerDeadline::readFrom)
+        .filter(ServerDeadline::hasTimeout)
+        .filter(deadline -> !deadline.isAdvisory())
+        // let the stricter deadline (the lower deadline) take precedence
+        .sorted(comparing(ServerDeadline::timeout))
+        .findFirst();
+  }
+
+  private Map<String, ServerDeadline> getAdvisoryDeadlines(
+      ImmutableList<RequestConfig> deadlineConfigs, RequestInfo requestInfo) {
+    return deadlineConfigs.stream()
+        .filter(deadlineConfig -> deadlineConfig.matches(requestInfo))
+        .map(ServerDeadline::readFrom)
+        .filter(ServerDeadline::hasTimeout)
+        .filter(ServerDeadline::isAdvisory)
+        .collect(toMap(ServerDeadline::id, Function.identity()));
+  }
+
+  @Override
+  public void checkIfCancelled(OnCancelled onCancelled) {
+    long now = TimeUtil.nowNanos();
+
+    Set<String> exceededAdvisoryDeadlines = new HashSet<>();
+    advisoryDeadlines
+        .values()
+        .forEach(
+            advisoryDeadline -> {
+              if (now > start + advisoryDeadline.timeout()) {
+                exceededAdvisoryDeadlines.add(advisoryDeadline.id());
+                logger.atFine().log(
+                    "advisory deadline exceeded (%s)",
+                    getTimeoutFormatter(advisoryDeadline.id() + ".timeout")
+                        .apply(advisoryDeadline.timeout()));
+                cancellationsMetrics.countAdvisoryDeadline(requestInfo, advisoryDeadline.id());
+              }
+            });
+    // remove advisory deadlines which have already been reported as exceeded so that they don't get
+    // reported again for this request
+    exceededAdvisoryDeadlines.forEach(advisoryDeadlines::remove);
+
+    if (deadline.isPresent() && now > deadline.get()) {
+      onCancelled.onCancel(cancellationReason, getTimeoutFormatter(timeoutName).apply(timeout));
+    }
+  }
+
+  /**
+   * Parses the given timeout value.
+   *
+   * @param timeoutValue the timeout that should be parsed, must represent a numerical time unit
+   *     (e.g. "5m"), if no time unit is specified minutes are assumed, may be {@code null}
+   * @return the parsed timeout in nanoseconds, {@code 0} if no timeout should be applied
+   * @throws InvalidDeadlineException thrown if the provided deadline value cannot be parsed, e.g.
+   *     because it uses a bad time unit
+   */
+  private static Optional<Long> parseTimeout(@Nullable String timeoutValue)
+      throws InvalidDeadlineException {
+    if (Strings.isNullOrEmpty(timeoutValue)) {
+      return Optional.empty();
+    }
+
+    if ("0".equals(timeoutValue)) {
+      return Optional.of(0L);
+    }
+
+    // If no time unit was specified, assume milliseconds.
+    if (Longs.tryParse(timeoutValue) != null) {
+      throw new InvalidDeadlineException(String.format("Missing time unit: %s", timeoutValue));
+    }
+
+    try {
+      long parsedTimeout =
+          ConfigUtil.getTimeUnit(timeoutValue, /* defaultValue= */ -1, TimeUnit.NANOSECONDS);
+      if (parsedTimeout == -1) {
+        throw new InvalidDeadlineException(String.format("Invalid value: %s", timeoutValue));
+      }
+      return Optional.of(parsedTimeout);
+    } catch (IllegalArgumentException e) {
+      throw new InvalidDeadlineException(e.getMessage(), e);
+    }
+  }
+
+  @AutoValue
+  abstract static class ServerDeadline {
+    abstract String id();
+
+    abstract long timeout();
+
+    abstract boolean isAdvisory();
+
+    boolean hasTimeout() {
+      return timeout() > 0;
+    }
+
+    static ServerDeadline readFrom(RequestConfig requestConfig) {
+      String timeoutValue =
+          requestConfig.cfg().getString(requestConfig.section(), requestConfig.id(), "timeout");
+      boolean isAdvisory =
+          requestConfig
+              .cfg()
+              .getBoolean(
+                  requestConfig.section(),
+                  requestConfig.id(),
+                  "isAdvisory",
+                  /* defaultValue= */ false);
+      try {
+        Optional<Long> timeout = parseTimeout(timeoutValue);
+        return new AutoValue_DeadlineChecker_ServerDeadline(
+            requestConfig.id(), timeout.orElse(0L), isAdvisory);
+      } catch (InvalidDeadlineException e) {
+        logger.atWarning().log(
+            "Ignoring invalid deadline configuration %s.%s.timeout: %s",
+            requestConfig.section(), requestConfig.id(), e.getMessage());
+        return new AutoValue_DeadlineChecker_ServerDeadline(requestConfig.id(), 0, isAdvisory);
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/IdentifiedUser.java b/java/com/google/gerrit/server/IdentifiedUser.java
index 24ea9d2..eb3e324 100644
--- a/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/java/com/google/gerrit/server/IdentifiedUser.java
@@ -343,15 +343,15 @@
   }
 
   /**
-   * @return the user's user name; null if one has not been selected/assigned or if the user name is
-   *     empty.
+   * Returns the user's user name; null if one has not been selected/assigned or if the user name is
+   * empty.
    */
   @Override
   public Optional<String> getUserName() {
     return state().userName();
   }
 
-  /** @return unique name of the user for logging, never {@code null} */
+  /** Returns unique name of the user for logging, never {@code null} */
   @Override
   public String getLoggableName() {
     return getUserName()
diff --git a/java/com/google/gerrit/server/InvalidDeadlineException.java b/java/com/google/gerrit/server/InvalidDeadlineException.java
new file mode 100644
index 0000000..d23b289
--- /dev/null
+++ b/java/com/google/gerrit/server/InvalidDeadlineException.java
@@ -0,0 +1,30 @@
+// 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;
+
+/** Exception that is thrown is a deadline cannot be parsed. */
+public class InvalidDeadlineException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  private static final String MESSAGE_PREFIX = "Invalid deadline. ";
+
+  public InvalidDeadlineException(String message) {
+    super(MESSAGE_PREFIX + message);
+  }
+
+  public InvalidDeadlineException(String message, Throwable cause) {
+    super(MESSAGE_PREFIX + message, cause);
+  }
+}
diff --git a/java/com/google/gerrit/server/LibModuleLoader.java b/java/com/google/gerrit/server/LibModuleLoader.java
index 0a6fb9f..36765e9 100644
--- a/java/com/google/gerrit/server/LibModuleLoader.java
+++ b/java/com/google/gerrit/server/LibModuleLoader.java
@@ -22,8 +22,10 @@
 import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.ProvisionException;
+import java.lang.reflect.Method;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
 /** Loads configured Guice modules from {@code gerrit.installModule}. */
@@ -37,6 +39,33 @@
         .collect(toList());
   }
 
+  public static List<Module> loadReindexModules(
+      Injector parent, Map<String, Integer> versions, int threads, boolean replica) {
+    Config cfg = getConfig(parent);
+    return Arrays.stream(
+            cfg.getStringList(
+                "gerrit", null, "install" + LibModuleType.INDEX_MODULE_TYPE.getConfigKey()))
+        .map(m -> createReindexModule(m, versions, threads, replica))
+        .collect(toList());
+  }
+
+  private static Module createReindexModule(
+      String className, Map<String, Integer> versions, int threads, boolean replica) {
+    Class<Module> clazz = loadModule(className);
+    try {
+
+      Method m =
+          clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class, boolean.class);
+
+      Module module = (Module) m.invoke(null, versions, threads, replica);
+      logger.atInfo().log("Installed module %s", className);
+      return module;
+    } catch (Exception e) {
+      logger.atSevere().withCause(e).log("Unable to load libModule for %s", className);
+      throw new IllegalStateException(e);
+    }
+  }
+
   private static Config getConfig(Injector i) {
     return i.getInstance(Key.get(Config.class, GerritServerConfig.class));
   }
diff --git a/java/com/google/gerrit/server/LibModuleType.java b/java/com/google/gerrit/server/LibModuleType.java
index b9cb196..57206aa 100644
--- a/java/com/google/gerrit/server/LibModuleType.java
+++ b/java/com/google/gerrit/server/LibModuleType.java
@@ -18,13 +18,16 @@
 public enum LibModuleType {
 
   /** Module for the sysInjector. */
-  SYS_MODULE("Module"),
+  SYS_MODULE_TYPE("Module"),
 
   /** BatchModule for the sysInjector */
-  SYS_BATCH_MODULE("BatchModule"),
+  SYS_BATCH_MODULE_TYPE("BatchModule"),
 
   /** Module for the dbInjector. */
-  DB_MODULE("DbModule");
+  DB_MODULE_TYPE("DbModule"),
+
+  /** Module for the implementation of the indexing backend. */
+  INDEX_MODULE_TYPE("IndexModule");
 
   private final String configKey;
 
diff --git a/java/com/google/gerrit/server/PatchSetUtil.java b/java/com/google/gerrit/server/PatchSetUtil.java
index 005ae3b..326ddf4 100644
--- a/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/java/com/google/gerrit/server/PatchSetUtil.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -149,10 +150,11 @@
         projectCache.get(notes.getProjectName()).orElseThrow(illegalState(notes.getProjectName()));
 
     ApprovalsUtil approvalsUtil = approvalsUtilProvider.get();
-    for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(notes, change.currentPatchSetId(), null, null)) {
-      LabelType type = projectState.getLabelTypes(notes).byLabel(ap.label());
-      if (type != null && ap.value() == 1 && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
+    for (PatchSetApproval ap : approvalsUtil.byPatchSet(notes, change.currentPatchSetId())) {
+      Optional<LabelType> type = projectState.getLabelTypes(notes).byLabel(ap.label());
+      if (type.isPresent()
+          && ap.value() == 1
+          && type.get().getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
diff --git a/java/com/google/gerrit/server/PerformanceMetrics.java b/java/com/google/gerrit/server/PerformanceMetrics.java
new file mode 100644
index 0000000..845ed80
--- /dev/null
+++ b/java/com/google/gerrit/server/PerformanceMetrics.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.server;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.PerformanceLogger;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.concurrent.TimeUnit;
+
+/** Performance logger that records the execution times as a metric. */
+@Singleton
+public class PerformanceMetrics implements PerformanceLogger {
+  private static final String OPERATION_LATENCY_METRIC_NAME = "performance/operations";
+  private static final String OPERATION_COUNT_METRIC_NAME = "performance/operations_count";
+
+  public final Timer3<String, String, String> operationsLatency;
+  public final Counter3<String, String, String> operationsCounter;
+
+  @Inject
+  PerformanceMetrics(MetricMaker metricMaker) {
+    Field<String> operationNameField =
+        Field.ofString(
+                "operation_name",
+                (metadataBuilder, fieldValue) -> metadataBuilder.operationName(fieldValue))
+            .description("The operation that was performed.")
+            .build();
+    Field<String> requestField =
+        Field.ofString("request", (metadataBuilder, fieldValue) -> {})
+            .description(
+                "The request for which the operation was performed"
+                    + " (format = '<request-type> <redacted-request-uri>').")
+            .build();
+    Field<String> pluginField =
+        Field.ofString(
+                "plugin", (metadataBuilder, fieldValue) -> metadataBuilder.pluginName(fieldValue))
+            .description("The name of the plugin that performed the operation.")
+            .build();
+
+    this.operationsLatency =
+        metricMaker
+            .newTimer(
+                OPERATION_LATENCY_METRIC_NAME,
+                new Description("Latency of performing operations")
+                    .setCumulative()
+                    .setUnit(Description.Units.MILLISECONDS),
+                operationNameField,
+                requestField,
+                pluginField)
+            .suppressLogging();
+    this.operationsCounter =
+        metricMaker.newCounter(
+            OPERATION_COUNT_METRIC_NAME,
+            new Description("Number of performed operations").setRate(),
+            operationNameField,
+            requestField,
+            pluginField);
+  }
+
+  @Override
+  public void log(String operation, long durationMs) {
+    log(operation, durationMs, /* metadata= */ null);
+  }
+
+  @Override
+  public void log(String operation, long durationMs, @Nullable Metadata metadata) {
+    String requestTag = TraceContext.getTag(TraceRequestListener.TAG_REQUEST).orElse("");
+    String pluginTag = TraceContext.getPluginTag().orElse("");
+    operationsLatency.record(operation, requestTag, pluginTag, durationMs, TimeUnit.MILLISECONDS);
+    operationsCounter.increment(operation, requestTag, pluginTag);
+  }
+}
diff --git a/java/com/google/gerrit/server/PublishCommentsOp.java b/java/com/google/gerrit/server/PublishCommentsOp.java
index 358ce92..84afe8c 100644
--- a/java/com/google/gerrit/server/PublishCommentsOp.java
+++ b/java/com/google/gerrit/server/PublishCommentsOp.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
@@ -49,7 +49,6 @@
 public class PublishCommentsOp implements BatchUpdateOp {
   private final PatchSetUtil psUtil;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeMessagesUtil cmUtil;
   private final CommentAdded commentAdded;
   private final CommentsUtil commentsUtil;
   private final EmailReviewComments.Factory email;
@@ -57,9 +56,10 @@
   private final Project.NameKey projectNameKey;
   private final PatchSet.Id psId;
   private final PublishCommentUtil publishCommentUtil;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   private List<HumanComment> comments = new ArrayList<>();
-  private ChangeMessage message;
+  private String mailMessage;
   private IdentifiedUser user;
 
   public interface Factory {
@@ -69,15 +69,14 @@
   @Inject
   public PublishCommentsOp(
       ChangeNotes.Factory changeNotesFactory,
-      ChangeMessagesUtil cmUtil,
       CommentAdded commentAdded,
       CommentsUtil commentsUtil,
       EmailReviewComments.Factory email,
       PatchSetUtil psUtil,
       PublishCommentUtil publishCommentUtil,
+      ChangeMessagesUtil changeMessagesUtil,
       @Assisted PatchSet.Id psId,
       @Assisted Project.NameKey projectNameKey) {
-    this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.commentAdded = commentAdded;
     this.commentsUtil = commentsUtil;
@@ -86,6 +85,7 @@
     this.publishCommentUtil = publishCommentUtil;
     this.psUtil = psUtil;
     this.projectNameKey = projectNameKey;
+    this.changeMessagesUtil = changeMessagesUtil;
   }
 
   @Override
@@ -104,12 +104,12 @@
     // We do it this way so that the execution results in 2 different commits in NoteDb
     ChangeUpdate changeUpdate = ctx.getDistinctUpdate(psId);
     publishCommentUtil.publish(ctx, changeUpdate, comments, null);
-    return insertMessage(ctx, changeUpdate);
+    return insertMessage(changeUpdate);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    if (message == null || comments.isEmpty()) {
+  public void postUpdate(PostUpdateContext ctx) {
+    if (Strings.isNullOrEmpty(mailMessage) || comments.isEmpty()) {
       return;
     }
     ChangeNotes changeNotes = changeNotesFactory.createChecked(projectNameKey, psId.changeId());
@@ -124,20 +124,30 @@
             String.format("Repository %s not found", ctx.getProject().get()), ex);
       }
       email
-          .create(notify, changeNotes, ps, user, message, comments, null, labelDelta, repoView)
+          .create(
+              notify,
+              changeNotes,
+              ps,
+              user,
+              mailMessage,
+              ctx.getWhen(),
+              comments,
+              null,
+              labelDelta,
+              repoView)
           .sendAsync();
     }
     commentAdded.fire(
-        changeNotes.getChange(),
+        ctx.getChangeData(changeNotes),
         ps,
         ctx.getAccount(),
-        message.getMessage(),
+        mailMessage,
         ImmutableMap.of(),
         ImmutableMap.of(),
         ctx.getWhen());
   }
 
-  private boolean insertMessage(ChangeContext ctx, ChangeUpdate changeUpdate) {
+  private boolean insertMessage(ChangeUpdate changeUpdate) {
     StringBuilder buf = new StringBuilder();
     if (comments.size() == 1) {
       buf.append("\n\n(1 comment)");
@@ -147,10 +157,9 @@
     if (buf.length() == 0) {
       return false;
     }
-    message =
-        ChangeMessagesUtil.newMessage(
-            psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, null);
-    cmUtil.addChangeMessage(changeUpdate, message);
+    mailMessage =
+        changeMessagesUtil.setChangeMessage(
+            changeUpdate, "Patch Set " + psId.get() + ":" + buf, null);
     return true;
   }
 }
diff --git a/java/com/google/gerrit/server/RequestCleanup.java b/java/com/google/gerrit/server/RequestCleanup.java
index f405c57..e07d148 100644
--- a/java/com/google/gerrit/server/RequestCleanup.java
+++ b/java/com/google/gerrit/server/RequestCleanup.java
@@ -44,7 +44,7 @@
       for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
         try {
           i.next().run();
-        } catch (Throwable err) {
+        } catch (Exception err) {
           logger.atSevere().withCause(err).log("Failed to execute per-request cleanup");
         }
         i.remove();
diff --git a/java/com/google/gerrit/server/RequestConfig.java b/java/com/google/gerrit/server/RequestConfig.java
new file mode 100644
index 0000000..83cea5b
--- /dev/null
+++ b/java/com/google/gerrit/server/RequestConfig.java
@@ -0,0 +1,227 @@
+// 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;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import java.util.Optional;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Represents a configuration on request level that matches requests by request type, URI pattern,
+ * caller and/or project pattern.
+ */
+@AutoValue
+public abstract class RequestConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static ImmutableList<RequestConfig> parseConfigs(Config cfg, String section) {
+    ImmutableList.Builder<RequestConfig> requestConfigs = ImmutableList.builder();
+
+    for (String id : cfg.getSubsections(section)) {
+      try {
+        RequestConfig.Builder requestConfig = RequestConfig.builder(cfg, section, id);
+        requestConfig.requestTypes(parseRequestTypes(cfg, section, id));
+        requestConfig.requestUriPatterns(parseRequestUriPatterns(cfg, section, id));
+        requestConfig.excludedRequestUriPatterns(parseExcludedRequestUriPatterns(cfg, section, id));
+        requestConfig.accountIds(parseAccounts(cfg, section, id));
+        requestConfig.projectPatterns(parseProjectPatterns(cfg, section, id));
+        requestConfigs.add(requestConfig.build());
+      } catch (ConfigInvalidException e) {
+        logger.atWarning().log("Ignoring invalid %s configuration:\n %s", section, e.getMessage());
+      }
+    }
+
+    return requestConfigs.build();
+  }
+
+  private static ImmutableSet<String> parseRequestTypes(Config cfg, String section, String id) {
+    return ImmutableSet.copyOf(cfg.getStringList(section, id, "requestType"));
+  }
+
+  private static ImmutableSet<Pattern> parseRequestUriPatterns(
+      Config cfg, String section, String id) throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "requestUriPattern");
+  }
+
+  private static ImmutableSet<Pattern> parseExcludedRequestUriPatterns(
+      Config cfg, String section, String id) throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "excludedRequestUriPattern");
+  }
+
+  private static ImmutableSet<Account.Id> parseAccounts(Config cfg, String section, String id)
+      throws ConfigInvalidException {
+    ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
+    String[] accounts = cfg.getStringList(section, id, "account");
+    for (String account : accounts) {
+      Optional<Account.Id> accountId = Account.Id.tryParse(account);
+      if (!accountId.isPresent()) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid request config ('%s.%s.account = %s'): invalid account ID",
+                section, id, account));
+      }
+      accountIds.add(accountId.get());
+    }
+    return accountIds.build();
+  }
+
+  private static ImmutableSet<Pattern> parseProjectPatterns(Config cfg, String section, String id)
+      throws ConfigInvalidException {
+    return parsePatterns(cfg, section, id, "projectPattern");
+  }
+
+  private static ImmutableSet<Pattern> parsePatterns(
+      Config cfg, String section, String id, String name) throws ConfigInvalidException {
+    ImmutableSet.Builder<Pattern> patterns = ImmutableSet.builder();
+    String[] patternRegExs = cfg.getStringList(section, id, name);
+    for (String patternRegEx : patternRegExs) {
+      try {
+        patterns.add(Pattern.compile(patternRegEx));
+      } catch (PatternSyntaxException e) {
+        throw new ConfigInvalidException(
+            String.format(
+                "Invalid request config ('%s.%s.%s = %s'): %s",
+                section, id, name, patternRegEx, e.getMessage()));
+      }
+    }
+    return patterns.build();
+  }
+
+  /** the config from which this request config was read */
+  abstract Config cfg();
+
+  /** the section from which this request config was read */
+  abstract String section();
+
+  /** ID of the config, also the subsection from which this request config was read */
+  abstract String id();
+
+  /** request types that should be matched */
+  abstract ImmutableSet<String> requestTypes();
+
+  /** pattern matching request URIs */
+  abstract ImmutableSet<Pattern> requestUriPatterns();
+
+  /** pattern matching request URIs to be excluded */
+  abstract ImmutableSet<Pattern> excludedRequestUriPatterns();
+
+  /** accounts IDs matching calling user */
+  abstract ImmutableSet<Account.Id> accountIds();
+
+  /** pattern matching projects names */
+  abstract ImmutableSet<Pattern> projectPatterns();
+
+  private static Builder builder(Config cfg, String section, String id) {
+    return new AutoValue_RequestConfig.Builder().cfg(cfg).section(section).id(id);
+  }
+
+  /**
+   * Whether this request config matches a given request.
+   *
+   * @param requestInfo request info
+   * @return whether this request config matches
+   */
+  boolean matches(RequestInfo requestInfo) {
+    // If in the request config request types are set and none of them matches, then the request is
+    // not matched.
+    if (!requestTypes().isEmpty()
+        && requestTypes().stream()
+            .noneMatch(type -> type.equalsIgnoreCase(requestInfo.requestType()))) {
+      return false;
+    }
+
+    // If in the request config request URI patterns are set and none of them matches, then the
+    // request is not matched.
+    if (!requestUriPatterns().isEmpty()) {
+      if (!requestInfo.requestUri().isPresent()) {
+        // The request has no request URI, hence it cannot match a request URI pattern.
+        return false;
+      }
+
+      if (requestUriPatterns().stream()
+          .noneMatch(p -> p.matcher(requestInfo.requestUri().get()).matches())) {
+        return false;
+      }
+    }
+
+    // If the request URI matches an excluded request URI pattern, then the request is not matched.
+    if (requestInfo.requestUri().isPresent()
+        && excludedRequestUriPatterns().stream()
+            .anyMatch(p -> p.matcher(requestInfo.requestUri().get()).matches())) {
+      return false;
+    }
+
+    // If in the request config accounts are set and none of them matches, then the request is not
+    // matched.
+    if (!accountIds().isEmpty()) {
+      try {
+        if (accountIds().stream()
+            .noneMatch(id -> id.equals(requestInfo.callingUser().getAccountId()))) {
+          return false;
+        }
+      } catch (UnsupportedOperationException e) {
+        // The calling user is not logged in, hence it cannot match an account.
+        return false;
+      }
+    }
+
+    // If in the request config project patterns are set and none of them matches, then the request
+    // is not matched.
+    if (!projectPatterns().isEmpty()) {
+      if (!requestInfo.project().isPresent()) {
+        // The request is not for a project, hence it cannot match a project pattern.
+        return false;
+      }
+
+      if (projectPatterns().stream()
+          .noneMatch(p -> p.matcher(requestInfo.project().get().get()).matches())) {
+        return false;
+      }
+    }
+
+    // For any match criteria (request type, request URI pattern, account, project pattern) that
+    // was specified in the request config, at least one of the configured value matched the
+    // request.
+    return true;
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+    abstract Builder cfg(Config cfg);
+
+    abstract Builder section(String section);
+
+    abstract Builder id(String id);
+
+    abstract Builder requestTypes(ImmutableSet<String> requestTypes);
+
+    abstract Builder requestUriPatterns(ImmutableSet<Pattern> requestUriPatterns);
+
+    abstract Builder excludedRequestUriPatterns(ImmutableSet<Pattern> excludedRequestUriPatterns);
+
+    abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
+
+    abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
+
+    abstract RequestConfig build();
+  }
+}
diff --git a/java/com/google/gerrit/server/RequestInfo.java b/java/com/google/gerrit/server/RequestInfo.java
index f369239..791e228 100644
--- a/java/com/google/gerrit/server/RequestInfo.java
+++ b/java/com/google/gerrit/server/RequestInfo.java
@@ -14,7 +14,13 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
+import com.google.common.base.Splitter;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.logging.TraceContext;
 import java.util.Optional;
@@ -54,6 +60,16 @@
    */
   public abstract Optional<String> requestUri();
 
+  /**
+   * Redacted request URI.
+   *
+   * <p>Request URI where resource IDs are replaced by '*'.
+   */
+  @Memoized
+  public Optional<String> redactedRequestUri() {
+    return requestUri().map(RequestInfo::redactRequestUri);
+  }
+
   /** The user that has sent the request. */
   public abstract CurrentUser callingUser();
 
@@ -67,12 +83,75 @@
    */
   public abstract Optional<Project.NameKey> project();
 
+  @Memoized
+  public String formatForLogging() {
+    StringBuilder sb = new StringBuilder();
+    sb.append(requestType());
+    redactedRequestUri().ifPresent(redactedRequestUri -> sb.append(' ').append(redactedRequestUri));
+    return sb.toString();
+  }
+
+  /**
+   * Redacts resource IDs from the given request URI.
+   *
+   * <p>resource IDs in the request URI are replaced with '*'.
+   *
+   * @param requestUri a REST URI that has path segments that alternate between view name and
+   *     resource IDs (e.g. "/<view>", "/<view>/<id>", "/<view>/<id>/<view>",
+   *     "/<view>/<id>/<view>/<id>", "/<view>/<id>/<view>/<id>/<view>" etc.), must be given without
+   *     the '/a' prefix
+   * @return the redacted request URI
+   */
+  static String redactRequestUri(String requestUri) {
+    requireNonNull(requestUri, "requestUri");
+    checkState(
+        !requestUri.startsWith("/a/"), "request URI must not start with '/a/': %s", requestUri);
+
+    StringBuilder redactedRequestUri = new StringBuilder();
+
+    boolean hasLeadingSlash = false;
+    boolean hasTrailingSlash = false;
+    if (requestUri.startsWith("/")) {
+      hasLeadingSlash = true;
+      requestUri = requestUri.substring(1);
+    }
+    if (requestUri.endsWith("/")) {
+      hasTrailingSlash = true;
+      requestUri = requestUri.substring(0, requestUri.length() - 1);
+    }
+
+    boolean idPathSegment = false;
+    for (String pathSegment : Splitter.on('/').split(requestUri)) {
+      if (!idPathSegment) {
+        redactedRequestUri.append("/" + pathSegment);
+        idPathSegment = true;
+      } else {
+        redactedRequestUri.append("/");
+        if (!pathSegment.isEmpty()) {
+          redactedRequestUri.append("*");
+        }
+        idPathSegment = false;
+      }
+    }
+
+    if (!hasLeadingSlash) {
+      redactedRequestUri.deleteCharAt(0);
+    }
+    if (hasTrailingSlash) {
+      redactedRequestUri.append('/');
+    }
+
+    return redactedRequestUri.toString();
+  }
+
   public static RequestInfo.Builder builder(
       RequestType requestType, CurrentUser callingUser, TraceContext traceContext) {
-    return new AutoValue_RequestInfo.Builder()
-        .requestType(requestType)
-        .callingUser(callingUser)
-        .traceContext(traceContext);
+    return builder().requestType(requestType).callingUser(callingUser).traceContext(traceContext);
+  }
+
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public static RequestInfo.Builder builder() {
+    return new AutoValue_RequestInfo.Builder();
   }
 
   @AutoValue.Builder
diff --git a/java/com/google/gerrit/server/StarredChangesUtil.java b/java/com/google/gerrit/server/StarredChangesUtil.java
index 904490c..f7193b7 100644
--- a/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -27,7 +27,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
@@ -57,7 +56,6 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Optional;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
@@ -161,8 +159,6 @@
 
   public static final String DEFAULT_LABEL = "star";
   public static final String IGNORE_LABEL = "ignore";
-  public static final String REVIEWED_LABEL = "reviewed";
-  public static final String UNREVIEWED_LABEL = "unreviewed";
   public static final ImmutableSortedSet<String> DEFAULT_LABELS =
       ImmutableSortedSet.of(DEFAULT_LABEL);
 
@@ -350,40 +346,6 @@
     return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
   }
 
-  private static String getReviewedLabel(Change change) {
-    return getReviewedLabel(change.currentPatchSetId().get());
-  }
-
-  private static String getReviewedLabel(int ps) {
-    return REVIEWED_LABEL + "/" + ps;
-  }
-
-  private static String getUnreviewedLabel(Change change) {
-    return getUnreviewedLabel(change.currentPatchSetId().get());
-  }
-
-  private static String getUnreviewedLabel(int ps) {
-    return UNREVIEWED_LABEL + "/" + ps;
-  }
-
-  public void markAsReviewed(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(getReviewedLabel(rsrc.getChange())),
-        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())));
-  }
-
-  public void markAsUnreviewed(ChangeResource rsrc) throws IllegalLabelException {
-    star(
-        rsrc.getUser().asIdentifiedUser().getAccountId(),
-        rsrc.getProject(),
-        rsrc.getChange().getId(),
-        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())),
-        ImmutableSet.of(getReviewedLabel(rsrc.getChange())));
-  }
-
   public static StarRef readLabels(Repository repo, String refName) throws IOException {
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
@@ -428,23 +390,6 @@
     if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
       throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
     }
-
-    Set<Integer> reviewedPatchSets = getStarredPatchSets(labels, REVIEWED_LABEL);
-    Set<Integer> unreviewedPatchSets = getStarredPatchSets(labels, UNREVIEWED_LABEL);
-    Optional<Integer> ps =
-        Sets.intersection(reviewedPatchSets, unreviewedPatchSets).stream().findFirst();
-    if (ps.isPresent()) {
-      throw new MutuallyExclusiveLabelsException(
-          getReviewedLabel(ps.get()), getUnreviewedLabel(ps.get()));
-    }
-  }
-
-  public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
-    return labels.stream()
-        .filter(l -> l.startsWith(label + "/"))
-        .filter(l -> Ints.tryParse(l.substring(label.length() + 1)) != null)
-        .map(l -> Integer.valueOf(l.substring(label.length() + 1)))
-        .collect(toSet());
   }
 
   private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
diff --git a/java/com/google/gerrit/server/StartupChecks.java b/java/com/google/gerrit/server/StartupChecks.java
index 9bf94ae..3b9892b 100644
--- a/java/com/google/gerrit/server/StartupChecks.java
+++ b/java/com/google/gerrit/server/StartupChecks.java
@@ -25,7 +25,7 @@
 
 @Singleton
 public class StartupChecks implements LifecycleListener {
-  public static class Module extends LifecycleModule {
+  public static class StartupChecksModule extends LifecycleModule {
     @Override
     protected void configure() {
       DynamicSet.setOf(binder(), StartupCheck.class);
diff --git a/java/com/google/gerrit/server/TraceRequestListener.java b/java/com/google/gerrit/server/TraceRequestListener.java
index 20c9f57..6cc0982 100644
--- a/java/com/google/gerrit/server/TraceRequestListener.java
+++ b/java/com/google/gerrit/server/TraceRequestListener.java
@@ -14,19 +14,11 @@
 
 package com.google.gerrit.server;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.Optional;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -36,20 +28,22 @@
  */
 @Singleton
 public class TraceRequestListener implements RequestListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  public static String TAG_REQUEST = "request";
 
-  private final Config cfg;
-  private final ImmutableList<TraceConfig> traceConfigs;
+  private static String TAG_PROJECT = "project";
+  private static String SECTION_TRACING = "tracing";
+
+  private final ImmutableList<RequestConfig> traceConfigs;
 
   @Inject
   TraceRequestListener(@GerritServerConfig Config cfg) {
-    this.cfg = cfg;
-    this.traceConfigs = parseTraceConfigs();
+    this.traceConfigs = RequestConfig.parseConfigs(cfg, SECTION_TRACING);
   }
 
   @Override
   public void onRequest(RequestInfo requestInfo) {
-    requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag("project", p));
+    requestInfo.traceContext().addTag(TAG_REQUEST, requestInfo.formatForLogging());
+    requestInfo.project().ifPresent(p -> requestInfo.traceContext().addTag(TAG_PROJECT, p));
     traceConfigs.stream()
         .filter(traceConfig -> traceConfig.matches(requestInfo))
         .forEach(
@@ -57,172 +51,6 @@
                 requestInfo
                     .traceContext()
                     .forceLogging()
-                    .addTag(RequestId.Type.TRACE_ID, traceConfig.traceId()));
-  }
-
-  private ImmutableList<TraceConfig> parseTraceConfigs() {
-    ImmutableList.Builder<TraceConfig> traceConfigs = ImmutableList.builder();
-
-    for (String traceId : cfg.getSubsections("tracing")) {
-      try {
-        TraceConfig.Builder traceConfig = TraceConfig.builder();
-        traceConfig.traceId(traceId);
-        traceConfig.requestTypes(parseRequestTypes(traceId));
-        traceConfig.requestUriPatterns(parseRequestUriPatterns(traceId));
-        traceConfig.accountIds(parseAccounts(traceId));
-        traceConfig.projectPatterns(parseProjectPatterns(traceId));
-        traceConfigs.add(traceConfig.build());
-      } catch (ConfigInvalidException e) {
-        logger.atWarning().log("Ignoring invalid tracing configuration:\n %s", e.getMessage());
-      }
-    }
-
-    return traceConfigs.build();
-  }
-
-  private ImmutableSet<String> parseRequestTypes(String traceId) {
-    return ImmutableSet.copyOf(cfg.getStringList("tracing", traceId, "requestType"));
-  }
-
-  private ImmutableSet<Pattern> parseRequestUriPatterns(String traceId)
-      throws ConfigInvalidException {
-    return parsePatterns(traceId, "requestUriPattern");
-  }
-
-  private ImmutableSet<Account.Id> parseAccounts(String traceId) throws ConfigInvalidException {
-    ImmutableSet.Builder<Account.Id> accountIds = ImmutableSet.builder();
-    String[] accounts = cfg.getStringList("tracing", traceId, "account");
-    for (String account : accounts) {
-      Optional<Account.Id> accountId = Account.Id.tryParse(account);
-      if (!accountId.isPresent()) {
-        throw new ConfigInvalidException(
-            String.format(
-                "Invalid tracing config ('tracing.%s.account = %s'): invalid account ID",
-                traceId, account));
-      }
-      accountIds.add(accountId.get());
-    }
-    return accountIds.build();
-  }
-
-  private ImmutableSet<Pattern> parseProjectPatterns(String traceId) throws ConfigInvalidException {
-    return parsePatterns(traceId, "projectPattern");
-  }
-
-  private ImmutableSet<Pattern> parsePatterns(String traceId, String name)
-      throws ConfigInvalidException {
-    ImmutableSet.Builder<Pattern> patterns = ImmutableSet.builder();
-    String[] patternRegExs = cfg.getStringList("tracing", traceId, name);
-    for (String patternRegEx : patternRegExs) {
-      try {
-        patterns.add(Pattern.compile(patternRegEx));
-      } catch (PatternSyntaxException e) {
-        throw new ConfigInvalidException(
-            String.format(
-                "Invalid tracing config ('tracing.%s.%s = %s'): %s",
-                traceId, name, patternRegEx, e.getMessage()));
-      }
-    }
-    return patterns.build();
-  }
-
-  @AutoValue
-  abstract static class TraceConfig {
-    /** ID for the trace */
-    abstract String traceId();
-
-    /** request types that should be traced */
-    abstract ImmutableSet<String> requestTypes();
-
-    /** pattern matching request URIs */
-    abstract ImmutableSet<Pattern> requestUriPatterns();
-
-    /** accounts IDs matching calling user */
-    abstract ImmutableSet<Account.Id> accountIds();
-
-    /** pattern matching projects names */
-    abstract ImmutableSet<Pattern> projectPatterns();
-
-    static Builder builder() {
-      return new AutoValue_TraceRequestListener_TraceConfig.Builder();
-    }
-
-    /**
-     * Whether this trace config matches a given request.
-     *
-     * @param requestInfo request info
-     * @return whether this trace config matches
-     */
-    boolean matches(RequestInfo requestInfo) {
-      // If in the trace config request types are set and none of them matches, then the request is
-      // not matched.
-      if (!requestTypes().isEmpty()
-          && requestTypes().stream()
-              .noneMatch(type -> type.equalsIgnoreCase(requestInfo.requestType()))) {
-        return false;
-      }
-
-      // If in the trace config request URI patterns are set and none of them matches, then the
-      // request is not matched.
-      if (!requestUriPatterns().isEmpty()) {
-        if (!requestInfo.requestUri().isPresent()) {
-          // The request has no request URI, hence it cannot match a request URI pattern.
-          return false;
-        }
-
-        if (requestUriPatterns().stream()
-            .noneMatch(p -> p.matcher(requestInfo.requestUri().get()).matches())) {
-          return false;
-        }
-      }
-
-      // If in the trace config accounts are set and none of them matches, then the request is not
-      // matched.
-      if (!accountIds().isEmpty()) {
-        try {
-          if (accountIds().stream()
-              .noneMatch(id -> id.equals(requestInfo.callingUser().getAccountId()))) {
-            return false;
-          }
-        } catch (UnsupportedOperationException e) {
-          // The calling user is not logged in, hence it cannot match an account.
-          return false;
-        }
-      }
-
-      // If in the trace config project patterns are set and none of them matches, then the request
-      // is not matched.
-      if (!projectPatterns().isEmpty()) {
-        if (!requestInfo.project().isPresent()) {
-          // The request is not for a project, hence it cannot match a project pattern.
-          return false;
-        }
-
-        if (projectPatterns().stream()
-            .noneMatch(p -> p.matcher(requestInfo.project().get().get()).matches())) {
-          return false;
-        }
-      }
-
-      // For any match criteria (request type, request URI pattern, account, project pattern) that
-      // was specified in the trace config, at least one of the configured value matched the
-      // request.
-      return true;
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder traceId(String traceId);
-
-      abstract Builder requestTypes(ImmutableSet<String> requestTypes);
-
-      abstract Builder requestUriPatterns(ImmutableSet<Pattern> requestUriPatterns);
-
-      abstract Builder accountIds(ImmutableSet<Account.Id> accountIds);
-
-      abstract Builder projectPatterns(ImmutableSet<Pattern> projectPatterns);
-
-      abstract TraceConfig build();
-    }
+                    .addTag(RequestId.Type.TRACE_ID, traceConfig.id()));
   }
 }
diff --git a/java/com/google/gerrit/server/WebLinks.java b/java/com/google/gerrit/server/WebLinks.java
index 785cd1c..2cf2326 100644
--- a/java/com/google/gerrit/server/WebLinks.java
+++ b/java/com/google/gerrit/server/WebLinks.java
@@ -27,11 +27,13 @@
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.WebLink;
 import com.google.inject.Inject;
@@ -55,7 +57,9 @@
       };
 
   private final DynamicSet<PatchSetWebLink> patchSetLinks;
+  private final DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks;
   private final DynamicSet<ParentWebLink> parentLinks;
+  private final DynamicSet<EditWebLink> editLinks;
   private final DynamicSet<FileWebLink> fileLinks;
   private final DynamicSet<FileHistoryWebLink> fileHistoryLinks;
   private final DynamicSet<DiffWebLink> diffLinks;
@@ -66,7 +70,9 @@
   @Inject
   public WebLinks(
       DynamicSet<PatchSetWebLink> patchSetLinks,
+      DynamicSet<ResolveConflictsWebLink> resolveConflictsLinks,
       DynamicSet<ParentWebLink> parentLinks,
+      DynamicSet<EditWebLink> editLinks,
       DynamicSet<FileWebLink> fileLinks,
       DynamicSet<FileHistoryWebLink> fileLogLinks,
       DynamicSet<DiffWebLink> diffLinks,
@@ -74,7 +80,9 @@
       DynamicSet<BranchWebLink> branchLinks,
       DynamicSet<TagWebLink> tagLinks) {
     this.patchSetLinks = patchSetLinks;
+    this.resolveConflictsLinks = resolveConflictsLinks;
     this.parentLinks = parentLinks;
+    this.editLinks = editLinks;
     this.fileLinks = fileLinks;
     this.fileHistoryLinks = fileLogLinks;
     this.diffLinks = diffLinks;
@@ -84,11 +92,12 @@
   }
 
   /**
+   * Returns links for patch sets
+   *
    * @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, String commitMessage, String branchName) {
@@ -98,11 +107,28 @@
   }
 
   /**
+   * Returns links for resolving conflicts
+   *
+   * @param project Project name.
+   * @param commit SHA1 of commit.
+   * @param commitMessage the commit message of the commit.
+   * @param branchName branch of the commit.
+   */
+  public ImmutableList<WebLinkInfo> getResolveConflictsLinks(
+      Project.NameKey project, String commit, String commitMessage, String branchName) {
+    return filterLinks(
+        resolveConflictsLinks,
+        webLink ->
+            webLink.getResolveConflictsWebLink(project.get(), commit, commitMessage, branchName));
+  }
+
+  /**
+   * Returns links for patch sets
+   *
    * @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, String commitMessage, String branchName) {
@@ -112,11 +138,25 @@
   }
 
   /**
+   * Returns links for editing
+   *
+   * @param project Project name.
+   * @param revision SHA1 of revision.
+   * @param file File name.
+   */
+  public ImmutableList<WebLinkInfo> getEditLinks(String project, String revision, String file) {
+    return Patch.isMagic(file)
+        ? ImmutableList.of()
+        : filterLinks(editLinks, webLink -> webLink.getEditWebLink(project, revision, file));
+  }
+
+  /**
+   * Returns links for files
+   *
    * @param project Project name.
    * @param revision Name of the revision (e.g. branch or commit ID)
    * @param hash SHA1 of revision.
    * @param file File name.
-   * @return Links for files.
    */
   public ImmutableList<WebLinkInfo> getFileLinks(
       String project, String revision, String hash, String file) {
@@ -126,10 +166,11 @@
   }
 
   /**
+   * Returns links for file history
+   *
    * @param project Project name.
    * @param revision SHA1 of revision.
    * @param file File name.
-   * @return Links for file history
    */
   public ImmutableList<WebLinkInfo> getFileHistoryLinks(
       String project, String revision, String file) {
@@ -143,6 +184,8 @@
   }
 
   /**
+   * Returns links for file diffs
+   *
    * @param project Project name.
    * @param patchSetIdA Patch set ID of side A, <code>null</code> if no base patch set was selected.
    * @param revisionA SHA1 of revision of side A.
@@ -150,7 +193,6 @@
    * @param patchSetIdB Patch set ID of side B.
    * @param revisionB SHA1 of revision of side B.
    * @param fileB File name of side B.
-   * @return Links for file diffs.
    */
   public ImmutableList<DiffWebLinkInfo> getDiffLinks(
       String project,
@@ -181,26 +223,29 @@
   }
 
   /**
+   * Returns links for projects
+   *
    * @param project Project name.
-   * @return Links for projects.
    */
   public ImmutableList<WebLinkInfo> getProjectLinks(String project) {
     return filterLinks(projectLinks, webLink -> webLink.getProjectWeblink(project));
   }
 
   /**
+   * Returns links for branches
+   *
    * @param project Project name
    * @param branch Branch name
-   * @return Links for branches.
    */
   public ImmutableList<WebLinkInfo> getBranchLinks(String project, String branch) {
     return filterLinks(branchLinks, webLink -> webLink.getBranchWebLink(project, branch));
   }
 
   /**
+   * Returns links for the tag
+   *
    * @param project Project name
    * @param tag Tag name
-   * @return Links for tags.
    */
   public ImmutableList<WebLinkInfo> getTagLinks(String project, String tag) {
     return filterLinks(tagLinks, webLink -> webLink.getTagWebLink(project, tag));
diff --git a/java/com/google/gerrit/server/account/AccountCacheImpl.java b/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 93e04880..093af68 100644
--- a/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.config.AllUsersName;
@@ -45,7 +45,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
@@ -77,6 +76,7 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsersName;
   private final DefaultPreferencesCache defaultPreferenceCache;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   AccountCacheImpl(
@@ -85,12 +85,14 @@
           LoadingCache<CachedAccountDetails.Key, CachedAccountDetails> accountDetailsCache,
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
-      DefaultPreferencesCache defaultPreferenceCache) {
+      DefaultPreferencesCache defaultPreferenceCache,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.externalIds = externalIds;
     this.accountDetailsCache = accountDetailsCache;
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.defaultPreferenceCache = defaultPreferenceCache;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   @Override
@@ -141,10 +143,10 @@
   public Optional<AccountState> getByUsername(String username) {
     try {
       return externalIds
-          .get(ExternalId.Key.create(SCHEME_USERNAME, username))
+          .get(externalIdKeyFactory.create(SCHEME_USERNAME, username))
           .map(e -> get(e.accountId()))
           .orElseGet(Optional::empty);
-    } catch (IOException | ConfigInvalidException e) {
+    } catch (IOException e) {
       logger.atWarning().withCause(e).log("Cannot load AccountState for username %s", username);
       return Optional.empty();
     }
diff --git a/java/com/google/gerrit/server/account/AccountConfig.java b/java/com/google/gerrit/server/account/AccountConfig.java
index e95bc1c..45f1f35 100644
--- a/java/com/google/gerrit/server/account/AccountConfig.java
+++ b/java/com/google/gerrit/server/account/AccountConfig.java
@@ -84,7 +84,7 @@
   private Optional<ObjectId> externalIdsRev;
   private ProjectWatches projectWatches;
   private StoredPreferences preferences;
-  private Optional<InternalAccountUpdate> accountUpdate = Optional.empty();
+  private Optional<AccountDelta> accountDelta = Optional.empty();
   private List<ValidationError> validationErrors;
 
   public AccountConfig(Account.Id accountId, AllUsersName allUsersName, Repository allUsersRepo) {
@@ -158,9 +158,9 @@
     this.loadedAccountProperties =
         Optional.of(
             new AccountProperties(account.id(), account.registeredOn(), new Config(), null));
-    this.accountUpdate =
+    this.accountDelta =
         Optional.of(
-            InternalAccountUpdate.builder()
+            AccountDelta.builder()
                 .setActive(account.isActive())
                 .setFullName(account.fullName())
                 .setDisplayName(account.displayName())
@@ -196,8 +196,8 @@
     return loadedAccountProperties.map(AccountProperties::getAccount).get();
   }
 
-  public AccountConfig setAccountUpdate(InternalAccountUpdate accountUpdate) {
-    this.accountUpdate = Optional.of(accountUpdate);
+  public AccountConfig setAccountDelta(AccountDelta accountDelta) {
+    this.accountDelta = Optional.of(accountDelta);
     return this;
   }
 
@@ -283,45 +283,44 @@
     saveProjectWatches();
     savePreferences();
 
-    accountUpdate = Optional.empty();
+    accountDelta = Optional.empty();
 
     return true;
   }
 
   private void saveAccount() throws IOException {
-    if (accountUpdate.isPresent()) {
+    if (accountDelta.isPresent()) {
       saveConfig(
-          AccountProperties.ACCOUNT_CONFIG,
-          loadedAccountProperties.get().save(accountUpdate.get()));
+          AccountProperties.ACCOUNT_CONFIG, loadedAccountProperties.get().save(accountDelta.get()));
     }
   }
 
   private void saveProjectWatches() throws IOException {
-    if (accountUpdate.isPresent()
-        && (!accountUpdate.get().getDeletedProjectWatches().isEmpty()
-            || !accountUpdate.get().getUpdatedProjectWatches().isEmpty())) {
+    if (accountDelta.isPresent()
+        && (!accountDelta.get().getDeletedProjectWatches().isEmpty()
+            || !accountDelta.get().getUpdatedProjectWatches().isEmpty())) {
       Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches =
           new HashMap<>(projectWatches.getProjectWatches());
-      accountUpdate.get().getDeletedProjectWatches().forEach(newProjectWatches::remove);
-      accountUpdate.get().getUpdatedProjectWatches().forEach(newProjectWatches::put);
+      accountDelta.get().getDeletedProjectWatches().forEach(newProjectWatches::remove);
+      accountDelta.get().getUpdatedProjectWatches().forEach(newProjectWatches::put);
       saveConfig(ProjectWatches.WATCH_CONFIG, projectWatches.save(newProjectWatches));
     }
   }
 
   private void savePreferences() throws IOException, ConfigInvalidException {
-    if (!accountUpdate.isPresent()
-        || (!accountUpdate.get().getGeneralPreferences().isPresent()
-            && !accountUpdate.get().getDiffPreferences().isPresent()
-            && !accountUpdate.get().getEditPreferences().isPresent())) {
+    if (!accountDelta.isPresent()
+        || (!accountDelta.get().getGeneralPreferences().isPresent()
+            && !accountDelta.get().getDiffPreferences().isPresent()
+            && !accountDelta.get().getEditPreferences().isPresent())) {
       return;
     }
 
     saveConfig(
         StoredPreferences.PREFERENCES_CONFIG,
         preferences.saveGeneralPreferences(
-            accountUpdate.get().getGeneralPreferences(),
-            accountUpdate.get().getDiffPreferences(),
-            accountUpdate.get().getEditPreferences()));
+            accountDelta.get().getGeneralPreferences(),
+            accountDelta.get().getDiffPreferences(),
+            accountDelta.get().getEditPreferences()));
   }
 
   private void checkLoaded() {
diff --git a/java/com/google/gerrit/server/account/AccountDeactivator.java b/java/com/google/gerrit/server/account/AccountDeactivator.java
index ea327f8..a4d7608 100644
--- a/java/com/google/gerrit/server/account/AccountDeactivator.java
+++ b/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -33,7 +33,7 @@
 public class AccountDeactivator implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends LifecycleModule {
+  public static class AccountDeactivatorModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(Lifecycle.class);
diff --git a/java/com/google/gerrit/server/account/InternalAccountUpdate.java b/java/com/google/gerrit/server/account/AccountDelta.java
similarity index 96%
rename from java/com/google/gerrit/server/account/InternalAccountUpdate.java
rename to java/com/google/gerrit/server/account/AccountDelta.java
index 4f9202f..fac3233 100644
--- a/java/com/google/gerrit/server/account/InternalAccountUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountDelta.java
@@ -33,23 +33,23 @@
 import java.util.Set;
 
 /**
- * Class to prepare updates to an account.
+ * Data holder for updates to be applied to an account.
  *
- * <p>Account updates are done through {@link AccountsUpdate}. This class should be used to tell
- * {@link AccountsUpdate} how an account should be modified.
+ * <p>Instances of this type are passed to {@link AccountsUpdate}, which modifies the account
+ * accordingly.
  *
- * <p>This class allows to prepare updates of account properties, external IDs, preferences
+ * <p>Updates can be applied to account properties (name, email etc.), external IDs, preferences
  * (general, diff and edit preferences) and project watches. The account ID and the registration
  * date cannot be updated.
  *
- * <p>For the account properties there are getters in this class and the setters in the {@link
- * Builder} that correspond to the fields in {@link Account}.
+ * <p>For the account properties there are getters in this class and setters in the {@link Builder}
+ * that correspond to the fields in {@link Account}.
  */
 @AutoValue
-public abstract class InternalAccountUpdate {
+public abstract class AccountDelta {
   public static Builder builder() {
     return new Builder.WrapperThatConvertsNullStringArgsToEmptyStrings(
-        new AutoValue_InternalAccountUpdate.Builder());
+        new AutoValue_AccountDelta.Builder());
   }
 
   /**
@@ -162,13 +162,13 @@
   public abstract Optional<EditPreferencesInfo> getEditPreferences();
 
   /**
-   * Class to build an account update.
+   * Class to build an {@link AccountDelta}.
    *
    * <p>Account data is only updated if the corresponding setter is invoked. If a setter is not
    * invoked the corresponding data stays unchanged. To unset string values the setter can be
    * invoked with either {@code null} or an empty string ({@code null} is converted to an empty
    * string by using the {@link WrapperThatConvertsNullStringArgsToEmptyStrings} wrapper, see {@link
-   * InternalAccountUpdate#builder()}).
+   * AccountDelta#builder()}).
    */
   @AutoValue.Builder
   public abstract static class Builder {
@@ -447,12 +447,8 @@
      */
     public abstract Builder setEditPreferences(EditPreferencesInfo editPreferences);
 
-    /**
-     * Builds the account update.
-     *
-     * @return the account update
-     */
-    public abstract InternalAccountUpdate build();
+    /** Builds the instance. */
+    public abstract AccountDelta build();
 
     /**
      * Wrapper for {@link Builder} that converts {@code null} string arguments to empty strings for
@@ -525,7 +521,7 @@
       }
 
       @Override
-      public InternalAccountUpdate build() {
+      public AccountDelta build() {
         return delegate.build();
       }
 
diff --git a/java/com/google/gerrit/server/account/AccountLimits.java b/java/com/google/gerrit/server/account/AccountLimits.java
index 1845f5b..5549d28 100644
--- a/java/com/google/gerrit/server/account/AccountLimits.java
+++ b/java/com/google/gerrit/server/account/AccountLimits.java
@@ -51,7 +51,7 @@
     user = currentUser;
   }
 
-  /** @return which priority queue the user's tasks should be submitted to. */
+  /** Returns which priority queue the user's tasks should be submitted to. */
   public QueueProvider.QueueType getQueueType() {
     // If a non-generic group (that is not Anonymous Users or Registered Users)
     // grants us INTERACTIVE permission, use the INTERACTIVE queue even if
@@ -99,7 +99,7 @@
     return getRange(GlobalCapability.QUERY_LIMIT).getMax();
   }
 
-  /** @return true if the user has a permission rule specifying the range. */
+  /** Returns true if the user has a permission rule specifying the range. */
   public boolean hasExplicitRange(String permission) {
     return GlobalCapability.hasRange(permission) && !getRules(permission).isEmpty();
   }
diff --git a/java/com/google/gerrit/server/account/AccountManager.java b/java/com/google/gerrit/server/account/AccountManager.java
index 47c6efb..407d2f7 100644
--- a/java/com/google/gerrit/server/account/AccountManager.java
+++ b/java/com/google/gerrit/server/account/AccountManager.java
@@ -34,14 +34,15 @@
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ServerInitiated;
-import com.google.gerrit.server.account.AccountsUpdate.AccountUpdater;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.ssh.SshKeyCache;
@@ -78,6 +79,8 @@
   private final GroupsUpdate.Factory groupsUpdateFactory;
   private final boolean autoUpdateAccountActiveStatus;
   private final SetInactiveFlag setInactiveFlag;
+  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @VisibleForTesting
   @Inject
@@ -93,7 +96,9 @@
       ProjectCache projectCache,
       ExternalIds externalIds,
       GroupsUpdate.Factory groupsUpdateFactory,
-      SetInactiveFlag setInactiveFlag) {
+      SetInactiveFlag setInactiveFlag,
+      ExternalIdFactory externalIdFactory,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.sequences = sequences;
     this.accounts = accounts;
     this.accountsUpdateProvider = accountsUpdateProvider;
@@ -109,13 +114,15 @@
     this.autoUpdateAccountActiveStatus =
         cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
     this.setInactiveFlag = setInactiveFlag;
+    this.externalIdFactory = externalIdFactory;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
-  /** @return user identified by this external identity string */
+  /** Returns a user identified by this external identity string */
   public Optional<Account.Id> lookup(String externalId) throws AccountException {
     try {
-      return externalIds.get(ExternalId.Key.parse(externalId)).map(ExternalId::accountId);
-    } catch (IOException | ConfigInvalidException e) {
+      return externalIds.get(externalIdKeyFactory.parse(externalId)).map(ExternalId::accountId);
+    } catch (IOException e) {
       throw new AccountException("Cannot lookup account " + externalId, e);
     }
   }
@@ -221,7 +228,7 @@
   private void update(AuthRequest who, ExternalId extId)
       throws IOException, ConfigInvalidException, AccountException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    List<Consumer<InternalAccountUpdate.Builder>> accountUpdates = new ArrayList<>();
+    List<Consumer<AccountDelta.Builder>> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -230,7 +237,7 @@
     String oldEmail = extId.email();
     if (newEmail != null && !newEmail.equals(oldEmail)) {
       ExternalId extIdWithNewEmail =
-          ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password());
+          externalIdFactory.create(extId.key(), extId.accountId(), newEmail, extId.password());
       checkEmailNotUsed(extId.accountId(), extIdWithNewEmail);
       accountUpdates.add(u -> u.replaceExternalId(extId, extIdWithNewEmail));
 
@@ -262,7 +269,7 @@
           .update(
               "Update Account on Login",
               user.getAccountId(),
-              AccountUpdater.joinConsumers(accountUpdates))
+              AccountsUpdate.joinConsumers(accountUpdates))
           .orElseThrow(
               () -> new StorageException("Account " + user.getAccountId() + " has been deleted"));
     }
@@ -274,7 +281,7 @@
     logger.atFine().log("Assigning new Id %s to account", newId);
 
     ExternalId extId =
-        ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
+        externalIdFactory.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
     logger.atFine().log("Created external Id: %s", extId);
     checkEmailNotUsed(newId, extId);
     ExternalId userNameExtId =
@@ -349,7 +356,7 @@
               "Cannot assign user name \"%s\" to account %s; name does not conform.",
               username, accountId));
     }
-    return ExternalId.create(SCHEME_USERNAME, username, accountId);
+    return externalIdFactory.create(SCHEME_USERNAME, username, accountId);
   }
 
   private void checkEmailNotUsed(Account.Id accountId, ExternalId extIdToBeCreated)
@@ -382,13 +389,13 @@
       throws IOException, ConfigInvalidException, AccountException {
     // The user initiated this request by logging in. -> Attribute all modifications to that user.
     GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(
                 memberIds -> Sets.union(memberIds, ImmutableSet.of(user.getAccountId())))
             .build();
     try {
-      groupsUpdate.updateGroup(groupUuid, groupUpdate);
+      groupsUpdate.updateGroup(groupUuid, groupDelta);
     } catch (NoSuchGroupException e) {
       throw new AccountException(String.format("Group %s not found", groupUuid), e);
     }
@@ -415,7 +422,7 @@
       update(who, extId);
     } else {
       ExternalId newExtId =
-          ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
+          externalIdFactory.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress());
       checkEmailNotUsed(to, newExtId);
       accountsUpdateProvider
           .get()
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/java/com/google/gerrit/server/account/AccountModule.java
similarity index 68%
copy from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
copy to java/com/google/gerrit/server/account/AccountModule.java
index 223851e..c1305cf 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/java/com/google/gerrit/server/account/AccountModule.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2014 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,11 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.acceptance.pgm;
+package com.google.gerrit.server.account;
 
-import com.google.inject.Injector;
+import com.google.inject.AbstractModule;
 
-public class ReindexIT extends AbstractReindexTests {
+public class AccountModule extends AbstractModule {
   @Override
-  public void configureIndex(Injector injector) {}
+  protected void configure() {
+    bind(AuthRequest.Factory.class);
+  }
 }
diff --git a/java/com/google/gerrit/server/account/AccountProperties.java b/java/com/google/gerrit/server/account/AccountProperties.java
index 5ae5567..9b7ca81 100644
--- a/java/com/google/gerrit/server/account/AccountProperties.java
+++ b/java/com/google/gerrit/server/account/AccountProperties.java
@@ -103,21 +103,19 @@
     account = accountBuilder.build();
   }
 
-  Config save(InternalAccountUpdate accountUpdate) {
-    writeToAccountConfig(accountUpdate, accountConfig);
+  Config save(AccountDelta accountDelta) {
+    writeToAccountConfig(accountDelta, accountConfig);
     return accountConfig;
   }
 
-  public static void writeToAccountConfig(InternalAccountUpdate accountUpdate, Config cfg) {
-    accountUpdate.getActive().ifPresent(active -> setActive(cfg, active));
-    accountUpdate.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
-    accountUpdate
-        .getDisplayName()
-        .ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
-    accountUpdate
+  public static void writeToAccountConfig(AccountDelta accountDelta, Config cfg) {
+    accountDelta.getActive().ifPresent(active -> setActive(cfg, active));
+    accountDelta.getFullName().ifPresent(fullName -> set(cfg, KEY_FULL_NAME, fullName));
+    accountDelta.getDisplayName().ifPresent(displayName -> set(cfg, KEY_DISPLAY_NAME, displayName));
+    accountDelta
         .getPreferredEmail()
         .ifPresent(preferredEmail -> set(cfg, KEY_PREFERRED_EMAIL, preferredEmail));
-    accountUpdate.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
+    accountDelta.getStatus().ifPresent(status -> set(cfg, KEY_STATUS, status));
   }
 
   /**
diff --git a/java/com/google/gerrit/server/account/AccountResolver.java b/java/com/google/gerrit/server/account/AccountResolver.java
index 2665b9a..68f5a85 100644
--- a/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/java/com/google/gerrit/server/account/AccountResolver.java
@@ -27,12 +27,16 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AnonymousCowardName;
+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.query.account.InternalAccountQuery;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -437,7 +441,15 @@
       // up with a reasonable result list.
       // TODO(dborowitz): This doesn't match the documentation; consider whether it's possible to be
       // more strict here.
-      return accountQueryProvider.get().enforceVisibility(true).byDefault(input).stream();
+      boolean canSeeSecondaryEmails = false;
+      try {
+        permissionBackend.user(self.get()).check(GlobalPermission.MODIFY_ACCOUNT);
+        canSeeSecondaryEmails = true;
+      } catch (AuthException | PermissionBackendException e) {
+        // remains false
+      }
+      return accountQueryProvider.get().enforceVisibility(true)
+          .byDefault(input, canSeeSecondaryEmails).stream();
     }
 
     @Override
@@ -473,6 +485,7 @@
   private final Provider<InternalAccountQuery> accountQueryProvider;
   private final Realm realm;
   private final String anonymousCowardName;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   AccountResolver(
@@ -482,15 +495,17 @@
       IdentifiedUser.GenericFactory userFactory,
       Provider<CurrentUser> self,
       Provider<InternalAccountQuery> accountQueryProvider,
+      PermissionBackend permissionBackend,
       Realm realm,
       @AnonymousCowardName String anonymousCowardName) {
-    this.realm = realm;
     this.accountCache = accountCache;
+    this.emails = emails;
     this.accountControlFactory = accountControlFactory;
     this.userFactory = userFactory;
     this.self = self;
     this.accountQueryProvider = accountQueryProvider;
-    this.emails = emails;
+    this.permissionBackend = permissionBackend;
+    this.realm = realm;
     this.anonymousCowardName = anonymousCowardName;
   }
 
@@ -531,6 +546,19 @@
     return searchImpl(input, searchers, visibilitySupplierCanSee(), accountActivityPredicate);
   }
 
+  /**
+   * As opposed to {@link #resolve}, the returned result includes all inactive accounts for the
+   * input search.
+   *
+   * <p>This can be used to resolve Gerrit Account from email to its {@link
+   * com.google.gerrit.entities.Account.Id}, to make sure that if {@link Account} with such email
+   * exists in Gerrit (even inactive), user data (email address) won't be recorded as it is, but
+   * instead will be stored as a link to the corresponding Gerrit Account.
+   */
+  public Result resolveIncludeInactive(String input) throws ConfigInvalidException, IOException {
+    return searchImpl(input, searchers, visibilitySupplierCanSee(), all());
+  }
+
   public Result resolveIgnoreVisibility(String input) throws ConfigInvalidException, IOException {
     return searchImpl(input, searchers, visibilitySupplierAll(), accountActivityPredicate());
   }
diff --git a/java/com/google/gerrit/server/account/AccountTagProvider.java b/java/com/google/gerrit/server/account/AccountTagProvider.java
new file mode 100644
index 0000000..ddb1331
--- /dev/null
+++ b/java/com/google/gerrit/server/account/AccountTagProvider.java
@@ -0,0 +1,14 @@
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import java.util.List;
+
+/**
+ * An extension point for plugins to define their own account tags in addition to the ones defined
+ * at {@link com.google.gerrit.extensions.common.AccountInfo.Tags}.
+ */
+@ExtensionPoint
+public interface AccountTagProvider {
+  List<String> getTags(Account.Id id);
+}
diff --git a/java/com/google/gerrit/server/account/AccountsUpdate.java b/java/com/google/gerrit/server/account/AccountsUpdate.java
index 1b3aa96..93738b0 100644
--- a/java/com/google/gerrit/server/account/AccountsUpdate.java
+++ b/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
-import com.google.common.collect.Lists;
-import com.google.common.util.concurrent.Runnables;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.exceptions.StorageException;
@@ -49,6 +50,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -63,18 +65,17 @@
 /**
  * Creates and updates accounts.
  *
- * <p>This class should be used for all account updates. It supports updating account properties,
- * external IDs, preferences (general, diff and edit preferences) and project watches.
+ * <p>This class should be used for all account updates. See {@link AccountDelta} for what can be
+ * updated.
  *
- * <p>Updates to one account are always atomic. Batch updating several accounts within one
- * transaction is not supported.
+ * <p>Batch updates of multiple different accounts can be performed atomically, see {@link
+ * #updateBatch(List)}. Batch creation is not supported.
  *
  * <p>For any account update the caller must provide a commit message, the account ID and an {@link
- * AccountUpdater}. The account updater allows to read the current {@link AccountState} and to
- * prepare updates to the account by calling setters on the provided {@link
- * InternalAccountUpdate.Builder}. If the current account state is of no interest the caller may
- * also provide a {@link Consumer} for {@link InternalAccountUpdate.Builder} instead of the account
- * updater.
+ * ConfigureDeltaFromState}. The account updater reads the current {@link AccountState} and prepares
+ * updates to the account by calling setters on the provided {@link AccountDelta.Builder}. If the
+ * current account state is of no interest the caller may also provide a {@link Consumer} for {@link
+ * AccountDelta.Builder} instead of the account updater.
  *
  * <p>The provided commit message is used for the update of the user branch. Using a precise and
  * unique commit message allows to identify the code from which an update was made when looking at a
@@ -148,37 +149,35 @@
   }
 
   /**
-   * Updater for an account.
+   * Account updates are commonly performed by evaluating the current account state and creating a
+   * delta to be applied to it in a later step. This is done by implementing this interface.
    *
-   * <p>Allows to read the current state of an account and to prepare updates to it.
+   * <p>If the current account state is not needed, use a {@link Consumer} of {@link
+   * AccountDelta.Builder} instead.
    */
   @FunctionalInterface
-  public interface AccountUpdater {
+  public interface ConfigureDeltaFromState {
     /**
-     * Prepare updates to an account.
+     * Receives the current {@link AccountState} (which is immutable) and configures an {@link
+     * AccountDelta.Builder} with changes to the account.
      *
-     * <p>Use the provided account only to read the current state of the account. Don't do updates
-     * to the account. For updates use the provided account update builder.
-     *
-     * @param accountState the account that is being updated
-     * @param update account update builder
+     * @param accountState the state of the account that is being updated
+     * @param delta the changes to be applied
      */
-    void update(AccountState accountState, InternalAccountUpdate.Builder update) throws IOException;
+    void configure(AccountState accountState, AccountDelta.Builder delta) throws IOException;
+  }
 
-    static AccountUpdater join(List<AccountUpdater> updaters) {
-      return (accountState, update) -> {
-        for (AccountUpdater updater : updaters) {
-          updater.update(accountState, update);
-        }
-      };
-    }
+  /** Data holder for the set of arguments required to update an account. Used for batch updates. */
+  public static class UpdateArguments {
+    private final String message;
+    private final Account.Id accountId;
+    private final ConfigureDeltaFromState configureDeltaFromState;
 
-    static AccountUpdater joinConsumers(List<Consumer<InternalAccountUpdate.Builder>> consumers) {
-      return join(Lists.transform(consumers, AccountUpdater::fromConsumer));
-    }
-
-    static AccountUpdater fromConsumer(Consumer<InternalAccountUpdate.Builder> consumer) {
-      return (a, u) -> consumer.accept(u);
+    public UpdateArguments(
+        String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState) {
+      this.message = message;
+      this.accountId = accountId;
+      this.configureDeltaFromState = configureDeltaFromState;
     }
   }
 
@@ -193,13 +192,19 @@
   private final PersonIdent committerIdent;
   private final PersonIdent authorIdent;
 
-  // Invoked after reading the account config.
+  /** Invoked after reading the account config. */
   private final Runnable afterReadRevision;
 
-  // Invoked after updating the account but before committing the changes.
+  /** Invoked after updating the account but before committing the changes. */
   private final Runnable beforeCommit;
 
+  /** Single instance that accumulates updates from the batch. */
+  private ExternalIdNotes externalIdNotes;
+
+  private static final Runnable DO_NOTHING = () -> {};
+
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -220,11 +225,12 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.empty()),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   AccountsUpdate(
       GitRepositoryManager repoManager,
       GitReferenceUpdated gitRefUpdated,
@@ -246,8 +252,8 @@
         extIdNotesLoader,
         serverIdent,
         createPersonIdent(serverIdent, Optional.of(currentUser)),
-        Runnables.doNothing(),
-        Runnables.doNothing());
+        DO_NOTHING,
+        DO_NOTHING);
   }
 
   @VisibleForTesting
@@ -279,29 +285,32 @@
     this.beforeCommit = requireNonNull(beforeCommit, "beforeCommit");
   }
 
+  /** Returns an instance that runs all specified consumers. */
+  public static ConfigureDeltaFromState joinConsumers(
+      List<Consumer<AccountDelta.Builder>> consumers) {
+    return (accountStateIgnored, update) -> consumers.forEach(c -> c.accept(update));
+  }
+
+  private static ConfigureDeltaFromState fromConsumer(Consumer<AccountDelta.Builder> consumer) {
+    return (a, u) -> consumer.accept(u);
+  }
+
   private static PersonIdent createPersonIdent(
       PersonIdent serverIdent, Optional<IdentifiedUser> user) {
-    if (!user.isPresent()) {
-      return serverIdent;
-    }
-    return user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+    return user.isPresent()
+        ? user.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone())
+        : serverIdent;
   }
 
   /**
-   * Inserts a new account.
-   *
-   * @param message commit message for the account creation, must not be {@code null or empty}
-   * @param accountId ID of the new account
-   * @param init consumer to populate the new account
-   * @return the newly created account
-   * @throws DuplicateKeyException if the account already exists
-   * @throws IOException if creating the user branch fails due to an IO error
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * Like {@link #insert(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
+   * instead, i.e. the update does not depend on the current account state (which, for insertion,
+   * would only contain the account ID).
    */
   public AccountState insert(
-      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> init)
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> init)
       throws IOException, ConfigInvalidException {
-    return insert(message, accountId, AccountUpdater.fromConsumer(init));
+    return insert(message, accountId, fromConsumer(init));
   }
 
   /**
@@ -309,57 +318,48 @@
    *
    * @param message commit message for the account creation, must not be {@code null or empty}
    * @param accountId ID of the new account
-   * @param updater updater to populate the new account
+   * @param init to populate the new account
    * @return the newly created account
    * @throws DuplicateKeyException if the account already exists
    * @throws IOException if creating the user branch fails due to an IO error
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public AccountState insert(String message, Account.Id accountId, AccountUpdater updater)
+  public AccountState insert(String message, Account.Id accountId, ConfigureDeltaFromState init)
       throws IOException, ConfigInvalidException {
-    return updateAccount(
-            r -> {
-              AccountConfig accountConfig = read(r, accountId);
-              Account account =
-                  accountConfig.getNewAccount(new Timestamp(committerIdent.getWhen().getTime()));
-              AccountState accountState = AccountState.forAccount(account);
-              InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-              updater.update(accountState, updateBuilder);
+    return execute(
+            ImmutableList.of(
+                repo -> {
+                  AccountConfig accountConfig = read(repo, accountId);
+                  Account account =
+                      accountConfig.getNewAccount(
+                          new Timestamp(committerIdent.getWhen().getTime()));
+                  AccountState accountState = AccountState.forAccount(account);
+                  AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+                  init.configure(accountState, deltaBuilder);
 
-              InternalAccountUpdate update = updateBuilder.build();
-              accountConfig.setAccountUpdate(update);
-              ExternalIdNotes extIdNotes =
-                  createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
-              CachedPreferences defaultPreferences =
-                  CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+                  AccountDelta accountDelta = deltaBuilder.build();
+                  accountConfig.setAccountDelta(accountDelta);
+                  externalIdNotes =
+                      createExternalIdNotes(
+                          repo, accountConfig.getExternalIdsRev(), accountId, accountDelta);
+                  CachedPreferences defaultPreferences =
+                      CachedPreferences.fromConfig(
+                          VersionedDefaultPreferences.get(repo, allUsersName));
 
-              UpdatedAccount updatedAccounts =
-                  new UpdatedAccount(
-                      externalIds, message, accountConfig, extIdNotes, defaultPreferences);
-              updatedAccounts.setCreated(true);
-              return updatedAccounts;
-            })
+                  return new UpdatedAccount(message, accountConfig, defaultPreferences, true);
+                }))
+        .get(0)
         .get();
   }
 
   /**
-   * Gets the account and updates it atomically.
-   *
-   * <p>Changing the registration date of an account is not supported.
-   *
-   * @param message commit message for the account update, must not be {@code null or empty}
-   * @param accountId ID of the account
-   * @param update consumer to update the account, only invoked if the account exists
-   * @return the updated account, {@link Optional#empty()} if the account doesn't exist
-   * @throws IOException if updating the user branch fails due to an IO error
-   * @throws LockFailureException if updating the user branch still fails due to concurrent updates
-   *     after the retry timeout exceeded
-   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * Like {@link #update(String, Account.Id, ConfigureDeltaFromState)}, but using a {@link Consumer}
+   * instead, i.e. the update does not depend on the current account state.
    */
   public Optional<AccountState> update(
-      String message, Account.Id accountId, Consumer<InternalAccountUpdate.Builder> update)
-      throws LockFailureException, IOException, ConfigInvalidException {
-    return update(message, accountId, AccountUpdater.fromConsumer(update));
+      String message, Account.Id accountId, Consumer<AccountDelta.Builder> update)
+      throws IOException, ConfigInvalidException {
+    return update(message, accountId, fromConsumer(update));
   }
 
   /**
@@ -369,41 +369,77 @@
    *
    * @param message commit message for the account update, must not be {@code null or empty}
    * @param accountId ID of the account
-   * @param updater updater to update the account, only invoked if the account exists
+   * @param configureDeltaFromState deltaBuilder to update the account, only invoked if the account
+   *     exists
    * @return the updated account, {@link Optional#empty} if the account doesn't exist
    * @throws IOException if updating the user branch fails due to an IO error
    * @throws LockFailureException if updating the user branch still fails due to concurrent updates
    *     after the retry timeout exceeded
    * @throws ConfigInvalidException if any of the account fields has an invalid value
    */
-  public Optional<AccountState> update(String message, Account.Id accountId, AccountUpdater updater)
+  public Optional<AccountState> update(
+      String message, Account.Id accountId, ConfigureDeltaFromState configureDeltaFromState)
       throws LockFailureException, IOException, ConfigInvalidException {
-    return updateAccount(
-        r -> {
-          AccountConfig accountConfig = read(r, accountId);
-          CachedPreferences defaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
-          Optional<AccountState> account =
-              AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
-          if (!account.isPresent()) {
-            return null;
-          }
+    return updateBatch(
+            ImmutableList.of(new UpdateArguments(message, accountId, configureDeltaFromState)))
+        .get(0);
+  }
 
-          InternalAccountUpdate.Builder updateBuilder = InternalAccountUpdate.builder();
-          updater.update(account.get(), updateBuilder);
+  private ExecutableUpdate createExecutableUpdate(UpdateArguments updateArguments) {
+    return repo -> {
+      AccountConfig accountConfig = read(repo, updateArguments.accountId);
+      CachedPreferences defaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+      Optional<AccountState> accountState =
+          AccountState.fromAccountConfig(externalIds, accountConfig, defaultPreferences);
+      if (!accountState.isPresent()) {
+        return null;
+      }
 
-          InternalAccountUpdate update = updateBuilder.build();
-          accountConfig.setAccountUpdate(update);
-          ExternalIdNotes extIdNotes =
-              createExternalIdNotes(r, accountConfig.getExternalIdsRev(), accountId, update);
-          CachedPreferences cachedDefaultPreferences =
-              CachedPreferences.fromConfig(VersionedDefaultPreferences.get(r, allUsersName));
+      AccountDelta.Builder deltaBuilder = AccountDelta.builder();
+      updateArguments.configureDeltaFromState.configure(accountState.get(), deltaBuilder);
 
-          UpdatedAccount updatedAccounts =
-              new UpdatedAccount(
-                  externalIds, message, accountConfig, extIdNotes, cachedDefaultPreferences);
-          return updatedAccounts;
-        });
+      AccountDelta delta = deltaBuilder.build();
+      accountConfig.setAccountDelta(delta);
+      ExternalIdNotes.checkSameAccount(
+          Iterables.concat(
+              delta.getCreatedExternalIds(),
+              delta.getUpdatedExternalIds(),
+              delta.getDeletedExternalIds()),
+          updateArguments.accountId);
+
+      if (externalIdNotes == null) {
+        externalIdNotes =
+            extIdNotesLoader.load(
+                repo, accountConfig.getExternalIdsRev().orElse(ObjectId.zeroId()));
+      }
+      externalIdNotes.replace(delta.getDeletedExternalIds(), delta.getCreatedExternalIds());
+      externalIdNotes.upsert(delta.getUpdatedExternalIds());
+
+      CachedPreferences cachedDefaultPreferences =
+          CachedPreferences.fromConfig(VersionedDefaultPreferences.get(repo, allUsersName));
+
+      return new UpdatedAccount(
+          updateArguments.message, accountConfig, cachedDefaultPreferences, false);
+    };
+  }
+
+  /**
+   * Updates multiple different accounts atomically. This will only store a single new value (aka
+   * set of all external IDs of the host) in the external ID cache, which is important for storage
+   * economy. All {@code updates} must be for different accounts.
+   *
+   * <p>NOTE on error handling: Since updates are executed in multiple stages, with some stages
+   * resulting from the union of all individual updates, we cannot point to the update that caused
+   * the error. Callers should be aware that a single "update of death" (or a set of updates that
+   * together have this property) will always prevent the entire batch from being executed.
+   */
+  public ImmutableList<Optional<AccountState>> updateBatch(List<UpdateArguments> updates)
+      throws IOException, ConfigInvalidException {
+    checkArgument(
+        updates.stream().map(u -> u.accountId.get()).distinct().count() == updates.size(),
+        "updates must all be for different accounts");
+    return execute(updates.stream().map(this::createExecutableUpdate).collect(toList()));
   }
 
   private AccountConfig read(Repository allUsersRepo, Account.Id accountId)
@@ -413,26 +449,35 @@
     return accountConfig;
   }
 
-  private Optional<AccountState> updateAccount(AccountUpdate accountUpdate)
+  private ImmutableList<Optional<AccountState>> execute(List<ExecutableUpdate> executableUpdates)
       throws IOException, ConfigInvalidException {
-    return executeAccountUpdate(
+    List<Optional<AccountState>> accountState = new ArrayList<>();
+    List<UpdatedAccount> updatedAccounts = new ArrayList<>();
+    executeWithRetry(
         () -> {
-          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-            UpdatedAccount updatedAccount = accountUpdate.update(allUsersRepo);
-            if (updatedAccount == null) {
-              return Optional.empty();
-            }
+          // Reset state for retry.
+          externalIdNotes = null;
+          accountState.clear();
+          updatedAccounts.clear();
 
-            commit(allUsersRepo, updatedAccount);
-            return Optional.of(updatedAccount.getAccount());
+          try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
+            for (ExecutableUpdate executableUpdate : executableUpdates) {
+              updatedAccounts.add(executableUpdate.execute(allUsersRepo));
+            }
+            commit(
+                allUsersRepo, updatedAccounts.stream().filter(Objects::nonNull).collect(toList()));
+            for (UpdatedAccount ua : updatedAccounts) {
+              accountState.add(ua == null ? Optional.empty() : ua.getAccountState());
+            }
           }
+          return null;
         });
+    return ImmutableList.copyOf(accountState);
   }
 
-  private Optional<AccountState> executeAccountUpdate(Action<Optional<AccountState>> action)
-      throws IOException, ConfigInvalidException {
+  private void executeWithRetry(Action<Void> action) throws IOException, ConfigInvalidException {
     try {
-      return retryHelper.accountUpdate("updateAccount", action).call();
+      retryHelper.accountUpdate("updateAccount", action).call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       Throwables.throwIfInstanceOf(e, IOException.class);
@@ -442,10 +487,7 @@
   }
 
   private ExternalIdNotes createExternalIdNotes(
-      Repository allUsersRepo,
-      Optional<ObjectId> rev,
-      Account.Id accountId,
-      InternalAccountUpdate update)
+      Repository allUsersRepo, Optional<ObjectId> rev, Account.Id accountId, AccountDelta update)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     ExternalIdNotes.checkSameAccount(
         Iterables.concat(
@@ -460,73 +502,56 @@
     return extIdNotes;
   }
 
-  private void commit(Repository allUsersRepo, UpdatedAccount updatedAccount) throws IOException {
+  private void commit(Repository allUsersRepo, List<UpdatedAccount> updatedAccounts)
+      throws IOException {
+    if (updatedAccounts.isEmpty()) {
+      return;
+    }
+
     beforeCommit.run();
 
     BatchRefUpdate batchRefUpdate = allUsersRepo.getRefDatabase().newBatchUpdate();
 
-    if (updatedAccount.isCreated()) {
-      commitNewAccountConfig(
-          updatedAccount.getMessage(),
-          allUsersRepo,
-          batchRefUpdate,
-          updatedAccount.getAccountConfig());
-    } else {
+    String externalIdUpdateMessage =
+        updatedAccounts.size() == 1
+            ? Iterables.getOnlyElement(updatedAccounts).message
+            : "Batch update for " + updatedAccounts.size() + " accounts";
+    for (UpdatedAccount updatedAccount : updatedAccounts) {
+      // These updates are all for different refs (because batches never update the same account
+      // more than once), so there can be multiple commits in the same batch, all with the same base
+      // revision in their AccountConfig.
       commitAccountConfig(
-          updatedAccount.getMessage(),
+          updatedAccount.message,
           allUsersRepo,
           batchRefUpdate,
-          updatedAccount.getAccountConfig());
-    }
+          updatedAccount.accountConfig,
+          updatedAccount.created /* allowEmptyCommit */);
+      // When creating a new account we must allow empty commits so that the user branch gets
+      // created with an empty commit when no account properties are set and hence no
+      // 'account.config' file will be created.
 
-    commitExternalIdUpdates(
-        updatedAccount.getMessage(),
-        allUsersRepo,
-        batchRefUpdate,
-        updatedAccount.getExternalIdNotes());
+      // These update the same ref, so they need to be stacked on top of one another using the same
+      // ExternalIdNotes instance.
+      commitExternalIdUpdates(externalIdUpdateMessage, allUsersRepo, batchRefUpdate);
+    }
 
     RefUpdateUtil.executeChecked(batchRefUpdate, allUsersRepo);
 
-    // Skip accounts that are updated when evicting the account cache via ExternalIdNotes to avoid
-    // double reindexing. The updated accounts will already be reindexed by ReindexAfterRefUpdate.
-    Set<Account.Id> accountsThatWillBeReindexByReindexAfterRefUpdate =
-        getUpdatedAccounts(batchRefUpdate);
-    updatedAccount
-        .getExternalIdNotes()
-        .updateCaches(accountsThatWillBeReindexByReindexAfterRefUpdate);
+    Set<Account.Id> accountsToSkipForReindex = getUpdatedAccountIds(batchRefUpdate);
+    extIdNotesLoader.updateExternalIdCacheAndMaybeReindexAccounts(
+        externalIdNotes, accountsToSkipForReindex);
 
     gitRefUpdated.fire(
-        allUsersName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
+        allUsersName, batchRefUpdate, currentUser.map(IdentifiedUser::state).orElse(null));
   }
 
-  private static Set<Account.Id> getUpdatedAccounts(BatchRefUpdate batchRefUpdate) {
+  private static Set<Account.Id> getUpdatedAccountIds(BatchRefUpdate batchRefUpdate) {
     return batchRefUpdate.getCommands().stream()
         .map(c -> Account.Id.fromRef(c.getRefName()))
         .filter(Objects::nonNull)
         .collect(toSet());
   }
 
-  private void commitNewAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    // When creating a new account we must allow empty commits so that the user branch gets created
-    // with an empty commit when no account properties are set and hence no 'account.config' file
-    // will be created.
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, true);
-  }
-
-  private void commitAccountConfig(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      AccountConfig accountConfig)
-      throws IOException {
-    commitAccountConfig(message, allUsersRepo, batchRefUpdate, accountConfig, false);
-  }
-
   private void commitAccountConfig(
       String message,
       Repository allUsersRepo,
@@ -541,13 +566,9 @@
   }
 
   private void commitExternalIdUpdates(
-      String message,
-      Repository allUsersRepo,
-      BatchRefUpdate batchRefUpdate,
-      ExternalIdNotes extIdNotes)
-      throws IOException {
+      String message, Repository allUsersRepo, BatchRefUpdate batchRefUpdate) throws IOException {
     try (MetaDataUpdate md = createMetaDataUpdate(message, allUsersRepo, batchRefUpdate)) {
-      extIdNotes.commit(md);
+      externalIdNotes.commit(md);
     }
   }
 
@@ -566,57 +587,31 @@
   }
 
   @FunctionalInterface
-  private static interface AccountUpdate {
-    UpdatedAccount update(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+  private interface ExecutableUpdate {
+    UpdatedAccount execute(Repository allUsersRepo) throws IOException, ConfigInvalidException;
   }
 
-  private static class UpdatedAccount {
-    private final ExternalIds externalIds;
-    private final String message;
-    private final AccountConfig accountConfig;
-    private final ExternalIdNotes extIdNotes;
-    private final CachedPreferences defaultPreferences;
+  private class UpdatedAccount {
+    final String message;
+    final AccountConfig accountConfig;
+    final CachedPreferences defaultPreferences;
+    final boolean created;
 
-    private boolean created;
-
-    private UpdatedAccount(
-        ExternalIds externalIds,
+    UpdatedAccount(
         String message,
         AccountConfig accountConfig,
-        ExternalIdNotes extIdNotes,
-        CachedPreferences defaultPreferences) {
+        CachedPreferences defaultPreferences,
+        boolean created) {
       checkState(!Strings.isNullOrEmpty(message), "message for account update must be set");
-      this.externalIds = requireNonNull(externalIds);
       this.message = requireNonNull(message);
       this.accountConfig = requireNonNull(accountConfig);
-      this.extIdNotes = requireNonNull(extIdNotes);
       this.defaultPreferences = defaultPreferences;
-    }
-
-    public String getMessage() {
-      return message;
-    }
-
-    public AccountConfig getAccountConfig() {
-      return accountConfig;
-    }
-
-    public AccountState getAccount() throws IOException {
-      return AccountState.fromAccountConfig(
-              externalIds, accountConfig, extIdNotes, defaultPreferences)
-          .get();
-    }
-
-    public ExternalIdNotes getExternalIdNotes() {
-      return extIdNotes;
-    }
-
-    public void setCreated(boolean created) {
       this.created = created;
     }
 
-    public boolean isCreated() {
-      return created;
+    Optional<AccountState> getAccountState() throws IOException {
+      return AccountState.fromAccountConfig(
+          externalIds, accountConfig, externalIdNotes, defaultPreferences);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/account/AuthRequest.java b/java/com/google/gerrit/server/account/AuthRequest.java
index ddb54a6..50ed532 100644
--- a/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/java/com/google/gerrit/server/account/AuthRequest.java
@@ -21,6 +21,9 @@
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.util.Optional;
 
 /**
@@ -32,31 +35,52 @@
  * not all OpenID providers return them, and not all non-OpenID systems can use them.
  */
 public class AuthRequest {
-  /** Create a request for a local username, such as from LDAP. */
-  public static AuthRequest forUser(String username) {
-    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_GERRIT, username));
-    r.setUserName(username);
-    return r;
+  @Singleton
+  public static class Factory {
+    private final ExternalIdKeyFactory externalIdKeyFactory;
+
+    @Inject
+    public Factory(ExternalIdKeyFactory externalIdKeyFactory) {
+      this.externalIdKeyFactory = externalIdKeyFactory;
+    }
+
+    public AuthRequest create(ExternalId.Key externalIdKey) {
+      return new AuthRequest(externalIdKey, externalIdKeyFactory);
+    }
+
+    /** Create a request for a local username, such as from LDAP. */
+    public AuthRequest createForUser(String username) {
+      AuthRequest r =
+          new AuthRequest(
+              externalIdKeyFactory.create(SCHEME_GERRIT, username), externalIdKeyFactory);
+      r.setUserName(username);
+      return r;
+    }
+
+    /** Create a request for an external username. */
+    public AuthRequest createForExternalUser(String username) {
+      AuthRequest r =
+          new AuthRequest(
+              externalIdKeyFactory.create(SCHEME_EXTERNAL, username), externalIdKeyFactory);
+      r.setUserName(username);
+      return r;
+    }
+
+    /**
+     * Create a request for an email address registration.
+     *
+     * <p>This type of request should be used only to attach a new email address to an existing user
+     * account.
+     */
+    public AuthRequest createForEmail(String email) {
+      AuthRequest r =
+          new AuthRequest(externalIdKeyFactory.create(SCHEME_MAILTO, email), externalIdKeyFactory);
+      r.setEmailAddress(email);
+      return r;
+    }
   }
 
-  /** Create a request for an external username. */
-  public static AuthRequest forExternalUser(String username) {
-    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_EXTERNAL, username));
-    r.setUserName(username);
-    return r;
-  }
-
-  /**
-   * Create a request for an email address registration.
-   *
-   * <p>This type of request should be used only to attach a new email address to an existing user
-   * account.
-   */
-  public static AuthRequest forEmail(String email) {
-    AuthRequest r = new AuthRequest(ExternalId.Key.create(SCHEME_MAILTO, email));
-    r.setEmailAddress(email);
-    return r;
-  }
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   private ExternalId.Key externalId;
   private String password;
@@ -69,8 +93,9 @@
   private boolean authProvidesAccountActiveStatus;
   private boolean active;
 
-  public AuthRequest(ExternalId.Key externalId) {
+  private AuthRequest(ExternalId.Key externalId, ExternalIdKeyFactory externalIdKeyFactory) {
     this.externalId = externalId;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   public ExternalId.Key getExternalIdKey() {
@@ -86,7 +111,7 @@
 
   public void setLocalUser(String localUser) {
     if (externalId.isScheme(SCHEME_GERRIT)) {
-      externalId = ExternalId.Key.create(SCHEME_GERRIT, localUser);
+      externalId = externalIdKeyFactory.create(SCHEME_GERRIT, localUser);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/GroupBackend.java b/java/com/google/gerrit/server/account/GroupBackend.java
index d6360c5..91edaf2 100644
--- a/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/java/com/google/gerrit/server/account/GroupBackend.java
@@ -26,7 +26,7 @@
 /** Implementations of GroupBackend provide lookup and membership accessors to a group system. */
 @ExtensionPoint
 public interface GroupBackend {
-  /** @return {@code true} if the backend can operate on the UUID. */
+  /** Returns {@code true} if the backend can operate on the UUID. */
   boolean handles(AccountGroup.UUID uuid);
 
   /**
@@ -38,12 +38,12 @@
   @Nullable
   GroupDescription.Basic get(AccountGroup.UUID uuid);
 
-  /** @return suggestions for the group name sorted by name. */
+  /** Returns suggestions for the group name sorted by name. */
   Collection<GroupReference> suggest(String name, @Nullable ProjectState project);
 
-  /** @return the group membership checker for the backend. */
+  /** Returns the group membership checker for the backend. */
   GroupMembership membershipsOf(CurrentUser user);
 
-  /** @return {@code true} if the group with the given UUID is visible to all registered users. */
+  /** Returns {@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/GroupCache.java b/java/com/google/gerrit/server/account/GroupCache.java
index aaae95a..1e28d7d 100644
--- a/java/com/google/gerrit/server/account/GroupCache.java
+++ b/java/com/google/gerrit/server/account/GroupCache.java
@@ -103,6 +103,10 @@
    */
   void evict(AccountGroup.UUID groupUuid);
 
-  /** @see #evict(AccountGroup.UUID); */
+  /**
+   * Removes the association of the given UUIDs with groups
+   *
+   * <p>See {@link #evict(AccountGroup.UUID)}
+   */
   void evict(Collection<AccountGroup.UUID> groupUuid);
 }
diff --git a/java/com/google/gerrit/server/account/GroupIncludeCache.java b/java/com/google/gerrit/server/account/GroupIncludeCache.java
index 6547619..d92d9fc 100644
--- a/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -37,7 +37,7 @@
    */
   Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
-  /** @return set of any UUIDs that are not internal groups. */
+  /** Returns set of any UUIDs that are not internal groups. */
   Collection<AccountGroup.UUID> allExternalMembers();
 
   void evictGroupsWithMember(Account.Id memberId);
diff --git a/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 13b71cf..130fa44 100644
--- a/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -14,17 +14,19 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.Streams.stream;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
+import static java.util.stream.Stream.concat;
 
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
-import com.google.common.collect.Streams;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountInfo.Tags;
 import com.google.gerrit.extensions.common.AvatarInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -45,12 +47,13 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.stream.Stream;
 
 @Singleton
 public class InternalAccountDirectory extends AccountDirectory {
   static final Set<FillOptions> ID_ONLY = Collections.unmodifiableSet(EnumSet.of(FillOptions.ID));
 
-  public static class Module extends AbstractModule {
+  public static class InternalAccountDirectoryModule extends AbstractModule {
     @Override
     protected void configure() {
       bind(AccountDirectory.class).to(InternalAccountDirectory.class);
@@ -63,6 +66,7 @@
   private final Provider<CurrentUser> self;
   private final PermissionBackend permissionBackend;
   private final ServiceUserClassifier serviceUserClassifier;
+  private final DynamicMap<AccountTagProvider> accountTagProviders;
 
   @Inject
   InternalAccountDirectory(
@@ -71,13 +75,15 @@
       IdentifiedUser.GenericFactory userFactory,
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
-      ServiceUserClassifier serviceUserClassifier) {
+      ServiceUserClassifier serviceUserClassifier,
+      DynamicMap<AccountTagProvider> accountTagProviders) {
     this.accountCache = accountCache;
     this.avatar = avatar;
     this.userFactory = userFactory;
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.serviceUserClassifier = serviceUserClassifier;
+    this.accountTagProviders = accountTagProviders;
   }
 
   @Override
@@ -102,7 +108,7 @@
 
     Set<FillOptions> fillOptionsWithoutSecondaryEmails =
         Sets.difference(options, EnumSet.of(FillOptions.SECONDARY_EMAILS));
-    Set<Account.Id> ids = Streams.stream(in).map(a -> Account.id(a._accountId)).collect(toSet());
+    Set<Account.Id> ids = stream(in).map(a -> Account.id(a._accountId)).collect(toSet());
     Map<Account.Id, AccountState> accountStates = accountCache.get(ids);
     for (AccountInfo info : in) {
       Account.Id id = Account.id(info._accountId);
@@ -160,10 +166,10 @@
     }
 
     if (options.contains(FillOptions.TAGS)) {
-      info.tags =
-          serviceUserClassifier.isServiceUser(account.id())
-              ? ImmutableList.of(AccountInfo.Tag.SERVICE_USER)
-              : null;
+      List<String> tags = getTags(account.id());
+      if (!tags.isEmpty()) {
+        info.tags = tags;
+      }
     }
 
     if (options.contains(FillOptions.AVATARS)) {
@@ -194,6 +200,15 @@
         .collect(toList());
   }
 
+  private List<String> getTags(Account.Id id) {
+    Stream<String> tagsFromProviders =
+        stream(accountTagProviders.iterator())
+            .flatMap(accountTagProvider -> accountTagProvider.get().getTags(id).stream());
+    Stream<String> tagsFromServiceUserClassifier =
+        serviceUserClassifier.isServiceUser(id) ? Stream.of(Tags.SERVICE_USER) : Stream.empty();
+    return concat(tagsFromProviders, tagsFromServiceUserClassifier).collect(toList());
+  }
+
   private static void addAvatar(
       AvatarProvider provider, AccountInfo account, IdentifiedUser user, int size) {
     String url = provider.getUrl(user, size);
diff --git a/java/com/google/gerrit/server/account/Realm.java b/java/com/google/gerrit/server/account/Realm.java
index d56ed07..3f642f7 100644
--- a/java/com/google/gerrit/server/account/Realm.java
+++ b/java/com/google/gerrit/server/account/Realm.java
@@ -41,10 +41,10 @@
 
   void onCreateAccount(AuthRequest who, Account account);
 
-  /** @return true if the user has the given email address. */
+  /** Returns true if the user has the given email address. */
   boolean hasEmailAddress(IdentifiedUser who, String email);
 
-  /** @return all known email addresses for the identified user. */
+  /** Returns all known email addresses for the identified user. */
   Set<String> getEmailAddresses(IdentifiedUser who);
 
   /**
@@ -56,19 +56,13 @@
    */
   Account.Id lookup(String accountName) throws IOException;
 
-  /**
-   * @return true if the account is active.
-   * @throws NamingException
-   * @throws LoginException
-   * @throws AccountException
-   * @throws IOException
-   */
+  /** Returns true if the account is active. */
   default boolean isActive(@SuppressWarnings("unused") String username)
       throws LoginException, NamingException, AccountException, IOException {
     return true;
   }
 
-  /** @return true if the account is backed by the realm, false otherwise. */
+  /** Returns true if the account is backed by the realm, false otherwise. */
   default boolean accountBelongsToRealm(
       @SuppressWarnings("unused") Collection<ExternalId> externalIds) {
     return false;
diff --git a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
index 27ac9f4..db030f9 100644
--- a/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
+++ b/java/com/google/gerrit/server/account/ServiceUserClassifierImpl.java
@@ -76,7 +76,7 @@
               .get(toTraverse.remove(0))
               .orElseThrow(() -> new IllegalStateException("invalid subgroup"));
       if (seen.contains(currentGroup.getGroupUUID())) {
-        logger.atWarning().log(
+        logger.atFine().log(
             "Skipping %s because it's a cyclic subgroup", currentGroup.getGroupUUID());
         continue;
       }
diff --git a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 30021e6..555a2c1 100644
--- a/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -206,7 +206,6 @@
    *
    * @param pub the public SSH key to be added
    * @return the new SSH key
-   * @throws InvalidSshKeyException
    */
   private AccountSshKey addKey(String pub) throws InvalidSshKeyException {
     checkLoaded();
diff --git a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
index 4da2a9e..e718bcb 100644
--- a/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/AllExternalIds.java
@@ -14,41 +14,38 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
-import static java.util.stream.Collectors.toList;
-
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.collect.SetMultimap;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.proto.Protos;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
-import java.util.Collection;
+import java.util.stream.Stream;
 
 /** Cache value containing all external IDs. */
 @AutoValue
 public abstract class AllExternalIds {
-  static AllExternalIds create(SetMultimap<Account.Id, ExternalId> byAccount) {
-    return new AutoValue_AllExternalIds(
-        ImmutableSetMultimap.copyOf(byAccount), byEmailCopy(byAccount.values()));
+  static AllExternalIds create(Stream<ExternalId> externalIds) {
+    ImmutableMap.Builder<ExternalId.Key, ExternalId> byKey = ImmutableMap.builder();
+    ImmutableSetMultimap.Builder<Account.Id, ExternalId> byAccount = ImmutableSetMultimap.builder();
+    ImmutableSetMultimap.Builder<String, ExternalId> byEmail = ImmutableSetMultimap.builder();
+    externalIds.forEach(
+        id -> {
+          byKey.put(id.key(), id);
+          byAccount.put(id.accountId(), id);
+          if (!Strings.isNullOrEmpty(id.email())) {
+            byEmail.put(id.email(), id);
+          }
+        });
+
+    return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
   }
 
-  static AllExternalIds create(Collection<ExternalId> externalIds) {
-    return new AutoValue_AllExternalIds(
-        externalIds.stream().collect(toImmutableSetMultimap(ExternalId::accountId, e -> e)),
-        byEmailCopy(externalIds));
-  }
-
-  private static ImmutableSetMultimap<String, ExternalId> byEmailCopy(
-      Collection<ExternalId> externalIds) {
-    return externalIds.stream()
-        .filter(e -> !Strings.isNullOrEmpty(e.email()))
-        .collect(toImmutableSetMultimap(ExternalId::email, e -> e));
-  }
+  public abstract ImmutableMap<ExternalId.Key, ExternalId> byKey();
 
   public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
 
@@ -71,7 +68,8 @@
       ExternalIdProto.Builder b =
           ExternalIdProto.newBuilder()
               .setKey(externalId.key().get())
-              .setAccountId(externalId.accountId().get());
+              .setAccountId(externalId.accountId().get())
+              .setIsCaseInsensitive(externalId.isCaseInsensitive());
       if (externalId.email() != null) {
         b.setEmail(externalId.email());
       }
@@ -89,13 +87,12 @@
       ObjectIdConverter idConverter = ObjectIdConverter.create();
       return create(
           Protos.parseUnchecked(AllExternalIdsProto.parser(), in).getExternalIdList().stream()
-              .map(proto -> toExternalId(idConverter, proto))
-              .collect(toList()));
+              .map(proto -> toExternalId(idConverter, proto)));
     }
 
     private static ExternalId toExternalId(ObjectIdConverter idConverter, ExternalIdProto proto) {
       return ExternalId.create(
-          ExternalId.Key.parse(proto.getKey()),
+          ExternalId.Key.parse(proto.getKey(), proto.getIsCaseInsensitive()),
           Account.id(proto.getAccountId()),
           // ExternalId treats null and empty strings the same, so no need to distinguish here.
           proto.getEmail(),
diff --git a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
index e1e9c70..1cd3de8 100644
--- a/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -21,6 +21,7 @@
 import com.google.inject.Module;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
 public class DisabledExternalIdCache implements ExternalIdCache {
@@ -42,6 +43,11 @@
       Collection<ExternalId> toAdd) {}
 
   @Override
+  public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public ImmutableSet<ExternalId> byAccount(Account.Id accountId) {
     throw new UnsupportedOperationException();
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalId.java b/java/com/google/gerrit/server/account/externalids/ExternalId.java
index 71ce4d8..0a23a10 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalId.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -17,35 +17,29 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.server.account.HashedPassword;
 import java.io.Serializable;
 import java.util.Collection;
+import java.util.Locale;
 import java.util.Objects;
 import java.util.Optional;
-import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.stream.Stream;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 @AutoValue
 public abstract class ExternalId implements Serializable {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   // If these regular expressions are modified the same modifications should be done to the
   // corresponding regular expressions in the
   // com.google.gerrit.client.account.UsernameField class.
@@ -106,10 +100,10 @@
 
   private static final long serialVersionUID = 1L;
 
-  private static final String EXTERNAL_ID_SECTION = "externalId";
-  private static final String ACCOUNT_ID_KEY = "accountId";
-  private static final String EMAIL_KEY = "email";
-  private static final String PASSWORD_KEY = "password";
+  static final String EXTERNAL_ID_SECTION = "externalId";
+  static final String ACCOUNT_ID_KEY = "accountId";
+  static final String EMAIL_KEY = "email";
+  static final String PASSWORD_KEY = "password";
 
   /**
    * Scheme used to label accounts created, when using the LDAP-based authentication types {@link
@@ -120,6 +114,8 @@
    * <p>The name {@code gerrit:} was a very poor choice.
    *
    * <p>Scheme names must not contain colons (':').
+   *
+   * <p>Will be handled case insensitive, if auth.userNameCaseInsensitive = true.
    */
   public static final String SCHEME_GERRIT = "gerrit";
 
@@ -129,7 +125,11 @@
   /** Scheme used to represent only an email address. */
   public static final String SCHEME_MAILTO = "mailto";
 
-  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
+  /**
+   * Scheme for the username used to authenticate an account, e.g. over SSH.
+   *
+   * <p>Will be handled case insensitive, if auth.userNameCaseInsensitive = true.
+   */
   public static final String SCHEME_USERNAME = "username";
 
   /** Scheme used for GPG public keys. */
@@ -156,10 +156,12 @@
      *
      * @param scheme the scheme name, must not contain colons (':'), can be {@code null}
      * @param id the external ID, must not contain colons (':')
+     * @param isCaseInsensitive whether the external ID key is matched case insensitively
      * @return the created external ID key
      */
-    public static Key create(@Nullable String scheme, String id) {
-      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
+    @VisibleForTesting
+    public static Key create(@Nullable String scheme, String id, boolean isCaseInsensitive) {
+      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id, isCaseInsensitive);
     }
 
     /**
@@ -167,29 +169,43 @@
      *
      * @return the parsed external ID key
      */
-    public static Key parse(String externalId) {
+    @VisibleForTesting
+    public static Key parse(String externalId, boolean isCaseInsensitive) {
       int c = externalId.indexOf(':');
       if (c < 1 || c >= externalId.length() - 1) {
-        return create(null, externalId);
+        return create(null, externalId, isCaseInsensitive);
       }
-      return create(externalId.substring(0, c), externalId.substring(c + 1));
+      return create(externalId.substring(0, c), externalId.substring(c + 1), isCaseInsensitive);
     }
 
     public abstract @Nullable String scheme();
 
     public abstract String id();
 
+    public abstract boolean isCaseInsensitive();
+
     public boolean isScheme(String scheme) {
       return scheme.equals(scheme());
     }
 
+    @Memoized
+    public ObjectId sha1() {
+      return sha1(isCaseInsensitive());
+    }
+
     /**
      * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
      * notes branch.
      */
     @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
-    public ObjectId sha1() {
-      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
+    private ObjectId sha1(Boolean isCaseInsensitive) {
+      String keyString = isCaseInsensitive ? get().toLowerCase(Locale.US) : get();
+      return ObjectId.fromRaw(Hashing.sha1().hashString(keyString, UTF_8).asBytes());
+    }
+
+    @Memoized
+    public ObjectId caseSensitiveSha1() {
+      return sha1(false);
     }
 
     /**
@@ -211,100 +227,27 @@
       return get();
     }
 
+    @Override
+    public final boolean equals(Object obj) {
+      if (!(obj instanceof ExternalId.Key)) {
+        return false;
+      }
+      ExternalId.Key o = (ExternalId.Key) obj;
+
+      return sha1().equals(o.sha1());
+    }
+
+    @Override
+    @Memoized
+    public int hashCode() {
+      return Objects.hash(sha1());
+    }
+
     public static ImmutableSet<ExternalId.Key> from(Collection<ExternalId> extIds) {
       return extIds.stream().map(ExternalId::key).collect(toImmutableSet());
     }
   }
 
-  /**
-   * Creates an external ID.
-   *
-   * @param scheme the scheme name, must not contain colons (':')
-   * @param id the external ID, must not contain colons (':')
-   * @param accountId the ID of the account to which the external ID belongs
-   * @return the created external ID
-   */
-  public static ExternalId create(String scheme, String id, Account.Id accountId) {
-    return create(Key.create(scheme, id), accountId, null, null);
-  }
-
-  /**
-   * Creates an external ID.
-   *
-   * @param scheme the scheme name, must not contain colons (':')
-   * @param id the external ID, must not contain colons (':')
-   * @param accountId the ID of the account to which the external ID belongs
-   * @param email the email of the external ID, may be {@code null}
-   * @param hashedPassword the hashed password of the external ID, may be {@code null}
-   * @return the created external ID
-   */
-  public static ExternalId create(
-      String scheme,
-      String id,
-      Account.Id accountId,
-      @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(Key.create(scheme, id), accountId, email, hashedPassword);
-  }
-
-  public static ExternalId create(Key key, Account.Id accountId) {
-    return create(key, accountId, null, null);
-  }
-
-  public static ExternalId create(
-      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
-    return create(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
-  }
-
-  public static ExternalId createWithPassword(
-      Key key, Account.Id accountId, @Nullable String email, @Nullable String plainPassword) {
-    plainPassword = Strings.emptyToNull(plainPassword);
-    String hashedPassword =
-        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
-    return create(key, accountId, email, hashedPassword);
-  }
-
-  /**
-   * Create a external ID for a username (scheme "username").
-   *
-   * @param id the external ID, must not contain colons (':')
-   * @param accountId the ID of the account to which the external ID belongs
-   * @param plainPassword the plain HTTP password, may be {@code null}
-   * @return the created external ID
-   */
-  public static ExternalId createUsername(
-      String id, Account.Id accountId, @Nullable String plainPassword) {
-    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
-  }
-
-  /**
-   * Creates an external ID with an email.
-   *
-   * @param scheme the scheme name, must not contain colons (':')
-   * @param id the external ID, must not contain colons (':')
-   * @param accountId the ID of the account to which the external ID belongs
-   * @param email the email of the external ID, may be {@code null}
-   * @return the created external ID
-   */
-  public static ExternalId createWithEmail(
-      String scheme, String id, Account.Id accountId, @Nullable String email) {
-    return createWithEmail(Key.create(scheme, id), accountId, email);
-  }
-
-  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
-    return create(key, accountId, Strings.emptyToNull(email), null);
-  }
-
-  public static ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(SCHEME_MAILTO, email, accountId, requireNonNull(email));
-  }
-
-  static ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
-    return new AutoValue_ExternalId(
-        extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
-  }
-
   @VisibleForTesting
   public static ExternalId create(
       Key key,
@@ -313,116 +256,20 @@
       @Nullable String hashedPassword,
       @Nullable ObjectId blobId) {
     return new AutoValue_ExternalId(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
-  }
-
-  /**
-   * Parses an external ID from a byte array that contain the external ID as an Git config file
-   * text.
-   *
-   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
-   * email and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   */
-  public static ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
-      throws ConfigInvalidException {
-    Config externalIdConfig = new Config();
-    try {
-      externalIdConfig.fromText(new String(raw, UTF_8));
-    } catch (ConfigInvalidException e) {
-      throw invalidConfig(noteId, e.getMessage());
-    }
-
-    return parse(noteId, externalIdConfig, blobId);
-  }
-
-  public static ExternalId parse(String noteId, Config externalIdConfig, ObjectId blobId)
-      throws ConfigInvalidException {
-    requireNonNull(blobId);
-
-    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
-    if (externalIdKeys.size() != 1) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Expected exactly 1 '%s' section, found %d",
-              EXTERNAL_ID_SECTION, externalIdKeys.size()));
-    }
-
-    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
-    Key externalIdKey = Key.parse(externalIdKeyStr);
-    if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
-    }
-
-    if (!externalIdKey.sha1().getName().equals(noteId)) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
-    }
-
-    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
-    String password =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
-    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
-
-    return create(
-        externalIdKey,
-        Account.id(accountId),
+        key,
+        accountId,
+        key.isCaseInsensitive(),
         Strings.emptyToNull(email),
-        Strings.emptyToNull(password),
+        Strings.emptyToNull(hashedPassword),
         blobId);
   }
 
-  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
-      throws ConfigInvalidException {
-    String accountIdStr =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value for '%s.%s.%s' is missing, expected account ID",
-              EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-
-    try {
-      int accountId =
-          externalIdConfig.getInt(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY, -1);
-      if (accountId < 0) {
-        throw invalidConfig(
-            noteId,
-            String.format(
-                "Value %s for '%s.%s.%s' is invalid, expected account ID",
-                accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-      }
-      return accountId;
-    } catch (IllegalArgumentException e) {
-      String msg =
-          String.format(
-              "Value %s for '%s.%s.%s' is invalid, expected account ID",
-              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
-      logger.atSevere().withCause(e).log(msg);
-      throw invalidConfig(noteId, msg);
-    }
-  }
-
-  private static ConfigInvalidException invalidConfig(String noteId, String message) {
-    return new ConfigInvalidException(
-        String.format("Invalid external ID config for note '%s': %s", noteId, message));
-  }
-
   public abstract Key key();
 
   public abstract Account.Id accountId();
 
+  public abstract boolean isCaseInsensitive();
+
   public abstract @Nullable String email();
 
   public abstract @Nullable String password();
@@ -463,13 +310,15 @@
     ExternalId o = (ExternalId) obj;
     return Objects.equals(key(), o.key())
         && Objects.equals(accountId(), o.accountId())
+        && Objects.equals(isCaseInsensitive(), o.isCaseInsensitive())
         && Objects.equals(email(), o.email())
         && Objects.equals(password(), o.password());
   }
 
+  @Memoized
   @Override
-  public final int hashCode() {
-    return Objects.hash(key(), accountId(), email(), password());
+  public int hashCode() {
+    return Objects.hash(key(), accountId(), isCaseInsensitive(), email(), password());
   }
 
   /**
@@ -486,7 +335,8 @@
    * </pre>
    */
   @Override
-  public final String toString() {
+  @Memoized
+  public String toString() {
     Config c = new Config();
     writeToConfig(c);
     return c.toText();
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
index 92e7c71..0029557 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -19,10 +19,12 @@
 import com.google.gerrit.entities.Account;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
- * Caches external IDs of all accounts.
+ * Caches external IDs of all accounts. Note that the granularity is "revision" only, so each update
+ * will cache a new value containing <b>all</b> external IDs.
  *
  * <p>On each cache access the SHA1 of the refs/meta/external-ids branch is read to verify that the
  * cache is up to date.
@@ -30,12 +32,23 @@
  * <p>All returned collections are unmodifiable.
  */
 interface ExternalIdCache {
+
+  /**
+   * Updates the cache.
+   *
+   * @param oldNotesRev current revision against which the below updates are applied
+   * @param newNotesRev key for the new cache revision
+   * @param toRemove external IDs to remove
+   * @param toAdd external IDs to add
+   */
   void onReplace(
       ObjectId oldNotesRev,
       ObjectId newNotesRev,
       Collection<ExternalId> toRemove,
       Collection<ExternalId> toAdd);
 
+  Optional<ExternalId> byKey(ExternalId.Key key) throws IOException;
+
   ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
 
   ImmutableSet<ExternalId> byAccount(Account.Id accountId, ObjectId rev) throws IOException;
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
index 9084de7..e6db593 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -26,6 +26,7 @@
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
@@ -73,6 +74,11 @@
   }
 
   @Override
+  public Optional<ExternalId> byKey(ExternalId.Key key) throws IOException {
+    return Optional.ofNullable(get().byKey().get(key));
+  }
+
+  @Override
   public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
     return get().byAccount().get(accountId);
   }
@@ -135,7 +141,7 @@
         m = MultimapBuilder.hashKeys().hashSetValues().build();
       }
       update.accept(m);
-      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m));
+      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m.values().stream()));
     } catch (ExecutionException e) {
       logger.atWarning().withCause(e).log("Cannot update external IDs");
     } finally {
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
index 8887e06..72d703b 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheLoader.java
@@ -17,6 +17,7 @@
 import com.google.common.base.CharMatcher;
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheLoader;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.flogger.FluentLogger;
@@ -72,6 +73,7 @@
   private final Timer0 reloadDifferential;
   private final boolean enablePartialReloads;
   private final boolean isPersistentCache;
+  private final ExternalIdFactory externalIdFactory;
 
   @Inject
   ExternalIdCacheLoader(
@@ -81,7 +83,8 @@
       @Named(ExternalIdCacheImpl.CACHE_NAME)
           Provider<Cache<ObjectId, AllExternalIds>> externalIdCache,
       MetricMaker metricMaker,
-      @GerritServerConfig Config config) {
+      @GerritServerConfig Config config,
+      ExternalIdFactory externalIdFactory) {
     this.externalIdReader = externalIdReader;
     this.externalIdCache = externalIdCache;
     this.gitRepositoryManager = gitRepositoryManager;
@@ -92,7 +95,9 @@
             new Description("Total number of external ID cache reloads from Git.")
                 .setRate()
                 .setUnit("updates"),
-            Field.ofBoolean("partial", Metadata.Builder::partial).build());
+            Field.ofBoolean("partial", Metadata.Builder::partial)
+                .description("Whether the reload was partial.")
+                .build());
     this.reloadDifferential =
         metricMaker.newTimer(
             "notedb/external_id_partial_read_latency",
@@ -104,6 +109,7 @@
         config.getBoolean("cache", ExternalIdCacheImpl.CACHE_NAME, "enablePartialReloads", true);
     this.isPersistentCache =
         config.getInt("cache", ExternalIdCacheImpl.CACHE_NAME, "diskLimit", 0) > 0;
+    this.externalIdFactory = externalIdFactory;
   }
 
   @Override
@@ -215,12 +221,13 @@
    * @param additions map of name to blob ID for each external ID that should be added
    * @param removals set of name {@link ObjectId}s that should be removed
    */
-  private static AllExternalIds buildAllExternalIds(
+  private AllExternalIds buildAllExternalIds(
       Repository repo,
       AllExternalIds oldExternalIds,
       Map<ObjectId, ObjectId> additions,
       Set<ObjectId> removals)
       throws IOException {
+    ImmutableMap.Builder<ExternalId.Key, ExternalId> byKey = ImmutableMap.builder();
     ImmutableSetMultimap.Builder<Account.Id, ExternalId> byAccount = ImmutableSetMultimap.builder();
     ImmutableSetMultimap.Builder<String, ExternalId> byEmail = ImmutableSetMultimap.builder();
 
@@ -230,6 +237,7 @@
         continue;
       }
 
+      byKey.put(externalId.key(), externalId);
       byAccount.put(externalId.accountId(), externalId);
       if (externalId.email() != null) {
         byEmail.put(externalId.email(), externalId);
@@ -242,7 +250,7 @@
         ExternalId parsedExternalId;
         try {
           parsedExternalId =
-              ExternalId.parse(
+              externalIdFactory.parse(
                   nameToBlob.getKey().name(),
                   reader.open(nameToBlob.getValue()).getCachedBytes(),
                   nameToBlob.getValue());
@@ -252,13 +260,14 @@
           continue;
         }
 
+        byKey.put(parsedExternalId.key(), parsedExternalId);
         byAccount.put(parsedExternalId.accountId(), parsedExternalId);
         if (parsedExternalId.email() != null) {
           byEmail.put(parsedExternalId.email(), parsedExternalId);
         }
       }
     }
-    return new AutoValue_AllExternalIds(byAccount.build(), byEmail.build());
+    return new AutoValue_AllExternalIds(byKey.build(), byAccount.build(), byEmail.build());
   }
 
   private AllExternalIds reloadAllExternalIds(ObjectId notesRev)
@@ -269,7 +278,7 @@
             Metadata.builder().revision(notesRev.name()).build())) {
       ImmutableSet<ExternalId> externalIds = externalIdReader.all(notesRev);
       externalIds.forEach(ExternalId::checkThatBlobIdIsSet);
-      AllExternalIds allExternalIds = AllExternalIds.create(externalIds);
+      AllExternalIds allExternalIds = AllExternalIds.create(externalIds.stream());
       reloadCounter.increment(false);
       return allExternalIds;
     }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
new file mode 100644
index 0000000..f0ad1b2
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCacheModule.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
+import com.google.inject.TypeLiteral;
+import java.time.Duration;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class ExternalIdCacheModule extends CacheModule {
+  @Override
+  protected void configure() {
+    persist(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
+        // The cached data is potentially pretty large and we are always only interested
+        // in the latest value. However, due to a race condition, it is possible for different
+        // threads to observe different values of the meta ref, and hence request different keys
+        // from the cache. Extend the cache size by 1 to cover this case, but expire the extra
+        // object after a short period of time, since it may be a potentially large amount of
+        // memory.
+        // When loading a new value because the primary data advanced, we want to leverage the old
+        // cache state to recompute only what changed. This doesn't affect cache size though as
+        // Guava calls the loader first and evicts later on.
+        .maximumWeight(2)
+        .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
+        .loader(ExternalIdCacheLoader.class)
+        .diskLimit(-1)
+        .version(1)
+        .keySerializer(ObjectIdCacheSerializer.INSTANCE)
+        .valueSerializer(AllExternalIds.Serializer.INSTANCE);
+
+    bind(ExternalIdCacheImpl.class);
+    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
new file mode 100644
index 0000000..a59e935
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdCaseSensitivityMigrator.java
@@ -0,0 +1,138 @@
+// 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.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.exceptions.DuplicateKeyException;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+
+public class ExternalIdCaseSensitivityMigrator {
+
+  public static class ExternalIdCaseSensitivityMigratorModule extends AbstractModule {
+    @Override
+    public void configure() {
+      install(new FactoryModuleBuilder().build(ExternalIdCaseSensitivityMigrator.Factory.class));
+    }
+  }
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public interface Factory {
+    ExternalIdCaseSensitivityMigrator create(
+        @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
+        @Assisted("dryRun") Boolean dryRun);
+  }
+
+  private GitRepositoryManager repoManager;
+  private AllUsersName allUsersName;
+  private Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+  private ExternalIdNotes.FactoryNoReindex externalIdNotesFactory;
+
+  private ExternalIdFactory externalIdFactory;
+  private Boolean isUserNameCaseInsensitive;
+  private Boolean dryRun;
+
+  @Inject
+  public ExternalIdCaseSensitivityMigrator(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory,
+      ExternalIdNotes.FactoryNoReindex externalIdNotesFactory,
+      ExternalIdFactory externalIdFactory,
+      @Assisted("isUserNameCaseInsensitive") Boolean isUserNameCaseInsensitive,
+      @Assisted("dryRun") Boolean dryRun) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
+    this.externalIdNotesFactory = externalIdNotesFactory;
+    this.externalIdFactory = externalIdFactory;
+
+    this.isUserNameCaseInsensitive = isUserNameCaseInsensitive;
+    this.dryRun = dryRun;
+  }
+
+  private void recomputeExternalIdNoteId(ExternalIdNotes extIdNotes, ExternalId extId)
+      throws DuplicateKeyException, IOException {
+    if (extId.isScheme(SCHEME_GERRIT) || extId.isScheme(SCHEME_USERNAME)) {
+      ExternalIdKeyFactory keyFactory =
+          new ExternalIdKeyFactory(
+              new ExternalIdKeyFactory.Config() {
+                @Override
+                public boolean isUserNameCaseInsensitive() {
+                  return isUserNameCaseInsensitive;
+                }
+              });
+      ExternalId.Key updatedKey = keyFactory.create(extId.key().scheme(), extId.key().id());
+      ExternalId.Key oldKey =
+          keyFactory.create(extId.key().scheme(), extId.key().id(), !isUserNameCaseInsensitive);
+      if (!oldKey.sha1().getName().equals(updatedKey.sha1().getName())
+          && !extId.key().sha1().getName().equals(updatedKey.sha1().getName())) {
+        logger.atInfo().log("Converting note name of external ID: %s", oldKey);
+        ExternalId updatedExtId =
+            externalIdFactory.create(
+                updatedKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
+        ExternalId oldExtId =
+            externalIdFactory.create(
+                oldKey, extId.accountId(), extId.email(), extId.password(), extId.blobId());
+        extIdNotes.replace(
+            Collections.singleton(oldExtId),
+            Collections.singleton(updatedExtId),
+            (externalId) -> externalId.key().sha1());
+      }
+    }
+  }
+
+  public void migrate(Collection<ExternalId> todo, Runnable monitor)
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
+      for (ExternalId extId : todo) {
+        recomputeExternalIdNoteId(extIdNotes, extId);
+        monitor.run();
+      }
+      if (!dryRun) {
+        try (MetaDataUpdate metaDataUpdate =
+            metaDataUpdateServerFactory.get().create(allUsersName)) {
+          metaDataUpdate.setMessage(
+              String.format(
+                  "Migration to case %ssensitive usernames",
+                  isUserNameCaseInsensitive ? "" : "in"));
+          extIdNotes.commit(metaDataUpdate);
+        } catch (Exception e) {
+          logger.atSevere().withCause(e).log(e.getMessage());
+        }
+      }
+    } catch (DuplicateExternalIdKeyException e) {
+      logger.atSevere().withCause(e).log(e.getMessage());
+      throw e;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
new file mode 100644
index 0000000..bd9c7df
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdFactory.java
@@ -0,0 +1,335 @@
+// 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.account.externalids;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AuthConfig;
+import java.util.Set;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+
+@Singleton
+public class ExternalIdFactory {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+  private AuthConfig authConfig;
+
+  @Inject
+  public ExternalIdFactory(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
+  }
+
+  /**
+   * Creates an external ID.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @return the created external ID
+   */
+  public ExternalId create(String scheme, String id, Account.Id accountId) {
+    return create(externalIdKeyFactory.create(scheme, id), accountId, null, null);
+  }
+
+  /**
+   * Creates an external ID.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @param hashedPassword the hashed password of the external ID, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId create(
+      String scheme,
+      String id,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(externalIdKeyFactory.create(scheme, id), accountId, email, hashedPassword);
+  }
+
+  /**
+   * Creates an external ID.
+   *
+   * @param key the external Id key
+   * @param accountId the ID of the account to which the external ID belongs
+   * @return the created external ID
+   */
+  public ExternalId create(ExternalId.Key key, Account.Id accountId) {
+    return create(key, accountId, null, null);
+  }
+
+  /**
+   * Creates an external ID.
+   *
+   * @param key the external Id key
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @param hashedPassword the hashed password of the external ID, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId create(
+      ExternalId.Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
+  }
+
+  /**
+   * Creates an external ID adding a hashed password computed from a plain password.
+   *
+   * @param key the external Id key
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @param plainPassword the plain HTTP password, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId createWithPassword(
+      ExternalId.Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String plainPassword) {
+    plainPassword = Strings.emptyToNull(plainPassword);
+    String hashedPassword =
+        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
+    return create(key, accountId, email, hashedPassword);
+  }
+
+  /**
+   * Create a external ID for a username (scheme "username").
+   *
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param plainPassword the plain HTTP password, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId createUsername(
+      String id, Account.Id accountId, @Nullable String plainPassword) {
+    return createWithPassword(
+        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, id),
+        accountId,
+        null,
+        plainPassword);
+  }
+
+  /**
+   * Creates an external ID with an email.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email) {
+    return createWithEmail(externalIdKeyFactory.create(scheme, id), accountId, email);
+  }
+
+  /**
+   * Creates an external ID with an email.
+   *
+   * @param key the external Id key
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId createWithEmail(
+      ExternalId.Key key, Account.Id accountId, @Nullable String email) {
+    return create(key, accountId, Strings.emptyToNull(email), null);
+  }
+
+  /**
+   * Creates an external ID using the `mailto`-scheme.
+   *
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @return the created external ID
+   */
+  public ExternalId createEmail(Account.Id accountId, String email) {
+    return createWithEmail(ExternalId.SCHEME_MAILTO, email, accountId, requireNonNull(email));
+  }
+
+  ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
+    return create(extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
+  }
+
+  /**
+   * Creates an external ID.
+   *
+   * @param key the external Id key
+   * @param accountId the ID of the account to which the external ID belongs
+   * @param email the email of the external ID, may be {@code null}
+   * @param hashedPassword the hashed password of the external ID, may be {@code null}
+   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
+   *     {@code null} if the external ID was created in code and is not yet stored in Git.
+   * @return the created external ID
+   */
+  public ExternalId create(
+      ExternalId.Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword,
+      @Nullable ObjectId blobId) {
+    return ExternalId.create(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
+  }
+
+  /**
+   * Parses an external ID from a byte array that contains the external ID as a Git config file
+   * text.
+   *
+   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
+   * email and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   *
+   * @param noteId the SHA-1 sum of the external ID used as the note's ID
+   * @param raw a byte array that contains the external ID as a Git config file text.
+   * @param blobId the ID of the note blob in the external IDs branch that stores this external ID.
+   *     {@code null} if the external ID was created in code and is not yet stored in Git.
+   * @return the parsed external ID
+   */
+  public ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
+      throws ConfigInvalidException {
+    requireNonNull(blobId);
+
+    Config externalIdConfig = new Config();
+    try {
+      externalIdConfig.fromText(new String(raw, UTF_8));
+    } catch (ConfigInvalidException e) {
+      throw invalidConfig(noteId, e.getMessage());
+    }
+
+    Set<String> externalIdKeys = externalIdConfig.getSubsections(ExternalId.EXTERNAL_ID_SECTION);
+    if (externalIdKeys.size() != 1) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Expected exactly 1 '%s' section, found %d",
+              ExternalId.EXTERNAL_ID_SECTION, externalIdKeys.size()));
+    }
+
+    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
+    ExternalId.Key externalIdKey = externalIdKeyFactory.parse(externalIdKeyStr);
+    if (externalIdKey == null) {
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
+    }
+
+    if (!externalIdKey.sha1().getName().equals(noteId)) {
+      if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+      }
+
+      if (!externalIdKey.caseSensitiveSha1().getName().equals(noteId)) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Neither case sensitive nor case insensitive SHA1 of external ID '%s' match note ID '%s'",
+                externalIdKeyStr, noteId));
+      }
+      externalIdKey =
+          externalIdKeyFactory.create(externalIdKey.scheme(), externalIdKey.id(), false);
+    }
+
+    String email =
+        externalIdConfig.getString(
+            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.EMAIL_KEY);
+    String password =
+        externalIdConfig.getString(
+            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.PASSWORD_KEY);
+    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
+
+    return create(
+        externalIdKey,
+        Account.id(accountId),
+        Strings.emptyToNull(email),
+        Strings.emptyToNull(password),
+        blobId);
+  }
+
+  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
+      throws ConfigInvalidException {
+    String accountIdStr =
+        externalIdConfig.getString(
+            ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value for '%s.%s.%s' is missing, expected account ID",
+              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY));
+    }
+
+    try {
+      int accountId =
+          externalIdConfig.getInt(
+              ExternalId.EXTERNAL_ID_SECTION, externalIdKeyStr, ExternalId.ACCOUNT_ID_KEY, -1);
+      if (accountId < 0) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                accountIdStr,
+                ExternalId.EXTERNAL_ID_SECTION,
+                externalIdKeyStr,
+                ExternalId.ACCOUNT_ID_KEY));
+      }
+      return accountId;
+    } catch (IllegalArgumentException e) {
+      String msg =
+          String.format(
+              "Value %s for '%s.%s.%s' is invalid, expected account ID",
+              accountIdStr,
+              ExternalId.EXTERNAL_ID_SECTION,
+              externalIdKeyStr,
+              ExternalId.ACCOUNT_ID_KEY);
+      logger.atSevere().withCause(e).log(msg);
+      throw invalidConfig(noteId, msg);
+    }
+  }
+
+  private static ConfigInvalidException invalidConfig(String noteId, String message) {
+    return new ConfigInvalidException(
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
new file mode 100644
index 0000000..68d8b0c
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdKeyFactory.java
@@ -0,0 +1,103 @@
+// 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.account.externalids;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.ImplementedBy;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+@Singleton
+public class ExternalIdKeyFactory {
+  @ImplementedBy(ConfigImpl.class)
+  public interface Config {
+    boolean isUserNameCaseInsensitive();
+  }
+
+  /**
+   * Default implementation {@link Config}
+   *
+   * <p>Internally in google we are using different implementation.
+   */
+  @Singleton
+  public static class ConfigImpl implements Config {
+    private final boolean isUserNameCaseInsensitive;
+
+    @VisibleForTesting
+    @Inject
+    public ConfigImpl(AuthConfig authConfig) {
+      this.isUserNameCaseInsensitive = authConfig.isUserNameCaseInsensitive();
+    }
+
+    @Override
+    public boolean isUserNameCaseInsensitive() {
+      return isUserNameCaseInsensitive;
+    }
+  }
+
+  private final boolean isUserNameCaseInsensitive;
+
+  @Inject
+  public ExternalIdKeyFactory(Config config) {
+    this.isUserNameCaseInsensitive = config.isUserNameCaseInsensitive();
+  }
+
+  /**
+   * Creates an external ID key.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @return the created external ID key
+   */
+  public ExternalId.Key create(@Nullable String scheme, String id) {
+    return create(scheme, id, isUserNameCaseInsensitive);
+  }
+
+  /**
+   * Creates an external ID key.
+   *
+   * @param scheme the scheme name, must not contain colons (':'). E.g. {@link
+   *     ExternalId#SCHEME_USERNAME}.
+   * @param id the external ID, must not contain colons (':')
+   * @param userNameCaseInsensitive whether the external ID key is matched case insensitively
+   * @return the created external ID key
+   */
+  public ExternalId.Key create(
+      @Nullable String scheme, String id, boolean userNameCaseInsensitive) {
+    if (scheme != null
+        && (scheme.equals(ExternalId.SCHEME_USERNAME) || scheme.equals(ExternalId.SCHEME_GERRIT))) {
+      return ExternalId.Key.create(scheme, id, userNameCaseInsensitive);
+    }
+
+    return ExternalId.Key.create(scheme, id, false);
+  }
+
+  /**
+   * Parses an external ID key from its String representation
+   *
+   * @param externalId String representation of external ID key (e.g. username:johndoe)
+   * @return the external Id key object
+   */
+  public ExternalId.Key parse(String externalId) {
+    int c = externalId.indexOf(':');
+    if (c < 1 || c >= externalId.length() - 1) {
+      return create(null, externalId);
+    }
+    return create(externalId.substring(0, c), externalId.substring(c + 1));
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
index 3e5d7b8..da7b357 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2017 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.
@@ -14,34 +14,13 @@
 
 package com.google.gerrit.server.account.externalids;
 
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
-import com.google.inject.TypeLiteral;
-import java.time.Duration;
-import org.eclipse.jgit.lib.ObjectId;
+import com.google.inject.AbstractModule;
 
-public class ExternalIdModule extends CacheModule {
+public class ExternalIdModule extends AbstractModule {
   @Override
   protected void configure() {
-    persist(ExternalIdCacheImpl.CACHE_NAME, ObjectId.class, new TypeLiteral<AllExternalIds>() {})
-        // The cached data is potentially pretty large and we are always only interested
-        // in the latest value. However, due to a race condition, it is possible for different
-        // threads to observe different values of the meta ref, and hence request different keys
-        // from the cache. Extend the cache size by 1 to cover this case, but expire the extra
-        // object after a short period of time, since it may be a potentially large amount of
-        // memory.
-        // When loading a new value because the primary data advanced, we want to leverage the old
-        // cache state to recompute only what changed. This doesn't affect cache size though as
-        // Guava calls the loader first and evicts later on.
-        .maximumWeight(2)
-        .expireFromMemoryAfterAccess(Duration.ofMinutes(1))
-        .loader(ExternalIdCacheLoader.class)
-        .diskLimit(-1)
-        .version(1)
-        .keySerializer(ObjectIdCacheSerializer.INSTANCE)
-        .valueSerializer(AllExternalIds.Serializer.INSTANCE);
-
-    bind(ExternalIdCacheImpl.class);
-    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
+    bind(ExternalIdFactory.class);
+    bind(ExternalIdKeyFactory.class);
+    bind(PasswordVerifier.class);
   }
 }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
index e999c93..aa37451 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdNotes.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.Objects.requireNonNull;
-import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
@@ -30,14 +29,15 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -54,6 +54,7 @@
 import java.util.List;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -86,21 +87,46 @@
  * <p>On save the staged external ID updates are performed (see {@link #onSave(CommitBuilder)}).
  *
  * <p>After committing the external IDs a cache update can be requested which also reindexes the
- * accounts for which external IDs have been updated (see {@link #updateCaches()}).
+ * accounts for which external IDs have been updated (see {@link
+ * ExternalIdNotesLoader#updateExternalIdCacheAndMaybeReindexAccounts(ExternalIdNotes,
+ * Collection)}).
  */
 public class ExternalIdNotes extends VersionedMetaData {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private static final int MAX_NOTE_SZ = 1 << 19;
 
-  public interface ExternalIdNotesLoader {
+  public abstract static class ExternalIdNotesLoader {
+    protected final ExternalIdCache externalIdCache;
+    protected final MetricMaker metricMaker;
+    protected final AllUsersName allUsersName;
+    protected final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
+    protected final ExternalIdFactory externalIdFactory;
+    protected final AuthConfig authConfig;
+
+    protected ExternalIdNotesLoader(
+        ExternalIdCache externalIdCache,
+        MetricMaker metricMaker,
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      this.externalIdCache = externalIdCache;
+      this.metricMaker = metricMaker;
+      this.allUsersName = allUsersName;
+      this.upsertPreprocessors = upsertPreprocessors;
+      this.externalIdFactory = externalIdFactory;
+      this.authConfig = authConfig;
+    }
+
     /**
      * Loads the external ID notes from the current tip of the {@code refs/meta/external-ids}
      * branch.
      *
      * @param allUsersRepo the All-Users repository
      */
-    ExternalIdNotes load(Repository allUsersRepo) throws IOException, ConfigInvalidException;
+    public abstract ExternalIdNotes load(Repository allUsersRepo)
+        throws IOException, ConfigInvalidException;
 
     /**
      * Loads the external ID notes from the specified revision of the {@code refs/meta/external-ids}
@@ -112,42 +138,98 @@
      *     assumed that the {@code refs/meta/external-ids} branch doesn't exist and the loaded
      *     external IDs will be empty
      */
-    ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
+    public abstract ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException;
+
+    /**
+     * Updates the external ID cache. Subclasses of type {@link Factory} will also reindex the
+     * accounts for which external IDs were modified, while subclasses of type {@link
+     * FactoryNoReindex} will skip this.
+     *
+     * <p>Must only be called after committing changes.
+     *
+     * @param externalIdNotes the committed updates that should be applied to the cache. This first
+     *     and last element must be the updates commited first and last, respectively.
+     * @param accountsToSkipForReindex accounts that should not be reindexed. This is to avoid
+     *     double reindexing when updated accounts will already be reindexed by
+     *     ReindexAfterRefUpdate.
+     */
+    public void updateExternalIdCacheAndMaybeReindexAccounts(
+        ExternalIdNotes externalIdNotes, Collection<Account.Id> accountsToSkipForReindex)
+        throws IOException {
+      checkState(externalIdNotes.oldRev != null, "no changes committed yet");
+
+      // readOnly is ignored here (legacy behavior).
+
+      // Aggregate all updates.
+      ExternalIdCacheUpdates updates = new ExternalIdCacheUpdates();
+      for (CacheUpdate cacheUpdate : externalIdNotes.cacheUpdates) {
+        cacheUpdate.execute(updates);
+      }
+
+      // Perform the cache update.
+      if (!externalIdNotes.noCacheUpdate) {
+        // Regardless of noCacheUpdate it's still possible that the ExternalIdCache instance is of
+        // type DisabledExternalIdCache, making this call a no-op.
+        externalIdCache.onReplace(
+            externalIdNotes.oldRev,
+            externalIdNotes.getRevision(),
+            updates.getRemoved(),
+            updates.getAdded());
+      }
+
+      // Reindex accounts (if the subclass implements reindexAccount()).
+      if (!externalIdNotes.noReindex) {
+        Streams.concat(updates.getAdded().stream(), updates.getRemoved().stream())
+            .map(ExternalId::accountId)
+            .filter(i -> !accountsToSkipForReindex.contains(i))
+            .distinct()
+            .forEach(this::reindexAccount);
+      }
+
+      // Reset instance state.
+      externalIdNotes.cacheUpdates.clear();
+      externalIdNotes.keysToAdd.clear();
+      externalIdNotes.oldRev = null;
+    }
+
+    protected abstract void reindexAccount(Account.Id id);
   }
 
   @Singleton
-  public static class Factory implements ExternalIdNotesLoader {
-    private final ExternalIdCache externalIdCache;
-    private final AccountCache accountCache;
+  public static class Factory extends ExternalIdNotesLoader {
+
     private final Provider<AccountIndexer> accountIndexer;
-    private final MetricMaker metricMaker;
-    private final AllUsersName allUsersName;
 
     @Inject
     Factory(
         ExternalIdCache externalIdCache,
-        AccountCache accountCache,
         Provider<AccountIndexer> accountIndexer,
         MetricMaker metricMaker,
-        AllUsersName allUsersName) {
-      this.externalIdCache = externalIdCache;
-      this.accountCache = accountCache;
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      super(
+          externalIdCache,
+          metricMaker,
+          allUsersName,
+          upsertPreprocessors,
+          externalIdFactory,
+          authConfig);
       this.accountIndexer = accountIndexer;
-      this.metricMaker = metricMaker;
-      this.allUsersName = allUsersName;
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache,
-              accountCache,
-              accountIndexer,
               metricMaker,
               allUsersName,
-              allUsersRepo)
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .load();
     }
 
@@ -155,35 +237,52 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache,
-              accountCache,
-              accountIndexer,
               metricMaker,
               allUsersName,
-              allUsersRepo)
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
           .load(rev);
     }
+
+    @Override
+    protected void reindexAccount(Account.Id id) {
+      accountIndexer.get().index(id);
+    }
   }
 
   @Singleton
-  public static class FactoryNoReindex implements ExternalIdNotesLoader {
-    private final ExternalIdCache externalIdCache;
-    private final MetricMaker metricMaker;
-    private final AllUsersName allUsersName;
+  public static class FactoryNoReindex extends ExternalIdNotesLoader {
 
     @Inject
     FactoryNoReindex(
-        ExternalIdCache externalIdCache, MetricMaker metricMaker, AllUsersName allUsersName) {
-      this.externalIdCache = externalIdCache;
-      this.metricMaker = metricMaker;
-      this.allUsersName = allUsersName;
+        ExternalIdCache externalIdCache,
+        MetricMaker metricMaker,
+        AllUsersName allUsersName,
+        DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
+        ExternalIdFactory externalIdFactory,
+        AuthConfig authConfig) {
+      super(
+          externalIdCache,
+          metricMaker,
+          allUsersName,
+          upsertPreprocessors,
+          externalIdFactory,
+          authConfig);
     }
 
     @Override
     public ExternalIdNotes load(Repository allUsersRepo)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .setNoReindex()
           .load();
     }
 
@@ -191,28 +290,20 @@
     public ExternalIdNotes load(Repository allUsersRepo, @Nullable ObjectId rev)
         throws IOException, ConfigInvalidException {
       return new ExternalIdNotes(
-              externalIdCache, null, null, metricMaker, allUsersName, allUsersRepo)
+              metricMaker,
+              allUsersName,
+              allUsersRepo,
+              upsertPreprocessors,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .setNoReindex()
           .load(rev);
     }
-  }
 
-  /**
-   * Loads the external ID notes for reading only. The external ID notes are loaded from the current
-   * tip of the {@code refs/meta/external-ids} branch.
-   *
-   * @return read-only {@link ExternalIdNotes} instance
-   */
-  public static ExternalIdNotes loadReadOnly(AllUsersName allUsersName, Repository allUsersRepo)
-      throws IOException, ConfigInvalidException {
-    return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
-            new DisabledMetricMaker(),
-            allUsersName,
-            allUsersRepo)
-        .setReadOnly()
-        .load();
+    @Override
+    protected void reindexAccount(Account.Id id) {
+      // Do not reindex.
+    }
   }
 
   /**
@@ -226,16 +317,22 @@
    * @return read-only {@link ExternalIdNotes} instance
    */
   public static ExternalIdNotes loadReadOnly(
-      AllUsersName allUsersName, Repository allUsersRepo, @Nullable ObjectId rev)
+      AllUsersName allUsersName,
+      Repository allUsersRepo,
+      @Nullable ObjectId rev,
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
             new DisabledMetricMaker(),
             allUsersName,
-            allUsersRepo)
+            allUsersRepo,
+            DynamicMap.emptyMap(),
+            externalIdFactory,
+            isUserNameCaseInsensitiveMigrationMode)
         .setReadOnly()
+        .setNoCacheUpdate()
+        .setNoReindex()
         .load(rev);
   }
 
@@ -250,54 +347,82 @@
    * @return {@link ExternalIdNotes} instance that doesn't updates caches on save
    */
   public static ExternalIdNotes loadNoCacheUpdate(
-      AllUsersName allUsersName, Repository allUsersRepo)
+      AllUsersName allUsersName,
+      Repository allUsersRepo,
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode)
       throws IOException, ConfigInvalidException {
     return new ExternalIdNotes(
-            new DisabledExternalIdCache(),
-            null,
-            null,
             new DisabledMetricMaker(),
             allUsersName,
-            allUsersRepo)
+            allUsersRepo,
+            DynamicMap.emptyMap(),
+            externalIdFactory,
+            isUserNameCaseInsensitiveMigrationMode)
+        .setNoCacheUpdate()
+        .setNoReindex()
         .load();
   }
 
-  private final ExternalIdCache externalIdCache;
-  @Nullable private final AccountCache accountCache;
-  @Nullable private final Provider<AccountIndexer> accountIndexer;
   private final AllUsersName allUsersName;
   private final Counter0 updateCount;
   private final Repository repo;
+  private final DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors;
   private final CallerFinder callerFinder;
+  private final ExternalIdFactory externalIdFactory;
 
   private NoteMap noteMap;
   private ObjectId oldRev;
 
-  // Staged note map updates that should be executed on save.
-  private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+  /** Staged note map updates that should be executed on save. */
+  private final List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
 
-  // Staged cache updates that should be executed after external ID changes have been committed.
-  private List<CacheUpdate> cacheUpdates = new ArrayList<>();
+  /** Staged cache updates that should be executed after external ID changes have been committed. */
+  private final List<CacheUpdate> cacheUpdates = new ArrayList<>();
+
+  /**
+   * When performing batch updates (cf. {@link AccountsUpdate#updateBatch(List)} we need to ensure
+   * the batch does not introduce duplicates. In addition to checking against the status quo in
+   * {@link #noteMap} (cf. {@link #checkExternalIdKeysDontExist(Collection)}), which is sufficient
+   * for single updates, we also need to check for duplicates among the batch updates. As the actual
+   * updates are computed lazily just before applying them, we unfortunately need to track keys
+   * explicitly here even though they are already implicit in the lambdas that constitute the
+   * updates.
+   */
+  private final Set<ExternalId.Key> keysToAdd = new HashSet<>();
 
   private Runnable afterReadRevision;
   private boolean readOnly = false;
+  private boolean noCacheUpdate = false;
+  private boolean noReindex = false;
+  private boolean isUserNameCaseInsensitiveMigrationMode = false;
+  protected final Function<ExternalId, ObjectId> defaultNoteIdResolver =
+      (extId) -> {
+        ObjectId noteId = extId.key().sha1();
+        try {
+          if (isUserNameCaseInsensitiveMigrationMode && !noteMap.contains(noteId)) {
+            noteId = extId.key().caseSensitiveSha1();
+          }
+        } catch (IOException e) {
+          return noteId;
+        }
+        return noteId;
+      };
 
   private ExternalIdNotes(
-      ExternalIdCache externalIdCache,
-      @Nullable AccountCache accountCache,
-      @Nullable Provider<AccountIndexer> accountIndexer,
       MetricMaker metricMaker,
       AllUsersName allUsersName,
-      Repository allUsersRepo) {
-    this.externalIdCache = requireNonNull(externalIdCache, "externalIdCache");
-    this.accountCache = accountCache;
-    this.accountIndexer = accountIndexer;
+      Repository allUsersRepo,
+      DynamicMap<ExternalIdUpsertPreprocessor> upsertPreprocessors,
+      ExternalIdFactory externalIdFactory,
+      boolean isUserNameCaseInsensitiveMigrationMode) {
     this.updateCount =
         metricMaker.newCounter(
             "notedb/external_id_update_count",
             new Description("Total number of external ID updates.").setRate().setUnit("updates"));
     this.allUsersName = requireNonNull(allUsersName, "allUsersRepo");
     this.repo = requireNonNull(allUsersRepo, "allUsersRepo");
+    this.upsertPreprocessors = upsertPreprocessors;
     this.callerFinder =
         CallerFinder.builder()
             // 1. callers that come through ExternalIds
@@ -311,6 +436,8 @@
             // 3. direct callers
             .addTarget(ExternalIdNotes.class)
             .build();
+    this.externalIdFactory = externalIdFactory;
+    this.isUserNameCaseInsensitiveMigrationMode = isUserNameCaseInsensitiveMigrationMode;
   }
 
   public ExternalIdNotes setAfterReadRevision(Runnable afterReadRevision) {
@@ -319,7 +446,17 @@
   }
 
   private ExternalIdNotes setReadOnly() {
-    this.readOnly = true;
+    readOnly = true;
+    return this;
+  }
+
+  private ExternalIdNotes setNoCacheUpdate() {
+    noCacheUpdate = true;
+    return this;
+  }
+
+  private ExternalIdNotes setNoReindex() {
+    noReindex = true;
     return this;
   }
 
@@ -372,16 +509,26 @@
    */
   public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
     checkLoaded();
+    ObjectId noteId = getNoteId(key);
+    if (noteMap.contains(noteId)) {
+
+      try (RevWalk rw = new RevWalk(repo)) {
+        ObjectId noteDataId = noteMap.get(noteId);
+        byte[] raw = readNoteData(rw, noteDataId);
+        return Optional.of(externalIdFactory.parse(noteId.name(), raw, noteDataId));
+      }
+    }
+    return Optional.empty();
+  }
+
+  protected ObjectId getNoteId(ExternalId.Key key) throws IOException {
     ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return Optional.empty();
+
+    if (!noteMap.contains(noteId) && isUserNameCaseInsensitiveMigrationMode) {
+      noteId = key.caseSensitiveSha1();
     }
 
-    try (RevWalk rw = new RevWalk(repo)) {
-      ObjectId noteDataId = noteMap.get(noteId);
-      byte[] raw = readNoteData(rw, noteDataId);
-      return Optional.of(ExternalId.parse(noteId.name(), raw, noteDataId));
-    }
+    return noteId;
   }
 
   /**
@@ -414,7 +561,7 @@
       for (Note note : noteMap) {
         byte[] raw = readNoteData(rw, note.getData());
         try {
-          b.add(ExternalId.parse(note.getName(), raw, note.getData()));
+          b.add(externalIdFactory.parse(note.getName(), raw, note.getData()));
         } catch (ConfigInvalidException | RuntimeException e) {
           logger.atSevere().withCause(e).log(
               "Ignoring invalid external ID note %s", note.getName());
@@ -456,13 +603,15 @@
 
     Set<ExternalId> newExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             newExtIds.add(insertedExtId);
           }
         });
     cacheUpdates.add(cu -> cu.add(newExtIds));
+    incrementalDuplicateDetection(extIds);
   }
 
   /**
@@ -484,13 +633,15 @@
     Set<ExternalId> removedExtIds = get(ExternalId.Key.from(extIds));
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            ExternalId updatedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId updatedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(updatedExtId);
             updatedExtIds.add(updatedExtId);
           }
         });
     cacheUpdates.add(cu -> cu.remove(removedExtIds).add(updatedExtIds));
+    incrementalDuplicateDetection(extIds);
   }
 
   /**
@@ -514,9 +665,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId extId : extIds) {
-            remove(rw, noteMap, f, extId);
+            remove(rw, noteMap, extId);
             removedExtIds.add(extId);
           }
         });
@@ -543,9 +694,9 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : extIdKeys) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
             removedExtIds.add(removedExtId);
           }
         });
@@ -561,15 +712,21 @@
     checkLoaded();
     Set<ExternalId> removedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : extIdKeys) {
-            ExternalId extId = remove(rw, noteMap, f, extIdKey, null);
+            ExternalId extId = remove(rw, noteMap, extIdKey, null);
             removedExtIds.add(extId);
           }
         });
     cacheUpdates.add(cu -> cu.remove(removedExtIds));
   }
 
+  public void replace(
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, DuplicateExternalIdKeyException {
+    replace(accountId, toDelete, toAdd, defaultNoteIdResolver);
+  }
+
   /**
    * Replaces external IDs for an account by external ID keys.
    *
@@ -582,7 +739,10 @@
    *     the specified account.
    */
   public void replace(
-      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      Account.Id accountId,
+      Collection<ExternalId.Key> toDelete,
+      Collection<ExternalId> toAdd,
+      Function<ExternalId, ObjectId> noteIdResolver)
       throws IOException, DuplicateExternalIdKeyException {
     checkLoaded();
     checkSameAccount(toAdd, accountId);
@@ -591,20 +751,22 @@
     Set<ExternalId> removedExtIds = new HashSet<>();
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : toDelete) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, accountId);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, accountId);
             if (removedExtId != null) {
               removedExtIds.add(removedExtId);
             }
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId, noteIdResolver);
+            preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
         });
     cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+    incrementalDuplicateDetection(toAdd);
   }
 
   /**
@@ -625,18 +787,20 @@
     Set<ExternalId> removedExtIds = new HashSet<>();
     Set<ExternalId> updatedExtIds = new HashSet<>();
     noteMapUpdates.add(
-        (rw, n, f) -> {
+        (rw, n) -> {
           for (ExternalId.Key extIdKey : toDelete) {
-            ExternalId removedExtId = remove(rw, noteMap, f, extIdKey, null);
+            ExternalId removedExtId = remove(rw, noteMap, extIdKey, null);
             removedExtIds.add(removedExtId);
           }
 
           for (ExternalId extId : toAdd) {
-            ExternalId insertedExtId = upsert(rw, inserter, noteMap, f, extId);
+            ExternalId insertedExtId = upsert(rw, inserter, noteMap, extId);
+            preprocessUpsert(insertedExtId);
             updatedExtIds.add(insertedExtId);
           }
         });
     cacheUpdates.add(cu -> cu.add(updatedExtIds).remove(removedExtIds));
+    incrementalDuplicateDetection(toAdd);
   }
 
   /**
@@ -672,6 +836,32 @@
     replace(accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd);
   }
 
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(
+      Collection<ExternalId> toDelete,
+      Collection<ExternalId> toAdd,
+      Function<ExternalId, ObjectId> noteIdResolver)
+      throws IOException, DuplicateExternalIdKeyException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(
+        accountId, toDelete.stream().map(ExternalId::key).collect(toSet()), toAdd, noteIdResolver);
+  }
+
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
     if (revision != null) {
@@ -695,66 +885,6 @@
     return commit;
   }
 
-  /**
-   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
-   * external IDs were modified.
-   *
-   * <p>Must only be called after committing changes.
-   *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
-   *
-   * <p>No eviction from account cache and no reindex if this instance was created by {@link
-   * FactoryNoReindex}.
-   */
-  public void updateCaches() throws IOException {
-    updateCaches(ImmutableSet.of());
-  }
-
-  /**
-   * Updates the caches (external ID cache, account cache) and reindexes the accounts for which
-   * external IDs were modified.
-   *
-   * <p>Must only be called after committing changes.
-   *
-   * <p>No-op if this instance was created by {@link #loadNoCacheUpdate(AllUsersName, Repository)}.
-   *
-   * <p>No eviction from account cache if this instance was created by {@link FactoryNoReindex}.
-   *
-   * @param accountsToSkip set of accounts that should not be evicted from the account cache, in
-   *     this case the caller must take care to evict them otherwise
-   */
-  public void updateCaches(Collection<Account.Id> accountsToSkip) throws IOException {
-    checkState(oldRev != null, "no changes committed yet");
-
-    ExternalIdCacheUpdates externalIdCacheUpdates = new ExternalIdCacheUpdates();
-    for (CacheUpdate cacheUpdate : cacheUpdates) {
-      cacheUpdate.execute(externalIdCacheUpdates);
-    }
-
-    externalIdCache.onReplace(
-        oldRev,
-        getRevision(),
-        externalIdCacheUpdates.getRemoved(),
-        externalIdCacheUpdates.getAdded());
-
-    if (accountCache != null || accountIndexer != null) {
-      for (Account.Id id :
-          Streams.concat(
-                  externalIdCacheUpdates.getAdded().stream(),
-                  externalIdCacheUpdates.getRemoved().stream())
-              .map(ExternalId::accountId)
-              .filter(i -> !accountsToSkip.contains(i))
-              .collect(toSet())) {
-        if (accountIndexer != null) {
-          accountIndexer.get().index(id);
-        }
-      }
-    }
-
-    cacheUpdates.clear();
-    oldRev = null;
-  }
-
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkState(!readOnly, "Updating external IDs is disabled");
@@ -770,21 +900,14 @@
     }
 
     try (RevWalk rw = new RevWalk(reader)) {
-      Set<String> footers = new HashSet<>();
       for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
         try {
-          noteMapUpdate.execute(rw, noteMap, footers);
+          noteMapUpdate.execute(rw, noteMap);
         } catch (DuplicateExternalIdKeyException e) {
           throw new IOException(e);
         }
       }
       noteMapUpdates.clear();
-      if (!footers.isEmpty()) {
-        commit.setMessage(
-            footers.stream()
-                .sorted()
-                .collect(joining("\n", commit.getMessage().trim() + "\n\n", "")));
-      }
 
       RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
       ObjectId newTreeId = noteMap.writeTree(inserter);
@@ -829,23 +952,48 @@
     return accountId;
   }
 
+  private void incrementalDuplicateDetection(Collection<ExternalId> externalIds) {
+    externalIds.stream()
+        .map(ExternalId::key)
+        .forEach(
+            key -> {
+              if (!keysToAdd.add(key)) {
+                throw new DuplicateExternalIdKeyException(key);
+              }
+            });
+  }
+
   /**
-   * Insert or updates an new external ID and sets it in the note map.
+   * Inserts or updates a new external ID and sets it in the note map.
    *
-   * <p>If the external ID already exists it is overwritten.
+   * <p>If the external ID already exists, it is overwritten.
    */
-  private static ExternalId upsert(
-      RevWalk rw, ObjectInserter ins, NoteMap noteMap, Set<String> footers, ExternalId extId)
+  private ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    return upsert(rw, ins, noteMap, extId, defaultNoteIdResolver);
+  }
+
+  /**
+   * Inserts or updates a new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists, it is overwritten.
+   */
+  private ExternalId upsert(
+      RevWalk rw,
+      ObjectInserter ins,
+      NoteMap noteMap,
+      ExternalId extId,
+      Function<ExternalId, ObjectId> noteIdResolver)
       throws IOException, ConfigInvalidException {
     ObjectId noteId = extId.key().sha1();
     Config c = new Config();
-    if (noteMap.contains(extId.key().sha1())) {
+    ObjectId resolvedNoteId = noteIdResolver.apply(extId);
+    if (noteMap.contains(resolvedNoteId)) {
+      noteId = resolvedNoteId;
       ObjectId noteDataId = noteMap.get(noteId);
       byte[] raw = readNoteData(rw, noteDataId);
       try {
         c = new BlobBasedConfig(null, raw);
-        ExternalId oldExtId = ExternalId.parse(noteId.name(), c, noteDataId);
-        addFooters(footers, oldExtId);
       } catch (ConfigInvalidException e) {
         throw new ConfigInvalidException(
             String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
@@ -855,9 +1003,7 @@
     byte[] raw = c.toText().getBytes(UTF_8);
     ObjectId noteData = ins.insert(OBJ_BLOB, raw);
     noteMap.set(noteId, noteData);
-    ExternalId newExtId = ExternalId.create(extId, noteData);
-    addFooters(footers, newExtId);
-    return newExtId;
+    return externalIdFactory.create(extId, noteData);
   }
 
   /**
@@ -866,25 +1012,23 @@
    * @throws IllegalStateException is thrown if there is an existing external ID that has the same
    *     key, but otherwise doesn't match the specified external ID.
    */
-  private static ExternalId remove(
-      RevWalk rw, NoteMap noteMap, Set<String> footers, ExternalId extId)
+  private void remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
       throws IOException, ConfigInvalidException {
-    ObjectId noteId = extId.key().sha1();
+    ObjectId noteId = getNoteId(extId.key());
+
     if (!noteMap.contains(noteId)) {
-      return null;
+      return;
     }
 
     ObjectId noteDataId = noteMap.get(noteId);
     byte[] raw = readNoteData(rw, noteDataId);
-    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteDataId);
+    ExternalId actualExtId = externalIdFactory.parse(noteId.name(), raw, noteDataId);
     checkState(
         extId.equals(actualExtId),
         "external id %s should be removed, but it doesn't match the actual external id %s",
         extId.toString(),
         actualExtId.toString());
     noteMap.remove(noteId);
-    addFooters(footers, actualExtId);
-    return actualExtId;
   }
 
   /**
@@ -895,21 +1039,18 @@
    * @return the external ID that was removed, {@code null} if no external ID with the specified key
    *     exists
    */
-  private static ExternalId remove(
-      RevWalk rw,
-      NoteMap noteMap,
-      Set<String> footers,
-      ExternalId.Key extIdKey,
-      Account.Id expectedAccountId)
+  private ExternalId remove(
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
       throws IOException, ConfigInvalidException {
-    ObjectId noteId = extIdKey.sha1();
+    ObjectId noteId = getNoteId(extIdKey);
+
     if (!noteMap.contains(noteId)) {
       return null;
     }
 
     ObjectId noteDataId = noteMap.get(noteId);
     byte[] raw = readNoteData(rw, noteDataId);
-    ExternalId extId = ExternalId.parse(noteId.name(), raw, noteDataId);
+    ExternalId extId = externalIdFactory.parse(noteId.name(), raw, noteDataId);
     if (expectedAccountId != null) {
       checkState(
           expectedAccountId.equals(extId.accountId()),
@@ -920,17 +1061,9 @@
           extId.accountId().get());
     }
     noteMap.remove(noteId);
-    addFooters(footers, extId);
     return extId;
   }
 
-  private static void addFooters(Set<String> footers, ExternalId extId) {
-    footers.add("Account: " + extId.accountId().get());
-    if (extId.email() != null) {
-      footers.add("Email: " + extId.email());
-    }
-  }
-
   private void checkExternalIdsDontExist(Collection<ExternalId> extIds)
       throws DuplicateExternalIdKeyException, IOException {
     checkExternalIdKeysDontExist(ExternalId.Key.from(extIds));
@@ -957,9 +1090,13 @@
     checkState(noteMap != null, "External IDs not loaded yet");
   }
 
+  private void preprocessUpsert(ExternalId extId) {
+    upsertPreprocessors.forEach(p -> p.get().upsert(extId));
+  }
+
   @FunctionalInterface
   private interface NoteMapUpdate {
-    void execute(RevWalk rw, NoteMap noteMap, Set<String> footers)
+    void execute(RevWalk rw, NoteMap noteMap)
         throws IOException, ConfigInvalidException, DuplicateExternalIdKeyException;
   }
 
@@ -969,15 +1106,15 @@
   }
 
   private static class ExternalIdCacheUpdates {
-    private final Set<ExternalId> added = new HashSet<>();
-    private final Set<ExternalId> removed = new HashSet<>();
+    final Set<ExternalId> added = new HashSet<>();
+    final Set<ExternalId> removed = new HashSet<>();
 
     ExternalIdCacheUpdates add(Collection<ExternalId> extIds) {
       this.added.addAll(extIds);
       return this;
     }
 
-    public Set<ExternalId> getAdded() {
+    Set<ExternalId> getAdded() {
       return ImmutableSet.copyOf(added);
     }
 
@@ -986,7 +1123,7 @@
       return this;
     }
 
-    public Set<ExternalId> getRemoved() {
+    Set<ExternalId> getRemoved() {
       return ImmutableSet.copyOf(removed);
     }
   }
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
index c055313..e5aace0 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -69,10 +70,16 @@
   private boolean failOnLoad = false;
   private final Timer0 readAllLatency;
   private final Timer0 readSingleLatency;
+  private final ExternalIdFactory externalIdFactory;
+  private final AuthConfig authConfig;
 
   @Inject
   ExternalIdReader(
-      GitRepositoryManager repoManager, AllUsersName allUsersName, MetricMaker metricMaker) {
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIdFactory externalIdFactory,
+      AuthConfig authConfig) {
     this.repoManager = repoManager;
     this.allUsersName = allUsersName;
     this.readAllLatency =
@@ -87,6 +94,8 @@
             new Description("Latency for reading a single external ID from NoteDb.")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS));
+    this.externalIdFactory = externalIdFactory;
+    this.authConfig = authConfig;
   }
 
   @VisibleForTesting
@@ -106,7 +115,13 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo).all();
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              null,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .all();
     }
   }
 
@@ -125,7 +140,13 @@
 
     try (Timer0.Context ctx = readAllLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).all();
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              rev,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .all();
     }
   }
 
@@ -135,7 +156,13 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo).get(key);
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              null,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .get(key);
     }
   }
 
@@ -146,7 +173,13 @@
 
     try (Timer0.Context ctx = readSingleLatency.start();
         Repository repo = repoManager.openRepository(allUsersName)) {
-      return ExternalIdNotes.loadReadOnly(allUsersName, repo, rev).get(key);
+      return ExternalIdNotes.loadReadOnly(
+              allUsersName,
+              repo,
+              rev,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode())
+          .get(key);
     }
   }
 
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
new file mode 100644
index 0000000..c0697db
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdUpsertPreprocessor.java
@@ -0,0 +1,30 @@
+// 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.account.externalids;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * This optional preprocessor is called in {@link ExternalIdNotes} before an update is committed.
+ */
+@ExtensionPoint
+public interface ExternalIdUpsertPreprocessor {
+  /**
+   * Called when inserting or updating an external ID. {@link ExternalId#blobId()} is set. The
+   * upsert can be blocked by throwing {@link com.google.gerrit.exceptions.StorageException}, e.g.
+   * when a precondition or preparatory work fails.
+   */
+  void upsert(ExternalId extId);
+}
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
index 302a25e..9450ff5 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIds.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -35,11 +36,19 @@
 public class ExternalIds {
   private final ExternalIdReader externalIdReader;
   private final ExternalIdCache externalIdCache;
+  private final AuthConfig authConfig;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
-  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+  public ExternalIds(
+      ExternalIdReader externalIdReader,
+      ExternalIdCache externalIdCache,
+      ExternalIdKeyFactory externalIdKeyFactory,
+      AuthConfig authConfig) {
     this.externalIdReader = externalIdReader;
     this.externalIdCache = externalIdCache;
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
   }
 
   /** Returns all external IDs. */
@@ -53,8 +62,16 @@
   }
 
   /** Returns the specified external ID. */
-  public Optional<ExternalId> get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    return externalIdReader.get(key);
+  public Optional<ExternalId> get(ExternalId.Key key) throws IOException {
+    Optional<ExternalId> externalId = Optional.empty();
+    if (authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+      externalId =
+          externalIdCache.byKey(externalIdKeyFactory.create(key.scheme(), key.id(), false));
+    }
+    if (!externalId.isPresent()) {
+      externalId = externalIdCache.byKey(key);
+    }
+    return externalId;
   }
 
   /** Returns the specified external ID from the given revision. */
diff --git a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
index 815f7d0..4e67e3d 100644
--- a/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
@@ -43,29 +43,32 @@
   private final AllUsersName allUsers;
   private final AccountCache accountCache;
   private final OutgoingEmailValidator validator;
+  private final ExternalIdFactory externalIdFactory;
 
   @Inject
   ExternalIdsConsistencyChecker(
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
       AccountCache accountCache,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      ExternalIdFactory externalIdFactory) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.accountCache = accountCache;
     this.validator = validator;
+    this.externalIdFactory = externalIdFactory;
   }
 
   public List<ConsistencyProblemInfo> check() throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, null, externalIdFactory, false));
     }
   }
 
   public List<ConsistencyProblemInfo> check(ObjectId rev)
       throws IOException, ConfigInvalidException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev));
+      return check(ExternalIdNotes.loadReadOnly(allUsers, repo, rev, externalIdFactory, false));
     }
   }
 
@@ -79,7 +82,7 @@
       for (Note note : noteMap) {
         byte[] raw = ExternalIdNotes.readNoteData(rw, note.getData());
         try {
-          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
+          ExternalId extId = externalIdFactory.parse(note.getName(), raw, note.getData());
           problems.addAll(validateExternalId(extId));
 
           if (extId.email() != null) {
diff --git a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
similarity index 60%
rename from java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
rename to java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
index 6451b0f..8a3e4f1 100644
--- a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigratiorExecutor.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2018 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,11 +12,13 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.elasticsearch.bulk;
+package com.google.gerrit.server.account.externalids;
 
-public class DeleteRequest extends ActionRequest {
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-  public DeleteRequest(String id, String index) {
-    super("delete", id, index);
-  }
-}
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface OnlineExternalIdCaseSensivityMigratiorExecutor {}
diff --git a/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
new file mode 100644
index 0000000..e52991b
--- /dev/null
+++ b/java/com/google/gerrit/server/account/externalids/OnlineExternalIdCaseSensivityMigrator.java
@@ -0,0 +1,117 @@
+// 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.account.externalids;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePath;
+import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.concurrent.Executor;
+import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class OnlineExternalIdCaseSensivityMigrator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private Executor executor;
+  private ExternalIdCaseSensitivityMigrator.Factory migratorFactory;
+  private ExternalIds externalIds;
+  private VersionManager versionManager;
+  private Config globalConfig;
+  private Path sitePath;
+  private final TextProgressMonitor monitor = new TextProgressMonitor();
+  private boolean isUserNameCaseInsensitive;
+  private boolean isUserNameCaseInsensitiveMigrationMode;
+
+  @Inject
+  public OnlineExternalIdCaseSensivityMigrator(
+      @OnlineExternalIdCaseSensivityMigratiorExecutor ExecutorService executor,
+      ExternalIdCaseSensitivityMigrator.Factory migratorFactory,
+      ExternalIds externalIds,
+      VersionManager versionManager,
+      @GerritServerConfig Config globalConfig,
+      @SitePath Path sitePath) {
+    this.migratorFactory = migratorFactory;
+    this.externalIds = externalIds;
+    this.versionManager = versionManager;
+    this.globalConfig = globalConfig;
+    this.sitePath = sitePath;
+    this.executor = executor;
+    this.isUserNameCaseInsensitiveMigrationMode =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
+    this.isUserNameCaseInsensitive =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitive", false);
+  }
+
+  public void migrate() {
+    if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
+      logger.atSevere().log(
+          "External IDs online migration requires auth.userNameCaseInsensitive and auth.userNameCaseInsensitiveMigrationMode to be set to true. Skipping migration!");
+      return;
+    }
+    executor.execute(
+        () -> {
+          try {
+            Collection<ExternalId> todo = externalIds.all();
+            try {
+              monitor.beginTask("Converting external ID note names", todo.size());
+              migratorFactory
+                  .create(isUserNameCaseInsensitive, false)
+                  .migrate(todo, () -> monitor.update(1));
+            } finally {
+              monitor.endTask();
+            }
+            try {
+              updateGerritConfig();
+              monitor.beginTask("Reindex accounts", ProgressMonitor.UNKNOWN);
+              versionManager.startReindexer("accounts", true);
+            } finally {
+              monitor.endTask();
+            }
+            logger.atInfo().log("External IDs migration completed!");
+          } catch (IOException | ConfigInvalidException e) {
+            logger.atSevere().withCause(e).log(
+                "Exception during the external ids migration, cause %s", e.getMessage());
+          } catch (ReindexerAlreadyRunningException e) {
+            logger.atSevere().log("Failed to reindex external ids: %s", e.getMessage());
+          }
+        });
+  }
+
+  private void updateGerritConfig() throws IOException, ConfigInvalidException {
+    logger.atInfo().log(
+        "Setting auth.userNameCaseInsensitiveMigrationMode to false in gerrit.config.");
+
+    FileBasedConfig config =
+        new FileBasedConfig(
+            globalConfig, sitePath.resolve("etc/gerrit.config").toFile(), FS.DETECTED);
+    config.load();
+    config.setBoolean("auth", null, "userNameCaseInsensitiveMigrationMode", false);
+
+    config.save();
+  }
+}
diff --git a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
index 3f2f774..eb2bea9 100644
--- a/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
+++ b/java/com/google/gerrit/server/account/externalids/PasswordVerifier.java
@@ -20,24 +20,47 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AuthConfig;
+import com.google.inject.Inject;
 import java.util.Collection;
 
 /** Checks if a given username and password match a user's external IDs. */
 public class PasswordVerifier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+
+  private AuthConfig authConfig;
+
+  @Inject
+  public PasswordVerifier(ExternalIdKeyFactory externalIdKeyFactory, AuthConfig authConfig) {
+    this.externalIdKeyFactory = externalIdKeyFactory;
+    this.authConfig = authConfig;
+  }
+
   /** Returns {@code true} if there is an external ID matching both the username and password. */
-  public static boolean checkPassword(
+  public boolean checkPassword(
       Collection<ExternalId> externalIds, String username, @Nullable String password) {
     if (password == null) {
       return false;
     }
+
     for (ExternalId id : externalIds) {
       // Only process the "username:$USER" entry, which is unique.
-      if (!id.isScheme(SCHEME_USERNAME) || !username.equals(id.key().id())) {
+      if (!id.isScheme(SCHEME_USERNAME)) {
         continue;
       }
 
+      if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username))) {
+        if (!authConfig.isUserNameCaseInsensitiveMigrationMode()) {
+          continue;
+        }
+
+        if (!id.key().equals(externalIdKeyFactory.create(SCHEME_USERNAME, username, false))) {
+          continue;
+        }
+      }
+
       String hashedStr = id.password();
       if (!Strings.isNullOrEmpty(hashedStr)) {
         try {
diff --git a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
index b8040f7..a42afc3 100644
--- a/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
+++ b/java/com/google/gerrit/server/account/externalids/testing/ExternalIdTestUtil.java
@@ -45,7 +45,9 @@
         rw,
         ident,
         (ins, noteMap) -> {
-          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), accountId);
+          ExternalId extId =
+              ExternalId.create(
+                  ExternalId.Key.parse(externalId, false), accountId, null, null, null);
           ObjectId noteId = extId.key().sha1();
           Config c = new Config();
           extId.writeToConfig(c);
@@ -65,8 +67,10 @@
         rw,
         ident,
         (ins, noteMap) -> {
-          ExternalId extId = ExternalId.create(ExternalId.Key.parse(externalId), accountId);
-          ObjectId noteId = ExternalId.Key.parse(externalId + "x").sha1();
+          ExternalId extId =
+              ExternalId.create(
+                  ExternalId.Key.parse(externalId, false), accountId, null, null, null);
+          ObjectId noteId = ExternalId.Key.parse(externalId + "x", false).sha1();
           Config c = new Config();
           extId.writeToConfig(c);
           byte[] raw = c.toText().getBytes(UTF_8);
@@ -83,7 +87,7 @@
         rw,
         ident,
         (ins, noteMap) -> {
-          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          ObjectId noteId = ExternalId.Key.parse(externalId, false).sha1();
           byte[] raw = "bad-config".getBytes(UTF_8);
           ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
           noteMap.set(noteId, dataBlob);
@@ -98,7 +102,7 @@
         rw,
         ident,
         (ins, noteMap) -> {
-          ObjectId noteId = ExternalId.Key.parse(externalId).sha1();
+          ObjectId noteId = ExternalId.Key.parse(externalId, false).sha1();
           byte[] raw = "".getBytes(UTF_8);
           ObjectId dataBlob = ins.insert(OBJ_BLOB, raw);
           noteMap.set(noteId, dataBlob);
diff --git a/java/com/google/gerrit/server/api/GerritApiModule.java b/java/com/google/gerrit/server/api/GerritApiModule.java
index 9e60107..37fde47 100644
--- a/java/com/google/gerrit/server/api/GerritApiModule.java
+++ b/java/com/google/gerrit/server/api/GerritApiModule.java
@@ -16,16 +16,21 @@
 
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.api.accounts.AccountsModule;
+import com.google.gerrit.server.api.changes.ChangesModule;
+import com.google.gerrit.server.api.config.ConfigModule;
+import com.google.gerrit.server.api.groups.GroupsModule;
+import com.google.gerrit.server.api.projects.ProjectsModule;
 
 public class GerritApiModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(GerritApi.class).to(GerritApiImpl.class);
 
-    install(new com.google.gerrit.server.api.accounts.Module());
-    install(new com.google.gerrit.server.api.changes.Module());
-    install(new com.google.gerrit.server.api.config.Module());
-    install(new com.google.gerrit.server.api.groups.Module());
-    install(new com.google.gerrit.server.api.projects.Module());
+    install(new AccountsModule());
+    install(new ChangesModule());
+    install(new ConfigModule());
+    install(new GroupsModule());
+    install(new ProjectsModule());
   }
 }
diff --git a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 1eee10f..b23782f 100644
--- a/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
 import com.google.gerrit.extensions.api.accounts.SshKeyInput;
 import com.google.gerrit.extensions.api.accounts.StatusInput;
-import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.EditPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -37,7 +36,6 @@
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.AgreementInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.GpgKeyInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
@@ -86,13 +84,11 @@
 import com.google.gerrit.server.restapi.account.SetPreferences;
 import com.google.gerrit.server.restapi.account.SshKeys;
 import com.google.gerrit.server.restapi.account.StarredChanges;
-import com.google.gerrit.server.restapi.account.Stars;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.List;
 import java.util.Map;
-import java.util.SortedSet;
 
 public class AccountApiImpl implements AccountApi {
   interface Factory {
@@ -115,9 +111,6 @@
   private final DeleteWatchedProjects deleteWatchedProjects;
   private final StarredChanges.Create starredChangesCreate;
   private final StarredChanges.Delete starredChangesDelete;
-  private final Stars stars;
-  private final Stars.Get starsGet;
-  private final Stars.Post starsPost;
   private final GetEmails getEmails;
   private final CreateEmail createEmail;
   private final DeleteEmail deleteEmail;
@@ -159,9 +152,6 @@
       DeleteWatchedProjects deleteWatchedProjects,
       StarredChanges.Create starredChangesCreate,
       StarredChanges.Delete starredChangesDelete,
-      Stars stars,
-      Stars.Get starsGet,
-      Stars.Post starsPost,
       GetEmails getEmails,
       CreateEmail createEmail,
       DeleteEmail deleteEmail,
@@ -202,9 +192,6 @@
     this.deleteWatchedProjects = deleteWatchedProjects;
     this.starredChangesCreate = starredChangesCreate;
     this.starredChangesDelete = starredChangesDelete;
-    this.stars = stars;
-    this.starsGet = starsGet;
-    this.starsPost = starsPost;
     this.getEmails = getEmails;
     this.createEmail = createEmail;
     this.deleteEmail = deleteEmail;
@@ -384,35 +371,6 @@
   }
 
   @Override
-  public void setStars(String changeId, StarsInput input) throws RestApiException {
-    try {
-      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
-      starsPost.apply(rsrc, input);
-    } catch (Exception e) {
-      throw asRestApiException("Cannot post stars", e);
-    }
-  }
-
-  @Override
-  public SortedSet<String> getStars(String changeId) throws RestApiException {
-    try {
-      AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
-      return starsGet.apply(rsrc).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get stars", e);
-    }
-  }
-
-  @Override
-  public List<ChangeInfo> getStarredChanges() throws RestApiException {
-    try {
-      return stars.list().apply(account).value();
-    } catch (Exception e) {
-      throw asRestApiException("Cannot get starred changes", e);
-    }
-  }
-
-  @Override
   public List<GroupInfo> getGroups() throws RestApiException {
     try {
       return getGroups.apply(account).value();
diff --git a/java/com/google/gerrit/server/api/accounts/Module.java b/java/com/google/gerrit/server/api/accounts/AccountsModule.java
similarity index 94%
rename from java/com/google/gerrit/server/api/accounts/Module.java
rename to java/com/google/gerrit/server/api/accounts/AccountsModule.java
index 15c6ddb..70513b6 100644
--- a/java/com/google/gerrit/server/api/accounts/Module.java
+++ b/java/com/google/gerrit/server/api/accounts/AccountsModule.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.api.accounts.Accounts;
 import com.google.gerrit.extensions.config.FactoryModule;
 
-public class Module extends FactoryModule {
+public class AccountsModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(Accounts.class).to(AccountsImpl.class);
diff --git a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index 0b340b8..a49061d 100644
--- a/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -21,8 +21,6 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetApi;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -39,6 +37,8 @@
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -56,6 +56,8 @@
 import com.google.gerrit.extensions.common.PureRevertInfo;
 import com.google.gerrit.extensions.common.RevertSubmissionInfo;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -74,6 +76,7 @@
 import com.google.gerrit.server.restapi.change.ChangeIncludedIn;
 import com.google.gerrit.server.restapi.change.ChangeMessages;
 import com.google.gerrit.server.restapi.change.Check;
+import com.google.gerrit.server.restapi.change.CheckSubmitRequirement;
 import com.google.gerrit.server.restapi.change.CreateMergePatchSet;
 import com.google.gerrit.server.restapi.change.DeleteAssignee;
 import com.google.gerrit.server.restapi.change.DeleteChange;
@@ -91,8 +94,6 @@
 import com.google.gerrit.server.restapi.change.ListChangeDrafts;
 import com.google.gerrit.server.restapi.change.ListChangeRobotComments;
 import com.google.gerrit.server.restapi.change.ListReviewers;
-import com.google.gerrit.server.restapi.change.MarkAsReviewed;
-import com.google.gerrit.server.restapi.change.MarkAsUnreviewed;
 import com.google.gerrit.server.restapi.change.Move;
 import com.google.gerrit.server.restapi.change.PostHashtags;
 import com.google.gerrit.server.restapi.change.PostPrivate;
@@ -166,14 +167,13 @@
   private final Provider<ListChangeDrafts> listDraftsProvider;
   private final ChangeEditApiImpl.Factory changeEditApi;
   private final Check check;
+  private final CheckSubmitRequirement checkSubmitRequirement;
   private final Index index;
   private final Move move;
   private final PostPrivate postPrivate;
   private final DeletePrivate deletePrivate;
   private final Ignore ignore;
   private final Unignore unignore;
-  private final MarkAsReviewed markAsReviewed;
-  private final MarkAsUnreviewed markAsUnreviewed;
   private final SetWorkInProgress setWip;
   private final SetReadyForReview setReady;
   private final PutMessage putMessage;
@@ -222,14 +222,13 @@
       Provider<ListChangeDrafts> listDraftsProvider,
       ChangeEditApiImpl.Factory changeEditApi,
       Check check,
+      CheckSubmitRequirement checkSubmitRequirement,
       Index index,
       Move move,
       PostPrivate postPrivate,
       DeletePrivate deletePrivate,
       Ignore ignore,
       Unignore unignore,
-      MarkAsReviewed markAsReviewed,
-      MarkAsUnreviewed markAsUnreviewed,
       SetWorkInProgress setWip,
       SetReadyForReview setReady,
       PutMessage putMessage,
@@ -276,14 +275,13 @@
     this.listDraftsProvider = listDraftsProvider;
     this.changeEditApi = changeEditApi;
     this.check = check;
+    this.checkSubmitRequirement = checkSubmitRequirement;
     this.index = index;
     this.move = move;
     this.postPrivate = postPrivate;
     this.deletePrivate = deletePrivate;
     this.ignore = ignore;
     this.unignore = unignore;
-    this.markAsReviewed = markAsReviewed;
-    this.markAsUnreviewed = markAsUnreviewed;
     this.setWip = setWip;
     this.setReady = setReady;
     this.putMessage = putMessage;
@@ -467,7 +465,7 @@
   }
 
   @Override
-  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
+  public ReviewerResult addReviewer(ReviewerInput in) throws RestApiException {
     try {
       return postReviewers.apply(change, in).value();
     } catch (Exception e) {
@@ -716,6 +714,16 @@
   }
 
   @Override
+  public SubmitRequirementResultInfo checkSubmitRequirement(SubmitRequirementInput input)
+      throws RestApiException {
+    try {
+      return checkSubmitRequirement.apply(change, input).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check submit requirement", e);
+    }
+  }
+
+  @Override
   public void index() throws RestApiException {
     try {
       index.apply(change, new Input());
@@ -749,22 +757,6 @@
   }
 
   @Override
-  public void markAsReviewed(boolean reviewed) throws RestApiException {
-    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
-    // StarredChangesUtil.
-    try {
-      if (reviewed) {
-        markAsReviewed.apply(change, new Input());
-      } else {
-        markAsUnreviewed.apply(change, new Input());
-      }
-    } catch (StorageException | IllegalLabelException e) {
-      throw asRestApiException(
-          "Cannot mark change as " + (reviewed ? "reviewed" : "unreviewed"), e);
-    }
-  }
-
-  @Override
   public PureRevertInfo pureRevert() throws RestApiException {
     return pureRevert(null);
   }
diff --git a/java/com/google/gerrit/server/api/changes/Module.java b/java/com/google/gerrit/server/api/changes/ChangesModule.java
similarity index 96%
rename from java/com/google/gerrit/server/api/changes/Module.java
rename to java/com/google/gerrit/server/api/changes/ChangesModule.java
index f54d1fe..729396b 100644
--- a/java/com/google/gerrit/server/api/changes/Module.java
+++ b/java/com/google/gerrit/server/api/changes/ChangesModule.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.config.FactoryModule;
 
-public class Module extends FactoryModule {
+public class ChangesModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(Changes.class).to(ChangesImpl.class);
diff --git a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 573f2f5..764c46d 100644
--- a/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -57,9 +57,9 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.RevisionResource;
@@ -261,9 +261,9 @@
   }
 
   @Override
-  public void submit(SubmitInput in) throws RestApiException {
+  public ChangeInfo submit(SubmitInput in) throws RestApiException {
     try {
-      submit.apply(revision, in);
+      return submit.apply(revision, in).value();
     } catch (Exception e) {
       throw asRestApiException("Cannot submit change", e);
     }
@@ -642,7 +642,7 @@
         ListMultimapBuilder.treeKeys().arrayListValues().build();
     try {
       Iterable<PatchSetApproval> approvals =
-          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id(), null, null);
+          approvalsUtil.byPatchSet(revision.getNotes(), revision.getPatchSet().id());
       AccountLoader accountLoader =
           accountLoaderFactory.create(
               EnumSet.of(
diff --git a/java/com/google/gerrit/server/api/config/Module.java b/java/com/google/gerrit/server/api/config/ConfigModule.java
similarity index 94%
rename from java/com/google/gerrit/server/api/config/Module.java
rename to java/com/google/gerrit/server/api/config/ConfigModule.java
index b37aa1c..9340ae1 100644
--- a/java/com/google/gerrit/server/api/config/Module.java
+++ b/java/com/google/gerrit/server/api/config/ConfigModule.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.config.FactoryModule;
 
-public class Module extends FactoryModule {
+public class ConfigModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(Config.class).to(ConfigImpl.class);
diff --git a/java/com/google/gerrit/server/api/groups/Module.java b/java/com/google/gerrit/server/api/groups/GroupsModule.java
similarity index 94%
rename from java/com/google/gerrit/server/api/groups/Module.java
rename to java/com/google/gerrit/server/api/groups/GroupsModule.java
index 7d7af4e..58925e6 100644
--- a/java/com/google/gerrit/server/api/groups/Module.java
+++ b/java/com/google/gerrit/server/api/groups/GroupsModule.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.api.groups.Groups;
 import com.google.gerrit.extensions.config.FactoryModule;
 
-public class Module extends FactoryModule {
+public class GroupsModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(Groups.class).to(GroupsImpl.class);
diff --git a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index 6d7fc15..9521759 100644
--- a/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -63,6 +63,7 @@
 import com.google.gerrit.server.restapi.project.CheckAccess;
 import com.google.gerrit.server.restapi.project.ChildProjectsCollection;
 import com.google.gerrit.server.restapi.project.CommitsCollection;
+import com.google.gerrit.server.restapi.project.CommitsIncludedInRefs;
 import com.google.gerrit.server.restapi.project.CreateAccessChange;
 import com.google.gerrit.server.restapi.project.CreateProject;
 import com.google.gerrit.server.restapi.project.DeleteBranches;
@@ -88,8 +89,11 @@
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
+import java.util.Set;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
@@ -116,6 +120,7 @@
   private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
+  private final CommitsIncludedInRefs commitsIncludedInRefs;
   private final Provider<ListBranches> listBranches;
   private final Provider<ListTags> listTags;
   private final DeleteBranches deleteBranches;
@@ -154,6 +159,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -191,6 +197,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        commitsIncludedInRefs,
         listBranches,
         listTags,
         deleteBranches,
@@ -232,6 +239,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -269,6 +277,7 @@
         createAccessChange,
         getConfig,
         putConfig,
+        commitsIncludedInRefs,
         listBranches,
         listTags,
         deleteBranches,
@@ -309,6 +318,7 @@
       CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
+      CommitsIncludedInRefs commitsIncludedInRefs,
       Provider<ListBranches> listBranches,
       Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
@@ -346,6 +356,7 @@
     this.setAccess = setAccess;
     this.getConfig = getConfig;
     this.putConfig = putConfig;
+    this.commitsIncludedInRefs = commitsIncludedInRefs;
     this.listBranches = listBranches;
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
@@ -483,6 +494,18 @@
   }
 
   @Override
+  public Map<String, Set<String>> commitsIn(Collection<String> commits, Collection<String> refs)
+      throws RestApiException {
+    try {
+      commitsIncludedInRefs.addCommits(commits);
+      commitsIncludedInRefs.addRefs(refs);
+      return commitsIncludedInRefs.apply(project).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list commits included in refs", e);
+    }
+  }
+
+  @Override
   public ListRefsRequest<BranchInfo> branches() {
     return new ListRefsRequest<BranchInfo>() {
       @Override
diff --git a/java/com/google/gerrit/server/api/projects/Module.java b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
similarity index 95%
rename from java/com/google/gerrit/server/api/projects/Module.java
rename to java/com/google/gerrit/server/api/projects/ProjectsModule.java
index 8df5495..987c71f 100644
--- a/java/com/google/gerrit/server/api/projects/Module.java
+++ b/java/com/google/gerrit/server/api/projects/ProjectsModule.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.extensions.api.projects.Projects;
 import com.google.gerrit.extensions.config.FactoryModule;
 
-public class Module extends FactoryModule {
+public class ProjectsModule extends FactoryModule {
   @Override
   protected void configure() {
     bind(Projects.class).to(ProjectsImpl.class);
diff --git a/java/com/google/gerrit/server/approval/ApprovalCache.java b/java/com/google/gerrit/server/approval/ApprovalCache.java
new file mode 100644
index 0000000..5637249
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCache.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.approval;
+
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/**
+ * Cache that holds approvals per patch set and NoteDb state. This includes approvals copied forward
+ * from older patch sets.
+ */
+public interface ApprovalCache {
+  /** Returns {@link PatchSetApproval}s for the given patch set. */
+  Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId);
+}
diff --git a/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
new file mode 100644
index 0000000..fd31da9
--- /dev/null
+++ b/java/com/google/gerrit/server/approval/ApprovalCacheImpl.java
@@ -0,0 +1,133 @@
+// 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.approval;
+
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.proto.Entities;
+import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.cache.proto.Cache;
+import com.google.gerrit.server.cache.serialize.ObjectIdCacheSerializer;
+import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.protobuf.ByteString;
+import java.util.concurrent.ExecutionException;
+
+/** Implementation of the {@link ApprovalCache} interface */
+public class ApprovalCacheImpl implements ApprovalCache {
+  private static final String CACHE_NAME = "approvals";
+
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        bind(ApprovalCache.class).to(ApprovalCacheImpl.class);
+        persist(
+                CACHE_NAME,
+                Cache.PatchSetApprovalsKeyProto.class,
+                Cache.AllPatchSetApprovalsProto.class)
+            .version(2)
+            .loader(Loader.class)
+            .keySerializer(new ProtobufSerializer<>(Cache.PatchSetApprovalsKeyProto.parser()))
+            .valueSerializer(new ProtobufSerializer<>(Cache.AllPatchSetApprovalsProto.parser()));
+      }
+    };
+  }
+
+  private final LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto>
+      cache;
+
+  @Inject
+  ApprovalCacheImpl(
+      @Named(CACHE_NAME)
+          LoadingCache<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> cache) {
+    this.cache = cache;
+  }
+
+  @Override
+  public Iterable<PatchSetApproval> get(ChangeNotes notes, PatchSet.Id psId) {
+    try {
+      return fromProto(
+          cache.get(
+              Cache.PatchSetApprovalsKeyProto.newBuilder()
+                  .setChangeId(notes.getChangeId().get())
+                  .setPatchSetId(psId.get())
+                  .setProject(notes.getProjectName().get())
+                  .setId(
+                      ByteString.copyFrom(
+                          ObjectIdCacheSerializer.INSTANCE.serialize(notes.getMetaId())))
+                  .build()));
+    } catch (ExecutionException e) {
+      throw new StorageException(e);
+    }
+  }
+
+  @Singleton
+  static class Loader
+      extends CacheLoader<Cache.PatchSetApprovalsKeyProto, Cache.AllPatchSetApprovalsProto> {
+    private final ApprovalInference approvalInference;
+    private final ChangeNotes.Factory changeNotesFactory;
+
+    @Inject
+    Loader(ApprovalInference approvalInference, ChangeNotes.Factory changeNotesFactory) {
+      this.approvalInference = approvalInference;
+      this.changeNotesFactory = changeNotesFactory;
+    }
+
+    @Override
+    public Cache.AllPatchSetApprovalsProto load(Cache.PatchSetApprovalsKeyProto key)
+        throws Exception {
+      Change.Id changeId = Change.id(key.getChangeId());
+      return toProto(
+          approvalInference.forPatchSet(
+              changeNotesFactory.createChecked(
+                  Project.nameKey(key.getProject()),
+                  changeId,
+                  ObjectIdCacheSerializer.INSTANCE.deserialize(key.getId().toByteArray())),
+              PatchSet.id(changeId, key.getPatchSetId()),
+              null
+              /* revWalk= */ ,
+              null
+              /* repoConfig= */ ));
+    }
+  }
+
+  private static Iterable<PatchSetApproval> fromProto(Cache.AllPatchSetApprovalsProto proto) {
+    ImmutableList.Builder<PatchSetApproval> builder = ImmutableList.builder();
+    for (Entities.PatchSetApproval psa : proto.getApprovalList()) {
+      builder.add(PatchSetApprovalProtoConverter.INSTANCE.fromProto(psa));
+    }
+    return builder.build();
+  }
+
+  private static Cache.AllPatchSetApprovalsProto toProto(Iterable<PatchSetApproval> autoValue) {
+    Cache.AllPatchSetApprovalsProto.Builder builder = Cache.AllPatchSetApprovalsProto.newBuilder();
+    for (PatchSetApproval psa : autoValue) {
+      builder.addApproval(PatchSetApprovalProtoConverter.INSTANCE.toProto(psa));
+    }
+    return builder.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/ApprovalInference.java b/java/com/google/gerrit/server/approval/ApprovalInference.java
similarity index 68%
rename from java/com/google/gerrit/server/ApprovalInference.java
rename to java/com/google/gerrit/server/approval/ApprovalInference.java
index 04d874c..695997a 100644
--- a/java/com/google/gerrit/server/ApprovalInference.java
+++ b/java/com/google/gerrit/server/approval/ApprovalInference.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
@@ -26,35 +26,35 @@
 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.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.LabelNormalizer;
-import com.google.gerrit.server.git.GitRepositoryManager;
 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.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.gerrit.server.query.approval.ListOfFilesUnchangedPredicate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Map;
-import java.util.stream.Collectors;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -63,30 +63,37 @@
  * asserting a change's kind and checking the project config for allowed forward-inference.
  *
  * <p>The result of a copy may either be stored, as when stamping approvals in the database at
- * submit time, or refreshed on demand, as when reading approvals from the NoteDb.
+ * submit time, or refreshed on demand, as when reading approvals from the NoteDb. TODO(ghareeb):
+ * migrate to new diff cache
  */
 @Singleton
-public class ApprovalInference {
+class ApprovalInference {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
-  private final PatchListCache patchListCache;
-  private final GitRepositoryManager repositoryManager;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
+  private final OneOffRequestContext requestContext;
+  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
 
   @Inject
   ApprovalInference(
+      DiffOperations diffOperations,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
-      PatchListCache patchListCache,
-      GitRepositoryManager repositoryManager) {
+      ApprovalQueryBuilder approvalQueryBuilder,
+      OneOffRequestContext requestContext,
+      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
+    this.diffOperations = diffOperations;
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
-    this.patchListCache = patchListCache;
-    this.repositoryManager = repositoryManager;
+    this.approvalQueryBuilder = approvalQueryBuilder;
+    this.requestContext = requestContext;
+    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
   }
 
   /**
@@ -95,47 +102,46 @@
    */
   Iterable<PatchSetApproval> forPatchSet(
       ChangeNotes notes, PatchSet.Id psId, @Nullable RevWalk rw, @Nullable Config repoConfig) {
+    PatchSet patchset = notes.getPatchSets().get(psId);
+    if (patchset == null) {
+      return Collections.emptyList();
+    }
+    return forPatchSet(notes, patchset, rw, repoConfig);
+  }
+
+  Iterable<PatchSetApproval> forPatchSet(
+      ChangeNotes notes, PatchSet ps, @Nullable RevWalk rw, @Nullable Config repoConfig) {
     ProjectState project;
     try (TraceTimer traceTimer =
         TraceContext.newTimer(
             "Computing labels for patch set",
             Metadata.builder()
                 .changeId(notes.load().getChangeId().get())
-                .patchSetId(psId.get())
+                .patchSetId(ps.id().get())
                 .build())) {
       project =
           projectCache
               .get(notes.getProjectName())
               .orElseThrow(illegalState(notes.getProjectName()));
       Collection<PatchSetApproval> approvals =
-          getForPatchSetWithoutNormalization(notes, project, psId, rw, repoConfig);
+          getForPatchSetWithoutNormalization(notes, project, ps, rw, repoConfig);
       return labelNormalizer.normalize(notes, approvals).getNormalized();
     }
   }
 
-  private static boolean canCopy(
+  private boolean canCopyBasedOnBooleanLabelConfigs(
       ProjectState project,
       PatchSetApproval psa,
       PatchSet.Id psId,
       ChangeKind kind,
       LabelType type,
-      @Nullable PatchList patchListCurrentPatchset,
-      @Nullable PatchList patchListPriorPatchset) {
+      @Nullable Map<String, FileDiffOutput> baseVsCurrentDiff,
+      @Nullable Map<String, FileDiffOutput> baseVsPriorDiff,
+      @Nullable Map<String, FileDiffOutput> priorVsCurrentDiff) {
     int n = psa.key().patchSetId().get();
     checkArgument(n != psId.get());
 
-    if (type == null) {
-      logger.atFine().log(
-          "approval %d on label %s of patch set %d of change %d cannot be copied"
-              + " to patch set %d because the label no longer exists on project %s",
-          psa.value(),
-          psa.label(),
-          n,
-          psa.key().patchSetId().changeId().get(),
-          psId.get(),
-          project.getName());
-      return false;
-    } else if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
+    if (type.isCopyMinScore() && type.isMaxNegative(psa)) {
       logger.atFine().log(
           "veto approval %s on label %s of patch set %d of change %d can be copied"
               + " to patch set %d because the label has set copyMinScore = true on project %s",
@@ -181,7 +187,8 @@
           project.getName());
       return true;
     } else if (type.isCopyAllScoresIfListOfFilesDidNotChange()
-        && didListOfFilesNotChange(patchListCurrentPatchset, patchListPriorPatchset)) {
+        && listOfFilesUnchangedPredicate.match(
+            baseVsCurrentDiff, baseVsPriorDiff, priorVsCurrentDiff)) {
       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 "
@@ -313,24 +320,35 @@
     }
   }
 
-  private static boolean didListOfFilesNotChange(PatchList oldPatchList, PatchList newPatchList) {
-    Map<String, ChangeType> fileToChangeTypePs1 = getFileToChangeType(oldPatchList);
-    Map<String, ChangeType> fileToChangeTypePs2 = getFileToChangeType(newPatchList);
-    return fileToChangeTypePs1.equals(fileToChangeTypePs2);
-  }
-
-  private static Map<String, ChangeType> getFileToChangeType(PatchList ps) {
-    return ps.getPatches().stream()
-        .collect(
-            Collectors.toMap(
-                f -> f.getNewName() != null ? f.getNewName() : f.getOldName(),
-                f -> f.getChangeType()));
+  private boolean canCopyBasedOnCopyCondition(
+      ChangeNotes changeNotes,
+      PatchSetApproval psa,
+      PatchSet patchSet,
+      LabelType type,
+      ChangeKind changeKind) {
+    if (!type.getCopyCondition().isPresent()) {
+      return false;
+    }
+    ApprovalContext ctx = ApprovalContext.create(changeNotes, psa, patchSet, changeKind);
+    try {
+      // Use a request context to run checks as an internal user with expanded visibility. This is
+      // so that the output of the copy condition does not depend on who is running the current
+      // request (e.g. a group used in this query might not be visible to the person sending this
+      // request).
+      try (ManualRequestContext ignored = requestContext.open()) {
+        return approvalQueryBuilder.parse(type.getCopyCondition().get()).asMatchable().match(ctx);
+      }
+    } catch (QueryParseException e) {
+      logger.atWarning().withCause(e).log(
+          "Unable to copy label because config is invalid. This should have been caught before.");
+      return false;
+    }
   }
 
   private Collection<PatchSetApproval> getForPatchSetWithoutNormalization(
       ChangeNotes notes,
       ProjectState project,
-      PatchSet.Id psId,
+      PatchSet patchSet,
       @Nullable RevWalk rw,
       @Nullable Config repoConfig) {
     checkState(
@@ -339,15 +357,11 @@
         project.getNameKey(),
         notes.getProjectName());
 
-    PatchSet ps = notes.load().getPatchSets().get(psId);
-    if (ps == null) {
-      return Collections.emptyList();
-    }
-
+    PatchSet.Id psId = patchSet.id();
     // Add approvals on the given patch set to the result
     Table<String, Account.Id, PatchSetApproval> resultByUser = HashBasedTable.create();
     ImmutableList<PatchSetApproval> approvalsForGivenPatchSet =
-        notes.load().getApprovals().get(ps.id());
+        notes.load().getApprovals().get(patchSet.id());
     approvalsForGivenPatchSet.forEach(psa -> resultByUser.put(psa.label(), psa.accountId(), psa));
 
     // Bail out immediately if this is the first patch set. Return only approvals granted on the
@@ -370,63 +384,87 @@
 
     Iterable<PatchSetApproval> priorApprovals =
         getForPatchSetWithoutNormalization(
-            notes, project, priorPatchSet.getValue().id(), rw, repoConfig);
+            notes, project, priorPatchSet.getValue(), rw, repoConfig);
     if (!priorApprovals.iterator().hasNext()) {
       return resultByUser.values();
     }
 
     // Add labels from the previous patch set to the result in case the label isn't already there
     // and settings as well as change kind allow copying.
-    ChangeKind kind =
+    ChangeKind changeKind =
         changeKindCache.getChangeKind(
             project.getNameKey(),
             rw,
             repoConfig,
             priorPatchSet.getValue().commitId(),
-            ps.commitId());
+            patchSet.commitId());
     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 patchListCurrentPatchset = null;
-    PatchList patchListPriorPatchset = null;
+        patchSet.id().get(),
+        patchSet.id().changeId().get(),
+        priorPatchSet.getValue().id().changeId(),
+        changeKind);
+
+    Map<String, FileDiffOutput> baseVsCurrent = null;
+    Map<String, FileDiffOutput> baseVsPrior = null;
+    Map<String, FileDiffOutput> priorVsCurrent = null;
     LabelTypes labelTypes = project.getLabelTypes();
     for (PatchSetApproval psa : priorApprovals) {
       if (resultByUser.contains(psa.label(), psa.accountId())) {
         continue;
       }
-      LabelType type = labelTypes.byLabel(psa.labelId());
-      // Only compute patchList if there is a relevant label, since this is expensive.
-      if (patchListCurrentPatchset == null
-          && type != null
-          && type.isCopyAllScoresIfListOfFilesDidNotChange()) {
-        patchListCurrentPatchset = getPatchList(project, ps);
-        patchListPriorPatchset = getPatchList(project, priorPatchSet.getValue());
+      Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
+      // Only compute modified files if there is a relevant label, since this is expensive.
+      if (baseVsCurrent == null
+          && type.isPresent()
+          && type.get().isCopyAllScoresIfListOfFilesDidNotChange()) {
+        baseVsCurrent = listModifiedFiles(project, patchSet);
+        baseVsPrior = listModifiedFiles(project, priorPatchSet.getValue());
+        priorVsCurrent =
+            listModifiedFiles(project, priorPatchSet.getValue().commitId(), patchSet.commitId());
       }
-      if (!canCopy(
-          project, psa, ps.id(), kind, type, patchListCurrentPatchset, patchListPriorPatchset)) {
+      if (!type.isPresent()) {
+        logger.atFine().log(
+            "approval %d on label %s of patch set %d of change %d cannot be copied"
+                + " to patch set %d because the label no longer exists on project %s",
+            psa.value(),
+            psa.label(),
+            psa.key().patchSetId().get(),
+            psa.key().patchSetId().changeId().get(),
+            psId.get(),
+            project.getName());
         continue;
       }
-      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(ps.id()));
+      if (!canCopyBasedOnBooleanLabelConfigs(
+              project,
+              psa,
+              patchSet.id(),
+              changeKind,
+              type.get(),
+              baseVsCurrent,
+              baseVsPrior,
+              priorVsCurrent)
+          && !canCopyBasedOnCopyCondition(notes, psa, patchSet, type.get(), changeKind)) {
+        continue;
+      }
+      resultByUser.put(psa.label(), psa.accountId(), psa.copyWithPatchSet(patchSet.id()));
     }
     return resultByUser.values();
   }
 
   /**
-   * Gets the {@link PatchList} between a patch-set and the base. Can be used to compute difference
-   * in files between two patch-sets by using both {@link PatchList}s of those 2 patch-sets.
+   * Gets the modified files 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) {
-    // Compare against the base:
-    // * For merge commits the comparison is done against the 1st parent, which is the destination
-    //   branch.
-    // * For non-merge commits the comparison is done against the only parent, or an empty base if
-    //   no parent exists.
-    PatchListKey key =
-        PatchListKey.againstBase(
-            ps.commitId(), getParentCount(project.getNameKey(), ps.commitId()));
+  private Map<String, FileDiffOutput> listModifiedFiles(ProjectState project, PatchSet ps) {
     try {
-      return patchListCache.get(key, project.getNameKey());
-    } catch (PatchListNotAvailableException ex) {
+      Integer parentNum =
+          listOfFilesUnchangedPredicate.isInitialCommit(project.getNameKey(), ps.commitId())
+              ? 0
+              : 1;
+      return diffOperations.listModifiedFilesAgainstParent(
+          project.getNameKey(), ps.commitId(), parentNum);
+    } catch (DiffNotAvailableException 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 "
@@ -435,12 +473,20 @@
     }
   }
 
-  private int getParentCount(Project.NameKey project, ObjectId objectId) {
-    try (Repository repo = repositoryManager.openRepository(project);
-        RevWalk revWalk = new RevWalk(repo)) {
-      return revWalk.parseCommit(objectId).getParentCount();
-    } catch (IOException ex) {
-      throw new StorageException(ex);
+  /**
+   * Gets the modified files between two commits corresponding to different patchsets of the same
+   * change.
+   */
+  private Map<String, FileDiffOutput> listModifiedFiles(
+      ProjectState project, ObjectId sourceCommit, ObjectId targetCommit) {
+    try {
+      return diffOperations.listModifiedFiles(project.getNameKey(), sourceCommit, targetCommit);
+    } catch (DiffNotAvailableException 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/ApprovalsUtil.java b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
similarity index 90%
rename from java/com/google/gerrit/server/ApprovalsUtil.java
rename to java/com/google/gerrit/server/approval/ApprovalsUtil.java
index 411768d..c2e35d2 100644
--- a/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/java/com/google/gerrit/server/approval/ApprovalsUtil.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server;
+package com.google.gerrit.server.approval;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
@@ -39,6 +39,9 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -58,6 +61,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -94,16 +98,19 @@
   private final ApprovalInference approvalInference;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
+  private final ApprovalCache approvalCache;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
       ApprovalInference approvalInference,
       PermissionBackend permissionBackend,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalCache approvalCache) {
     this.approvalInference = approvalInference;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
+    this.approvalCache = approvalCache;
   }
 
   /**
@@ -271,7 +278,6 @@
    * @param ps patch set being approved.
    * @param user user adding approvals.
    * @param approvals approvals to add.
-   * @throws RestApiException
    */
   public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(
       ChangeUpdate update,
@@ -293,8 +299,12 @@
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
-      LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.id(), user, lt.getLabelId(), vote.getValue(), ts).build());
+      Optional<LabelType> lt = labelTypes.byLabel(vote.getKey());
+      if (!lt.isPresent()) {
+        throw new BadRequestException(
+            String.format("label \"%s\" is not a configured label", vote.getKey()));
+      }
+      cells.add(newApproval(ps.id(), user, lt.get().getLabelId(), vote.getValue(), ts).build());
     }
     for (PatchSetApproval psa : cells) {
       update.putApproval(psa.label(), psa.value());
@@ -304,11 +314,11 @@
 
   public static void checkLabel(LabelTypes labelTypes, String name, Short value)
       throws BadRequestException {
-    LabelType label = labelTypes.byLabel(name);
-    if (label == null) {
+    Optional<LabelType> label = labelTypes.byLabel(name);
+    if (!label.isPresent()) {
       throw new BadRequestException(String.format("label \"%s\" is not a configured label", name));
     }
-    if (label.getValue(value) == null) {
+    if (label.get().getValue(value) == null) {
       throw new BadRequestException(
           String.format("label \"%s\": %d is not a valid value", name, value));
     }
@@ -338,6 +348,14 @@
     return approvalInference.forPatchSet(notes, psId, rw, repoConfig);
   }
 
+  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet patchSet) {
+    return approvalInference.forPatchSet(notes, patchSet, /* rw= */ null, /* repoConfig= */ null);
+  }
+
+  public Iterable<PatchSetApproval> byPatchSet(ChangeNotes notes, PatchSet.Id psId) {
+    return approvalCache.get(notes, psId);
+  }
+
   public Iterable<PatchSetApproval> byPatchSetUser(
       ChangeNotes notes,
       PatchSet.Id psId,
@@ -347,6 +365,11 @@
     return filterApprovals(byPatchSet(notes, psId, rw, repoConfig), accountId);
   }
 
+  public Iterable<PatchSetApproval> byPatchSetUser(
+      ChangeNotes notes, PatchSet.Id psId, Account.Id accountId) {
+    return filterApprovals(byPatchSet(notes, psId), accountId);
+  }
+
   public PatchSetApproval getSubmitter(ChangeNotes notes, PatchSet.Id c) {
     if (c == null) {
       return null;
diff --git a/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 73a970b..5df4d28 100644
--- a/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -44,12 +44,14 @@
   private final AccountResolver accountResolver;
   private final AccountManager accountManager;
   private final AuthType authType;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   public AccountIdHandler(
       AccountResolver accountResolver,
       AccountManager accountManager,
       AuthConfig authConfig,
+      AuthRequest.Factory authRequestFactory,
       @Assisted CmdLineParser parser,
       @Assisted OptionDef option,
       @Assisted Setter<Account.Id> setter) {
@@ -57,6 +59,7 @@
     this.accountResolver = accountResolver;
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -105,7 +108,7 @@
     }
 
     try {
-      AuthRequest req = AuthRequest.forUser(user);
+      AuthRequest req = authRequestFactory.createForUser(user);
       req.setSkipAuthentication(true);
       return accountManager.authenticate(req).getAccountId();
     } catch (AccountException e) {
diff --git a/java/com/google/gerrit/server/auth/AuthBackend.java b/java/com/google/gerrit/server/auth/AuthBackend.java
index 9ec3366..424ee43 100644
--- a/java/com/google/gerrit/server/auth/AuthBackend.java
+++ b/java/com/google/gerrit/server/auth/AuthBackend.java
@@ -20,7 +20,7 @@
 @ExtensionPoint
 public interface AuthBackend {
 
-  /** @return an identifier that uniquely describes the backend. */
+  /** Returns an identifier that uniquely describes the backend. */
   String getDomain();
 
   /**
diff --git a/java/com/google/gerrit/server/auth/AuthUser.java b/java/com/google/gerrit/server/auth/AuthUser.java
index 987f086..9e1c5ec 100644
--- a/java/com/google/gerrit/server/auth/AuthUser.java
+++ b/java/com/google/gerrit/server/auth/AuthUser.java
@@ -52,18 +52,18 @@
     this.username = username;
   }
 
-  /** @return the globally unique identifier. */
+  /** Returns the globally unique identifier. */
   public final UUID getUUID() {
     return uuid;
   }
 
-  /** @return the backend specific user name, or null if one does not exist. */
+  /** Returns the backend specific user name, or null if one does not exist. */
   @Nullable
   public final String getUsername() {
     return username;
   }
 
-  /** @return {@code true} if {@link #getUsername()} is not null. */
+  /** Returns {@code true} if {@link #getUsername()} is not null. */
   public final boolean hasUsername() {
     return getUsername() != null;
   }
diff --git a/java/com/google/gerrit/server/auth/InternalAuthBackend.java b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
index 2f8886b..ce536f6 100644
--- a/java/com/google/gerrit/server/auth/InternalAuthBackend.java
+++ b/java/com/google/gerrit/server/auth/InternalAuthBackend.java
@@ -26,11 +26,14 @@
 public class InternalAuthBackend implements AuthBackend {
   private final AccountCache accountCache;
   private final AuthConfig authConfig;
+  private final PasswordVerifier passwordVerifier;
 
   @Inject
-  InternalAuthBackend(AccountCache accountCache, AuthConfig authConfig) {
+  InternalAuthBackend(
+      AccountCache accountCache, AuthConfig authConfig, PasswordVerifier passwordVerifier) {
     this.accountCache = accountCache;
     this.authConfig = authConfig;
+    this.passwordVerifier = passwordVerifier;
   }
 
   @Override
@@ -63,7 +66,7 @@
               + ": account inactive or not provisioned in Gerrit");
     }
 
-    if (!PasswordVerifier.checkPassword(who.externalIds(), username, req.getPassword().get())) {
+    if (!passwordVerifier.checkPassword(who.externalIds(), username, req.getPassword().get())) {
       throw new InvalidCredentialsException();
     }
     return new AuthUser(AuthUser.UUID.create(username), username);
diff --git a/java/com/google/gerrit/server/cache/CacheMetrics.java b/java/com/google/gerrit/server/cache/CacheMetrics.java
index 12194e7..f1fd4a8 100644
--- a/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -35,7 +35,9 @@
 @Singleton
 public class CacheMetrics {
   private static final Field<String> F_NAME =
-      Field.ofString("cache_name", Metadata.Builder::cacheName).build();
+      Field.ofString("cache_name", Metadata.Builder::cacheName)
+          .description("The name of the cache.")
+          .build();
 
   @Inject
   public CacheMetrics(
diff --git a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
index ee672cd..28d57e6 100644
--- a/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
+++ b/java/com/google/gerrit/server/cache/ForwardingRemovalListener.java
@@ -25,9 +25,6 @@
 /**
  * This listener dispatches removal events to all other RemovalListeners attached via the DynamicSet
  * API.
- *
- * @param <K>
- * @param <V>
  */
 @SuppressWarnings("rawtypes")
 public class ForwardingRemovalListener<K, V> implements RemovalListener<K, V> {
diff --git a/java/com/google/gerrit/server/cache/PerThreadCache.java b/java/com/google/gerrit/server/cache/PerThreadCache.java
index 609fce7..4270d1e 100644
--- a/java/com/google/gerrit/server/cache/PerThreadCache.java
+++ b/java/com/google/gerrit/server/cache/PerThreadCache.java
@@ -76,7 +76,7 @@
 
     /**
      * Returns a key based on the value's class and an identifier that uniquely identify the value.
-     * The identifier needs to implement {@code equals()} and {@hashCode()}.
+     * The identifier needs to implement {@code equals()} and {@code hashCode()}.
      */
     public static <T> Key<T> create(Class<T> clazz, Object identifier) {
       return new Key<>(clazz, ImmutableList.of(identifier));
@@ -84,7 +84,7 @@
 
     /**
      * Returns a key based on the value's class and a set of identifiers that uniquely identify the
-     * value. Identifiers need to implement {@code equals()} and {@hashCode()}.
+     * value. Identifiers need to implement {@code equals()} and {@code hashCode()}.
      */
     public static <T> Key<T> create(Class<T> clazz, Object... identifiers) {
       return new Key<>(clazz, ImmutableList.copyOf(identifiers));
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
index 7a53600..13b8b12 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheImpl.java
@@ -20,6 +20,7 @@
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.CacheStats;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.BloomFilter;
@@ -44,7 +45,10 @@
 import java.sql.Timestamp;
 import java.time.Duration;
 import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Calendar;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.ArrayBlockingQueue;
 import java.util.concurrent.BlockingQueue;
@@ -137,6 +141,23 @@
   }
 
   @Override
+  public ImmutableMap<K, V> getAll(Iterable<? extends K> keys) throws ExecutionException {
+    if (mem instanceof LoadingCache) {
+      ImmutableMap.Builder<K, V> result = ImmutableMap.builder();
+      LoadingCache<K, ValueHolder<V>> asLoadingCache = (LoadingCache<K, ValueHolder<V>>) mem;
+      ImmutableMap<K, ValueHolder<V>> values = asLoadingCache.getAll(keys);
+      for (Map.Entry<K, ValueHolder<V>> entry : values.entrySet()) {
+        result.put(entry.getKey(), entry.getValue().value);
+        if (store.needsRefresh(entry.getValue().created)) {
+          asLoadingCache.refresh(entry.getKey());
+        }
+      }
+      return result.build();
+    }
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
   public V get(K key, Callable<? extends V> valueLoader) throws ExecutionException {
     return mem.get(
             key,
@@ -265,6 +286,40 @@
     }
 
     @Override
+    public Map<K, ValueHolder<V>> loadAll(Iterable<? extends K> keys) throws Exception {
+      try (TraceTimer timer = TraceContext.newTimer("Loading multiple values from cache")) {
+        List<K> notInMemory = new ArrayList<>();
+        Map<K, ValueHolder<V>> result = new HashMap<>();
+        for (K key : keys) {
+          if (!store.mightContain(key)) {
+            notInMemory.add(key);
+            continue;
+          }
+          ValueHolder<V> h = store.getIfPresent(key);
+          if (h != null) {
+            result.put(key, h);
+          } else {
+            notInMemory.add(key);
+          }
+        }
+        try {
+          Map<K, V> remaining = loader.loadAll(notInMemory);
+          Instant instant = Instant.ofEpochMilli(TimeUtil.nowMs());
+          storeInDatabase(remaining, instant);
+          remaining
+              .entrySet()
+              .forEach(e -> result.put(e.getKey(), new ValueHolder<>(e.getValue(), instant)));
+        } catch (UnsupportedLoadingOperationException e) {
+          // Fallback to the default load() if loadAll() is not implemented
+          for (K k : notInMemory) {
+            result.put(k, load(k)); // No need to storeInDatabase here; load(k) does that.
+          }
+        }
+        return result;
+      }
+    }
+
+    @Override
     public ListenableFuture<ValueHolder<V>> reload(K key, ValueHolder<V> oldValue)
         throws Exception {
       ListenableFuture<V> reloadedValue = loader.reload(key, oldValue.value);
@@ -285,6 +340,15 @@
 
       return Futures.transform(reloadedValue, v -> new ValueHolder<>(v, TimeUtil.now()), executor);
     }
+
+    private void storeInDatabase(Map<K, V> entries, Instant instant) {
+      executor.execute(
+          () -> {
+            for (Map.Entry<K, V> entry : entries.entrySet()) {
+              store.put(entry.getKey(), new ValueHolder<>(entry.getValue(), instant));
+            }
+          });
+    }
   }
 
   static class SqlStore<K, V> {
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
index 40ef794..28a5f98 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/CachedProjectConfigSerializer.java
@@ -82,6 +82,9 @@
     proto.getLabelSectionsList().stream()
         .map(LabelTypeSerializer::deserialize)
         .forEach(builder::addLabelSection);
+    proto.getSubmitRequirementSectionsList().stream()
+        .map(SubmitRequirementSerializer::deserialize)
+        .forEach(builder::addSubmitRequirementSection);
     proto.getSubscribeSectionsList().stream()
         .map(SubscribeSectionSerializer::deserialize)
         .forEach(builder::addSubscribeSection);
@@ -152,6 +155,9 @@
     autoValue.getLabelSections().values().stream()
         .map(LabelTypeSerializer::serialize)
         .forEach(builder::addLabelSections);
+    autoValue.getSubmitRequirementSections().values().stream()
+        .map(SubmitRequirementSerializer::serialize)
+        .forEach(builder::addSubmitRequirementSections);
     autoValue.getSubscribeSections().values().stream()
         .map(SubscribeSectionSerializer::serialize)
         .forEach(builder::addSubscribeSections);
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 4627cdb..c00961f 100644
--- a/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
+++ b/java/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializer.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Converter;
 import com.google.common.base.Enums;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Shorts;
 import com.google.gerrit.entities.LabelFunction;
@@ -39,6 +40,7 @@
         .setAllowPostSubmit(proto.getAllowPostSubmit())
         .setIgnoreSelfApproval(proto.getIgnoreSelfApproval())
         .setDefaultValue(Shorts.saturatedCast(proto.getDefaultValue()))
+        .setCopyCondition(Strings.emptyToNull(proto.getCopyCondition()))
         .setCopyAnyScore(proto.getCopyAnyScore())
         .setCopyMinScore(proto.getCopyMinScore())
         .setCopyMaxScore(proto.getCopyMaxScore())
@@ -67,6 +69,7 @@
                 .map(LabelValueSerializer::serialize)
                 .collect(toImmutableList()))
         .setFunction(FUNCTION_CONVERTER.reverse().convert(autoValue.getFunction()))
+        .setCopyCondition(autoValue.getCopyCondition().orElse(""))
         .setCopyAnyScore(autoValue.isCopyAnyScore())
         .setCopyMinScore(autoValue.isCopyMinScore())
         .setCopyMaxScore(autoValue.isCopyMaxScore())
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
new file mode 100644
index 0000000..4e997b4
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementExpressionResultSerializer.java
@@ -0,0 +1,45 @@
+// 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.serialize.entities;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+
+/**
+ * Serializer of a {@link SubmitRequirementExpressionResult} to {@link
+ * SubmitRequirementExpressionResultProto}.
+ */
+public class SubmitRequirementExpressionResultSerializer {
+  public static SubmitRequirementExpressionResult deserialize(
+      SubmitRequirementExpressionResultProto proto) {
+    return SubmitRequirementExpressionResult.create(
+        SubmitRequirementExpression.create(proto.getExpression()),
+        SubmitRequirementExpressionResult.Status.valueOf(proto.getStatus()),
+        proto.getPassingAtomsList().stream().collect(ImmutableList.toImmutableList()),
+        proto.getFailingAtomsList().stream().collect(ImmutableList.toImmutableList()));
+  }
+
+  public static SubmitRequirementExpressionResultProto serialize(
+      SubmitRequirementExpressionResult r) {
+    return SubmitRequirementExpressionResultProto.newBuilder()
+        .setExpression(r.expression().expressionString())
+        .setStatus(r.status().name())
+        .addAllPassingAtoms(r.passingAtoms())
+        .addAllFailingAtoms(r.failingAtoms())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
new file mode 100644
index 0000000..47a377f
--- /dev/null
+++ b/java/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializer.java
@@ -0,0 +1,52 @@
+// 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.serialize.entities;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.server.cache.proto.Cache;
+import java.util.Optional;
+
+/** Serializer for {@link com.google.gerrit.entities.SubmitRequirement}. */
+public class SubmitRequirementSerializer {
+  public static SubmitRequirement deserialize(Cache.SubmitRequirementProto proto) {
+    return SubmitRequirement.builder()
+        .setName(proto.getName())
+        .setDescription(Optional.ofNullable(Strings.emptyToNull(proto.getDescription())))
+        .setApplicabilityExpression(
+            SubmitRequirementExpression.of(proto.getApplicabilityExpression()))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(proto.getSubmittabilityExpression()))
+        .setOverrideExpression(SubmitRequirementExpression.of(proto.getOverrideExpression()))
+        .setAllowOverrideInChildProjects(proto.getAllowOverrideInChildProjects())
+        .build();
+  }
+
+  public static Cache.SubmitRequirementProto serialize(SubmitRequirement submitRequirement) {
+    SubmitRequirementExpression emptyExpression = SubmitRequirementExpression.create("");
+    return Cache.SubmitRequirementProto.newBuilder()
+        .setName(submitRequirement.name())
+        .setDescription(submitRequirement.description().orElse(""))
+        .setApplicabilityExpression(
+            submitRequirement.applicabilityExpression().orElse(emptyExpression).expressionString())
+        .setSubmittabilityExpression(
+            submitRequirement.submittabilityExpression().expressionString())
+        .setOverrideExpression(
+            submitRequirement.overrideExpression().orElse(emptyExpression).expressionString())
+        .setAllowOverrideInChildProjects(submitRequirement.allowOverrideInChildProjects())
+        .build();
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/BUILD b/java/com/google/gerrit/server/cancellation/BUILD
new file mode 100644
index 0000000..05530a5
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/BUILD
@@ -0,0 +1,14 @@
+load("@rules_java//java:defs.bzl", "java_library")
+
+java_library(
+    name = "cancellation",
+    srcs = glob(
+        ["*.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//lib:guava",
+        "//lib/commons:lang",
+    ],
+)
diff --git a/java/com/google/gerrit/server/cancellation/RequestCancelledException.java b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
new file mode 100644
index 0000000..d89701f
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestCancelledException.java
@@ -0,0 +1,80 @@
+// 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.cancellation;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+import org.apache.commons.lang.WordUtils;
+
+/** Exception to signal that the current request is cancelled and should be aborted. */
+public class RequestCancelledException extends RuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * Checks whether the given exception was caused by {@link RequestCancelledException}. If yes, the
+   * {@link RequestCancelledException} is returned. If not, {@link Optional#empty()} is returned.
+   */
+  public static Optional<RequestCancelledException> getFromCausalChain(Throwable e) {
+    return Throwables.getCausalChain(e).stream()
+        .filter(RequestCancelledException.class::isInstance)
+        .map(RequestCancelledException.class::cast)
+        .findFirst();
+  }
+
+  private final RequestStateProvider.Reason cancellationReason;
+  private final Optional<String> cancellationMessage;
+
+  /**
+   * Create a {@code RequestCancelledException}.
+   *
+   * @param cancellationReason the reason why the request is cancelled
+   * @param cancellationMessage an optional message providing details about the cancellation
+   */
+  public RequestCancelledException(
+      RequestStateProvider.Reason cancellationReason, @Nullable String cancellationMessage) {
+    super(createMessage(cancellationReason, cancellationMessage));
+    this.cancellationReason = cancellationReason;
+    this.cancellationMessage = Optional.ofNullable(cancellationMessage);
+  }
+
+  private static String createMessage(
+      RequestStateProvider.Reason cancellationReason, @Nullable String message) {
+    StringBuilder messageBuilder = new StringBuilder();
+    messageBuilder.append(String.format("Request cancelled: %s", cancellationReason.name()));
+    if (message != null) {
+      messageBuilder.append(String.format(" (%s)", message));
+    }
+    return messageBuilder.toString();
+  }
+
+  /** Returns the reason why the request is cancelled. */
+  public RequestStateProvider.Reason getCancellationReason() {
+    return cancellationReason;
+  }
+
+  /** Returns the cancellation reason as a user-readable string. */
+  public String formatCancellationReason() {
+    return WordUtils.capitalizeFully(cancellationReason.name().replaceAll("_", " "));
+  }
+
+  /**
+   * Returns a message providing details about the cancellation, or {@link Optional#empty()} if none
+   * is available.
+   */
+  public Optional<String> getCancellationMessage() {
+    return cancellationMessage;
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateContext.java b/java/com/google/gerrit/server/cancellation/RequestStateContext.java
new file mode 100644
index 0000000..390c76f
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateContext.java
@@ -0,0 +1,179 @@
+// 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.cancellation;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Context that allows to register {@link RequestStateProvider}s.
+ *
+ * <p>The registered {@link RequestStateProvider}s are stored in {@link ThreadLocal} so that they
+ * can be accessed during the request execution (via {@link #getRequestStateProviders()}.
+ *
+ * <p>On {@link #close()} the {@link RequestStateProvider}s that have been registered by this {@code
+ * RequestStateContext} instance are removed from {@link ThreadLocal}.
+ *
+ * <p>Nesting {@code RequestStateContext}s is possible.
+ *
+ * <p>Currently there is no logic to automatically copy the {@link RequestStateContext} to
+ * background threads, but implementing this may be considered in the future. This means that by
+ * default we only support cancellation of the main thread, but not of background threads. That's
+ * fine as all significant work is being done in the main thread.
+ *
+ * <p>{@link com.google.gerrit.server.util.RequestContext} is also a context that is available for
+ * the time of the request, but it is not suitable to manage registrations of {@link
+ * RequestStateProvider}s. Hence {@link RequestStateProvider} registrations are managed by a
+ * separate context, which is this class, {@link RequestStateContext}:
+ *
+ * <ul>
+ *   <li>{@link com.google.gerrit.server.util.RequestContext} is an interface that has many
+ *       implementations and hence cannot manage a {@link ThreadLocal} state.
+ *   <li>{@link com.google.gerrit.server.util.RequestContext} is not an {@link AutoCloseable} and
+ *       hence cannot cleanup any {@link ThreadLocal} state on close (turning it into an {@link
+ *       AutoCloseable} would require a large refactoring).
+ *   <li>Despite the name {@link com.google.gerrit.server.util.RequestContext} is not only used for
+ *       requests scopes but also for other scopes that are not a request (e.g. plugin invocations,
+ *       email sending, manual scopes).
+ *   <li>{@link com.google.gerrit.server.util.RequestContext} is not copied to background and should
+ *       not be, but for {@link RequestStateContext} we may consider doing this in the future.
+ * </ul>
+ */
+public class RequestStateContext implements AutoCloseable {
+  /** The {@link RequestStateProvider}s that have been registered for the thread. */
+  private static final ThreadLocal<Set<RequestStateProvider>> threadLocalRequestStateProviders =
+      new ThreadLocal<>();
+
+  /** Whether currently a non-cancellable operation is being performed. */
+  private static final ThreadLocal<Boolean> inNonCancellableOperation = new ThreadLocal<>();
+
+  /**
+   * Aborts the current request by throwing a {@link RequestCancelledException} if any of the
+   * registered {@link RequestStateProvider}s reports the request as cancelled.
+   *
+   * <p>If an atomic operation is currently being performed, request cancellations are ignored and
+   * the request doesn't get aborted.
+   *
+   * @throws RequestCancelledException thrown if the current request is cancelled and should be
+   *     aborted
+   * @see #startNonCancellableOperation()
+   */
+  public static void abortIfCancelled() throws RequestCancelledException {
+    if (inNonCancellableOperation.get() != null && inNonCancellableOperation.get()) {
+      // Do not cancel the request while an atomic operation is being performed.
+      return;
+    }
+
+    getRequestStateProviders()
+        .forEach(
+            requestStateProvider ->
+                requestStateProvider.checkIfCancelled(
+                    (reason, message) -> {
+                      throw new RequestCancelledException(reason, message);
+                    }));
+  }
+
+  /**
+   * Starts a non-cancellable operation.
+   *
+   * <p>If the request was cancelled while the non-cancellable operation was running, it gets
+   * aborted on close of the returned {@link AutoCloseable}.
+   *
+   * @return {@link AutoCloseable} that finishes the non-cancellable operation on close.
+   */
+  public static NonCancellableOperationContext startNonCancellableOperation() {
+    if (inNonCancellableOperation.get() != null && inNonCancellableOperation.get()) {
+      // atomic operation is already in progress
+      return () -> {};
+    }
+
+    inNonCancellableOperation.set(true);
+    return () -> {
+      inNonCancellableOperation.remove();
+      abortIfCancelled();
+    };
+  }
+
+  /** Returns the {@link RequestStateProvider}s that have been registered for the thread. */
+  @VisibleForTesting
+  static ImmutableSet<RequestStateProvider> getRequestStateProviders() {
+    if (threadLocalRequestStateProviders.get() == null) {
+      return ImmutableSet.of();
+    }
+    return ImmutableSet.copyOf(threadLocalRequestStateProviders.get());
+  }
+
+  /** Opens a {@code RequestStateContext}. */
+  public static RequestStateContext open() {
+    return new RequestStateContext();
+  }
+
+  /**
+   * The {@link RequestStateProvider}s that have been registered by this {@code
+   * RequestStateContext}.
+   */
+  private Set<RequestStateProvider> requestStateProviders = new HashSet<>();
+
+  private RequestStateContext() {}
+
+  /**
+   * Registers a {@link RequestStateProvider}.
+   *
+   * @param requestStateProvider the {@link RequestStateProvider} that should be registered
+   * @return the {@code RequestStateContext} instance for chaining calls
+   */
+  public RequestStateContext addRequestStateProvider(RequestStateProvider requestStateProvider) {
+    if (threadLocalRequestStateProviders.get() == null) {
+      threadLocalRequestStateProviders.set(new HashSet<>());
+    }
+    if (threadLocalRequestStateProviders.get().add(requestStateProvider)) {
+      requestStateProviders.add(requestStateProvider);
+    }
+    return this;
+  }
+
+  /**
+   * Closes this {@code RequestStateContext}.
+   *
+   * <p>Ensures that all {@link RequestStateProvider}s that have been registered by this {@code
+   * RequestStateContext} instance are removed from {@link #threadLocalRequestStateProviders}.
+   *
+   * <p>If no {@link RequestStateProvider}s remain in {@link #threadLocalRequestStateProviders},
+   * {@link #threadLocalRequestStateProviders} is unset.
+   */
+  @Override
+  public void close() {
+    if (threadLocalRequestStateProviders.get() != null) {
+      requestStateProviders.forEach(
+          requestStateProvider ->
+              threadLocalRequestStateProviders.get().remove(requestStateProvider));
+      if (threadLocalRequestStateProviders.get().isEmpty()) {
+        threadLocalRequestStateProviders.remove();
+      }
+    }
+  }
+
+  /**
+   * Context for running a non-cancellable operation.
+   *
+   * <p>While open, the current request cannot be cancelled.
+   */
+  public interface NonCancellableOperationContext extends AutoCloseable {
+    @Override
+    void close();
+  }
+}
diff --git a/java/com/google/gerrit/server/cancellation/RequestStateProvider.java b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
new file mode 100644
index 0000000..683ca1d
--- /dev/null
+++ b/java/com/google/gerrit/server/cancellation/RequestStateProvider.java
@@ -0,0 +1,60 @@
+// 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.cancellation;
+
+import com.google.gerrit.common.Nullable;
+
+/** Interface that provides information about the state of the current request. */
+public interface RequestStateProvider {
+  /**
+   * Checks whether the current request is cancelled.
+   *
+   * <p>Invoked by Gerrit to check whether the current request is cancelled and should be aborted.
+   *
+   * <p>If the current request is cancelled {@link OnCancelled#onCancel(Reason, String)} is invoked
+   * on the provided callback.
+   *
+   * @param onCancelled callback that should be invoked if the request is cancelled
+   */
+  void checkIfCancelled(OnCancelled onCancelled);
+
+  /** Callback interface to be invoked if a request is cancelled. */
+  @FunctionalInterface
+  interface OnCancelled {
+    /**
+     * Callback that is invoked if the request is cancelled.
+     *
+     * @param reason the reason for the cancellation of the request
+     * @param message an optional message providing details about the cancellation
+     */
+    void onCancel(Reason reason, @Nullable String message);
+  }
+
+  /** Reason why a request is cancelled. */
+  enum Reason {
+    /** The client got disconnected or has cancelled the request. */
+    CLIENT_CLOSED_REQUEST,
+
+    /** The deadline that the client provided for the request exceeded. */
+    CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+
+    /**
+     * A server-side deadline for the request exceeded.
+     *
+     * <p>Server-side deadlines are usually configurable, but may also be hard-coded.
+     */
+    SERVER_DEADLINE_EXCEEDED;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/AbandonOp.java b/java/com/google/gerrit/server/change/AbandonOp.java
index 6c39ed0..10e1f92 100644
--- a/java/com/google/gerrit/server/change/AbandonOp.java
+++ b/java/com/google/gerrit/server/change/AbandonOp.java
@@ -18,7 +18,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -32,7 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -50,7 +49,7 @@
 
   private Change change;
   private PatchSet patchSet;
-  private ChangeMessage message;
+  private String mailMessage;
 
   public interface Factory {
     AbandonOp create(
@@ -94,24 +93,22 @@
     change.setLastUpdatedOn(ctx.getWhen());
 
     update.setStatus(change.getStatus());
-    message = newMessage(ctx);
-    cmUtil.addChangeMessage(update, message);
+    mailMessage = cmUtil.setChangeMessage(ctx, commentMessage(), ChangeMessagesUtil.TAG_ABANDON);
     return true;
   }
 
-  private ChangeMessage newMessage(ChangeContext ctx) {
+  private String commentMessage() {
     StringBuilder msg = new StringBuilder();
     msg.append("Abandoned");
     if (!Strings.nullToEmpty(msgTxt).trim().isEmpty()) {
       msg.append("\n\n");
       msg.append(msgTxt.trim());
     }
-
-    return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_ABANDON);
+    return msg.toString();
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     try {
       ReplyToChangeSender emailSender =
@@ -119,7 +116,7 @@
       if (accountState != null) {
         emailSender.setFrom(accountState.account().id());
       }
-      emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+      emailSender.setChangeMessage(mailMessage, ctx.getWhen());
       emailSender.setNotify(notify);
       emailSender.setMessageId(
           messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), patchSet.id()));
@@ -127,6 +124,12 @@
     } catch (Exception e) {
       logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
     }
-    changeAbandoned.fire(change, patchSet, accountState, msgTxt, ctx.getWhen(), notify.handling());
+    changeAbandoned.fire(
+        ctx.getChangeData(change),
+        patchSet,
+        accountState,
+        msgTxt,
+        ctx.getWhen(),
+        notify.handling());
   }
 }
diff --git a/java/com/google/gerrit/server/change/AbandonUtil.java b/java/com/google/gerrit/server/change/AbandonUtil.java
index 1bc1fad..d030ec1 100644
--- a/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -93,7 +93,7 @@
         try {
           batchAbandon.batchAbandon(updateFactory, project, internalUser, changes, message);
           count += changes.size();
-        } catch (Throwable e) {
+        } catch (Exception e) {
           StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
           for (ChangeData change : changes) {
             msg.append(" ").append(change.getId().get());
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index ff8e5c6..cbbd01a 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -23,7 +23,6 @@
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toList;
 
-import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
@@ -31,20 +30,18 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -52,7 +49,7 @@
 import java.util.List;
 import java.util.Set;
 
-public class AddReviewersOp implements BatchUpdateOp {
+public class AddReviewersOp extends ReviewerOp {
   public interface Factory {
 
     /**
@@ -75,56 +72,25 @@
         boolean forGroup);
   }
 
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<PatchSetApproval> addedReviewers();
-
-    public abstract ImmutableList<Address> addedReviewersByEmail();
-
-    public abstract ImmutableList<Account.Id> addedCCs();
-
-    public abstract ImmutableList<Address> addedCCsByEmail();
-
-    static Builder builder() {
-      return new AutoValue_AddReviewersOp_Result.Builder();
-    }
-
-    @AutoValue.Builder
-    abstract static class Builder {
-      abstract Builder setAddedReviewers(Iterable<PatchSetApproval> addedReviewers);
-
-      abstract Builder setAddedReviewersByEmail(Iterable<Address> addedReviewersByEmail);
-
-      abstract Builder setAddedCCs(Iterable<Account.Id> addedCCs);
-
-      abstract Builder setAddedCCsByEmail(Iterable<Address> addedCCsByEmail);
-
-      abstract Result build();
-    }
-  }
-
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ReviewerAdded reviewerAdded;
   private final AccountCache accountCache;
   private final ProjectCache projectCache;
-  private final AddReviewersEmail addReviewersEmail;
+  private final ModifyReviewersEmail modifyReviewersEmail;
   private final Set<Account.Id> accountIds;
   private final Collection<Address> addresses;
   private final ReviewerState state;
   private final boolean forGroup;
 
-  // Unlike addedCCs, addedReviewers is a PatchSetApproval because the AddReviewerResult returned
+  // Unlike addedCCs, addedReviewers is a PatchSetApproval because the ReviewerResult returned
   // via the REST API is supposed to include vote information.
   private List<PatchSetApproval> addedReviewers = ImmutableList.of();
   private Collection<Address> addedReviewersByEmail = ImmutableList.of();
   private Collection<Account.Id> addedCCs = ImmutableList.of();
   private Collection<Address> addedCCsByEmail = ImmutableList.of();
 
-  private boolean sendEmail = true;
   private Change change;
-  private PatchSet patchSet;
-  private Result opResult;
 
   @Inject
   AddReviewersOp(
@@ -133,7 +99,7 @@
       ReviewerAdded reviewerAdded,
       AccountCache accountCache,
       ProjectCache projectCache,
-      AddReviewersEmail addReviewersEmail,
+      ModifyReviewersEmail modifyReviewersEmail,
       @Assisted Set<Account.Id> accountIds,
       @Assisted Collection<Address> addresses,
       @Assisted ReviewerState state,
@@ -144,7 +110,7 @@
     this.reviewerAdded = reviewerAdded;
     this.accountCache = accountCache;
     this.projectCache = projectCache;
-    this.addReviewersEmail = addReviewersEmail;
+    this.modifyReviewersEmail = modifyReviewersEmail;
 
     this.accountIds = accountIds;
     this.addresses = addresses;
@@ -152,17 +118,6 @@
     this.forGroup = forGroup;
   }
 
-  // TODO(dborowitz): This mutable setter is ugly, but a) it's less ugly than adding boolean args
-  // all the way through the constructor stack, and b) this class is slated to be completely
-  // rewritten.
-  public void suppressEmail() {
-    this.sendEmail = false;
-  }
-
-  void setPatchSet(PatchSet patchSet) {
-    this.patchSet = requireNonNull(patchSet);
-  }
-
   @Override
   public boolean updateChange(ChangeContext ctx) throws RestApiException, IOException {
     change = ctx.getChange();
@@ -238,7 +193,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     opResult =
         Result.builder()
             .setAddedReviewers(addedReviewers)
@@ -247,13 +202,15 @@
             .setAddedCCsByEmail(addedCCsByEmail)
             .build();
     if (sendEmail) {
-      addReviewersEmail.emailReviewersAsync(
+      modifyReviewersEmail.emailReviewersAsync(
           ctx.getUser().asIdentifiedUser(),
           change,
           Lists.transform(addedReviewers, PatchSetApproval::accountId),
           addedCCs,
+          ImmutableSet.of(),
           addedReviewersByEmail,
           addedCCsByEmail,
+          ImmutableSet.of(),
           ctx.getNotify(change.getId()));
     }
     if (!addedReviewers.isEmpty()) {
@@ -262,12 +219,13 @@
               .map(r -> accountCache.get(r.accountId()))
               .flatMap(Streams::stream)
               .collect(toList());
-      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+      eventSender =
+          () ->
+              reviewerAdded.fire(
+                  ctx.getChangeData(change), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+      if (sendEvent) {
+        sendEvent();
+      }
     }
   }
-
-  public Result getResult() {
-    checkState(opResult != null, "Batch update wasn't executed yet");
-    return opResult;
-  }
 }
diff --git a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
index 8053b30..a980c32 100644
--- a/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/AddToAttentionSetOp.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -101,7 +101,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!notify) {
       return;
     }
diff --git a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index 6cf7a8f..a080d15 100644
--- a/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -30,7 +30,7 @@
 public class ChangeCleanupRunner implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends LifecycleModule {
+  public static class ChangeCleanupRunnerModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(Lifecycle.class);
diff --git a/java/com/google/gerrit/server/change/ChangeFinder.java b/java/com/google/gerrit/server/change/ChangeFinder.java
index ba104d8..9f253de 100644
--- a/java/com/google/gerrit/server/change/ChangeFinder.java
+++ b/java/com/google/gerrit/server/change/ChangeFinder.java
@@ -96,6 +96,7 @@
                 .setRate()
                 .setUnit("requests"),
             Field.ofEnum(ChangeIdType.class, "change_id_type", Metadata.Builder::changeIdType)
+                .description("The type of the change identifier.")
                 .build());
   }
 
diff --git a/java/com/google/gerrit/server/change/ChangeInserter.java b/java/com/google/gerrit/server/change/ChangeInserter.java
index fb027bd..85482e4 100644
--- a/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -18,7 +18,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Change.INITIAL_PATCH_SET_ID;
-import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Objects.requireNonNull;
@@ -32,7 +32,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
@@ -46,13 +45,13 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -74,6 +73,7 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.InsertChangeOp;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.RequestScopePropagator;
@@ -112,7 +112,7 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
   private final AutoMerger autoMerger;
@@ -131,6 +131,7 @@
   private boolean isPrivate;
   private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
+  private ImmutableListMultimap<String, String> validationOptions = ImmutableListMultimap.of();
   private boolean validate = true;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
@@ -138,17 +139,17 @@
   private boolean sendMail;
   private boolean updateRef;
   private Change.Id revertOf;
-  private ImmutableList<InternalAddReviewerInput> reviewerInputs;
+  private ImmutableList<InternalReviewerInput> reviewerInputs;
 
   // Fields set during the insertion process.
   private ReceiveCommand cmd;
   private Change change;
-  private ChangeMessage changeMessage;
+  private String changeMessage;
   private PatchSetInfo patchSetInfo;
   private PatchSet patchSet;
   private String pushCert;
   private ProjectState projectState;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
 
   @Inject
   ChangeInserter(
@@ -163,7 +164,7 @@
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
       AutoMerger autoMerger,
@@ -181,7 +182,7 @@
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
     this.autoMerger = autoMerger;
@@ -280,8 +281,8 @@
         Streams.concat(
                 Streams.stream(reviewers)
                     .distinct()
-                    .map(id -> newAddReviewerInput(id, ReviewerState.REVIEWER)),
-                Streams.stream(ccs).distinct().map(id -> newAddReviewerInput(id, ReviewerState.CC)))
+                    .map(id -> newReviewerInput(id, ReviewerState.REVIEWER)),
+                Streams.stream(ccs).distinct().map(id -> newReviewerInput(id, ReviewerState.CC)))
             .collect(toImmutableList());
     return this;
   }
@@ -305,11 +306,21 @@
 
   public ChangeInserter setGroups(List<String> groups) {
     requireNonNull(groups, "groups may not be empty");
-    checkState(patchSet == null, "setGroups(Iterable<String>) only valid before creating change");
+    checkState(patchSet == null, "setGroups(List<String>) only valid before creating change");
     this.groups = groups;
     return this;
   }
 
+  public ChangeInserter setValidationOptions(
+      ImmutableListMultimap<String, String> validationOptions) {
+    checkState(
+        patchSet == null,
+        "setValidationOptions(ImmutableListMultimap<String, String>) only valid before creating a"
+            + " change");
+    this.validationOptions = validationOptions;
+    return this;
+  }
+
   public ChangeInserter setFireRevisionCreated(boolean fireRevisionCreated) {
     this.fireRevisionCreated = fireRevisionCreated;
     return this;
@@ -361,7 +372,7 @@
     return this;
   }
 
-  public ChangeMessage getChangeMessage() {
+  public String getChangeMessage() {
     if (message == null) {
       return null;
     }
@@ -443,8 +454,9 @@
     }
 
     reviewerAdditions =
-        reviewerAdder.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
-    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+        reviewerModifier.prepare(ctx.getNotes(), ctx.getUser(), getReviewerInputs(), true);
+    Optional<ReviewerModification> reviewerError =
+        reviewerAdditions.getFailures().stream().findFirst();
     if (reviewerError.isPresent()) {
       throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
@@ -463,19 +475,14 @@
     }
     if (message != null) {
       changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.id(),
-              ctx.getUser(),
-              patchSet.createdOn(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
-      cmUtil.addChangeMessage(update, changeMessage);
+          cmUtil.setChangeMessage(
+              update, message, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
     }
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (sendMail && notify.shouldNotify()) {
@@ -528,7 +535,8 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+      revisionCreated.fire(
+          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
         List<LabelType> labels = projectState.getLabelTypes(change.getDest()).getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
@@ -544,7 +552,13 @@
           }
         }
         commentAdded.fire(
-            change, patchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
+            ctx.getChangeData(change),
+            patchSet,
+            ctx.getAccount(),
+            null,
+            allApprovals,
+            oldApprovals,
+            ctx.getWhen());
       }
     }
   }
@@ -560,7 +574,7 @@
               cmd,
               projectState.getProject(),
               change.getDest().branch(),
-              ImmutableListMultimap.of(),
+              validationOptions,
               ctx.getRepoView().getConfig(),
               ctx.getRevWalk().getObjectReader(),
               commitId,
@@ -580,35 +594,34 @@
     }
   }
 
-  private static InternalAddReviewerInput newAddReviewerInput(
-      String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
-    InternalAddReviewerInput input =
-        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+    InternalReviewerInput input =
+        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. This is required for the push path, where it automatically sets reviewers from
     // certain commit footers: putting a nonexistent user in a footer should not cause an error. In
     // theory we could provide finer control to do this for some reviewers and not others, but it's
     // not worth complicating the ChangeInserter interface further at this time.
-    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
 
     return input;
   }
 
-  private ImmutableList<InternalAddReviewerInput> getReviewerInputs() {
+  private ImmutableList<InternalReviewerInput> getReviewerInputs() {
     return Streams.concat(
             reviewerInputs.stream(),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getAuthor().getAccount(),
                     NotifyHandling.NONE,
                     change.getOwner())),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     patchSetInfo.getCommitId(),
                     patchSetInfo.getCommitter().getAccount(),
diff --git a/java/com/google/gerrit/server/change/ChangeJson.java b/java/com/google/gerrit/server/change/ChangeJson.java
index 029f231..328c5de 100644
--- a/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/java/com/google/gerrit/server/change/ChangeJson.java
@@ -31,6 +31,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SKIP_DIFFSTAT;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.extensions.client.ListChangesOption.SUBMIT_REQUIREMENTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
@@ -59,6 +60,8 @@
 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;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -66,7 +69,6 @@
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
-import com.google.gerrit.extensions.common.AttentionSetInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
@@ -75,6 +77,8 @@
 import com.google.gerrit.extensions.common.ProblemInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.SubmitRecordInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.index.RefState;
@@ -92,8 +96,11 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountInfoComparator;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -105,12 +112,12 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -248,6 +255,7 @@
       TrackingFooters trackingFooters,
       Metrics metrics,
       RevisionJson.Factory revisionJsonFactory,
+      ExperimentFeatures experimentFeatures,
       @GerritServerConfig Config cfg,
       @Assisted Iterable<ListChangesOption> options,
       @Assisted Optional<PluginDefinedInfosFactory> pluginDefinedInfosFactory) {
@@ -266,7 +274,9 @@
     this.revisionJson = revisionJsonFactory.create(options);
     this.options = Sets.immutableEnumSet(options);
     this.includeMergeable = MergeabilityComputationBehavior.fromConfig(cfg).includeInApi();
-    this.lazyLoad = containsAnyOf(this.options, REQUIRE_LAZY_LOAD);
+    this.lazyLoad =
+        containsAnyOf(this.options, REQUIRE_LAZY_LOAD)
+            || lazyloadSubmitRequirements(this.options, experimentFeatures);
     this.pluginDefinedInfosFactory = pluginDefinedInfosFactory;
 
     logger.atFine().log("options = %s", options);
@@ -362,11 +372,56 @@
     return reqInfos;
   }
 
+  private Collection<SubmitRecordInfo> submitRecordsFor(ChangeData cd) {
+    List<SubmitRecordInfo> submitRecordInfos = new ArrayList<>();
+    for (SubmitRecord record : cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT)) {
+      submitRecordInfos.add(submitRecordToInfo(record));
+    }
+    return submitRecordInfos;
+  }
+
+  private Collection<SubmitRequirementResultInfo> submitRequirementsFor(ChangeData cd) {
+    Collection<SubmitRequirementResultInfo> reqInfos = new ArrayList<>();
+    Map<SubmitRequirement, SubmitRequirementResult> requirements = cd.submitRequirements();
+    for (Map.Entry<SubmitRequirement, SubmitRequirementResult> entry : requirements.entrySet()) {
+      reqInfos.add(SubmitRequirementsJson.toInfo(entry.getKey(), entry.getValue()));
+    }
+    return reqInfos;
+  }
+
   private static LegacySubmitRequirementInfo requirementToInfo(
       LegacySubmitRequirement req, Status status) {
     return new LegacySubmitRequirementInfo(status.name(), req.fallbackText(), req.type());
   }
 
+  private SubmitRecordInfo submitRecordToInfo(SubmitRecord record) {
+    SubmitRecordInfo info = new SubmitRecordInfo();
+    if (record.status != null) {
+      info.status = SubmitRecordInfo.Status.valueOf(record.status.name());
+    }
+    info.ruleName = record.ruleName;
+    info.errorMessage = record.errorMessage;
+    if (record.labels != null) {
+      info.labels = new ArrayList<>();
+      for (SubmitRecord.Label label : record.labels) {
+        SubmitRecordInfo.Label labelInfo = new SubmitRecordInfo.Label();
+        labelInfo.label = label.label;
+        if (label.status != null) {
+          labelInfo.status = SubmitRecordInfo.Label.Status.valueOf(label.status.name());
+        }
+        labelInfo.appliedBy = accountLoader.get(label.appliedBy);
+        info.labels.add(labelInfo);
+      }
+    }
+    if (record.requirements != null) {
+      info.requirements = new ArrayList<>();
+      for (LegacySubmitRequirement requirement : record.requirements) {
+        info.requirements.add(requirementToInfo(requirement, record.status));
+      }
+    }
+    return info;
+  }
+
   private static void finish(ChangeInfo info) {
     info.id =
         Joiner.on('~')
@@ -462,6 +517,11 @@
             cache.put(Change.id(info._number), info);
           }
         } catch (RuntimeException e) {
+          Optional<RequestCancelledException> requestCancelledException =
+              RequestCancelledException.getFromCausalChain(e);
+          if (requestCancelledException.isPresent()) {
+            throw e;
+          }
           logger.atWarning().withCause(e).log(
               "Omitting corrupt change %s from results", cd.getId());
         }
@@ -549,11 +609,7 @@
               .collect(
                   toImmutableMap(
                       a -> a.account().get(),
-                      a ->
-                          new AttentionSetInfo(
-                              accountLoader.get(a.account()),
-                              Timestamp.from(a.timestamp()),
-                              a.reason())));
+                      a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader)));
     }
     out.assignee = in.getAssignee() != null ? accountLoader.get(in.getAssignee()) : null;
     out.hashtags = cd.hashtags();
@@ -612,6 +668,10 @@
 
     out.labels = labelsJson.labelsFor(accountLoader, cd, has(LABELS), has(DETAILED_LABELS));
     out.requirements = requirementsFor(cd);
+    out.submitRecords = submitRecordsFor(cd);
+    if (has(SUBMIT_REQUIREMENTS)) {
+      out.submitRequirements = submitRequirementsFor(cd);
+    }
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
@@ -883,4 +943,20 @@
     }
     return ImmutableListMultimap.of();
   }
+
+  private static boolean lazyloadSubmitRequirements(
+      Set<ListChangesOption> changeOptions, ExperimentFeatures experimentFeatures) {
+    // TODO(ghareeb,hiesel): Remove this method.
+    // We are testing the new submit requirements with users in lieu of upgrading the change index
+    // to a version that supports the new requirements.
+    // Upgrading now, before the feature is finalized would be counter productive, because the index
+    // format might change while we iterate over the feature.
+    // Allowing changes to lazyload parameters will slow down dashboards for users who have this
+    // feature enabled, but will backfill submit requirements that weren't loaded from the index by
+    // simply computing them.
+    return changeOptions.contains(SUBMIT_REQUIREMENTS)
+        && experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD);
+  }
 }
diff --git a/java/com/google/gerrit/server/change/ChangeMessages.java b/java/com/google/gerrit/server/change/ChangeMessages.java
index 6f2e1ef..787f036 100644
--- a/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -31,6 +31,7 @@
   public String reviewerInvalid;
   public String reviewerNotFoundUserOrGroup;
 
+  public String groupRemovalIsNotAllowed;
   public String groupIsNotAllowed;
   public String groupHasTooManyMembers;
   public String groupManyMembersConfirmation;
diff --git a/java/com/google/gerrit/server/change/ChangeResource.java b/java/com/google/gerrit/server/change/ChangeResource.java
index 3729b59..970f1b5 100644
--- a/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/java/com/google/gerrit/server/change/ChangeResource.java
@@ -30,12 +30,12 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -140,7 +140,7 @@
     return changeData.getId();
   }
 
-  /** @return true if {@link #getUser()} is the change's owner. */
+  /** Returns true if {@link #getUser()} is the change's owner. */
   public boolean isUserOwner() {
     Account.Id owner = getChange().getOwner();
     return user.isIdentifiedUser() && user.asIdentifiedUser().getAccountId().equals(owner);
@@ -167,7 +167,6 @@
   public void prepareETag(Hasher h, CurrentUser user) {
     h.putInt(JSON_FORMAT_VERSION)
         .putLong(getChange().getLastUpdatedOn().getTime())
-        .putInt(getChange().getRowVersion())
         .putInt(user.isIdentifiedUser() ? user.getAccountId().get() : 0);
 
     if (user.isIdentifiedUser()) {
diff --git a/java/com/google/gerrit/server/change/DeleteChangeOp.java b/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 14298d5..c7ddf19 100644
--- a/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.RepoContext;
@@ -49,6 +50,7 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeDeleted changeDeleted;
   private final Change.Id id;
 
@@ -57,11 +59,13 @@
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
       PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
+      ChangeData.Factory changeDataFactory,
       ChangeDeleted changeDeleted,
       @Assisted Change.Id id) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
+    this.changeDataFactory = changeDataFactory;
     this.changeDeleted = changeDeleted;
     this.id = id;
   }
@@ -90,7 +94,7 @@
                     .map(p -> p.commitId().name())
                     .orElse("n/a")));
     ctx.deleteChange();
-    changeDeleted.fire(ctx.getChange(), ctx.getAccount(), ctx.getWhen());
+    changeDeleted.fire(changeDataFactory.create(ctx.getChange()), ctx.getAccount(), ctx.getWhen());
     return true;
   }
 
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
index 255e13a..b512a2d 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -17,19 +17,18 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
-import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Collections;
 
-public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+public class DeleteReviewerByEmailOp extends ReviewerOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
@@ -38,19 +37,21 @@
 
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final MessageIdGenerator messageIdGenerator;
+  private final ChangeMessagesUtil changeMessagesUtil;
 
   private final Address reviewer;
-
-  private ChangeMessage changeMessage;
+  private String mailMessage;
   private Change change;
 
   @Inject
   DeleteReviewerByEmailOp(
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       MessageIdGenerator messageIdGenerator,
+      ChangeMessagesUtil changeMessagesUtil,
       @Assisted Address reviewer) {
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.messageIdGenerator = messageIdGenerator;
+    this.changeMessagesUtil = changeMessagesUtil;
     this.reviewer = reviewer;
   }
 
@@ -58,38 +59,38 @@
   public boolean updateChange(ChangeContext ctx) {
     change = ctx.getChange();
     PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    ChangeUpdate update = ctx.getUpdate(psId);
+    update.removeReviewerByEmail(reviewer);
+    // The reviewer is not a registered Gerrit user, thus the email address can be used in
+    // ChangeMessage without replacement (it does not classify as Gerrit user identifiable
+    // information).
     String msg = "Removed reviewer " + reviewer;
-    changeMessage =
-        new ChangeMessage(
-            ChangeMessage.key(change.getId(), ChangeUtil.messageUuid()),
-            ctx.getAccountId(),
-            ctx.getWhen(),
-            psId);
-    changeMessage.setMessage(msg);
-
-    ctx.getUpdate(psId).setChangeMessage(msg);
-    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    mailMessage =
+        changeMessagesUtil.setChangeMessage(ctx, msg, ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    try {
-      NotifyResolver.Result notify = ctx.getNotify(change.getId());
-      if (!notify.shouldNotify()) {
-        return;
+  public void postUpdate(PostUpdateContext ctx) {
+    opResult = Result.builder().setDeletedReviewerByEmail(reviewer).build();
+    if (sendEmail) {
+      try {
+        NotifyResolver.Result notify = ctx.getNotify(change.getId());
+        if (!notify.shouldNotify()) {
+          return;
+        }
+        DeleteReviewerSender emailSender =
+            deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
+        emailSender.setFrom(ctx.getAccountId());
+        emailSender.addReviewersByEmail(Collections.singleton(reviewer));
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
+        emailSender.setNotify(notify);
+        emailSender.setMessageId(
+            messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
+        emailSender.send();
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
       }
-      DeleteReviewerSender emailSender =
-          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
-      emailSender.setFrom(ctx.getAccountId());
-      emailSender.addReviewersByEmail(Collections.singleton(reviewer));
-      emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-      emailSender.setNotify(notify);
-      emailSender.setMessageId(
-          messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
-      emailSender.send();
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", change.getId());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index bf00d27..1e40429 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -20,10 +20,8 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.EmailException;
@@ -31,11 +29,12 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
 import com.google.gerrit.server.mail.send.DeleteReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
@@ -44,42 +43,42 @@
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.RemoveReviewerControl;
-import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 
-public class DeleteReviewerOp implements BatchUpdateOp {
+public class DeleteReviewerOp extends ReviewerOp {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public interface Factory {
-    DeleteReviewerOp create(AccountState reviewerAccount, DeleteReviewerInput input);
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
   }
 
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final ReviewerDeleted reviewerDeleted;
   private final Provider<IdentifiedUser> user;
   private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
+  private final AccountCache accountCache;
 
-  private final AccountState reviewer;
+  private final Account reviewer;
   private final DeleteReviewerInput input;
 
-  ChangeMessage changeMessage;
+  String mailMessage;
   Change currChange;
-  PatchSet currPs;
   Map<String, Short> newApprovals = new HashMap<>();
   Map<String, Short> oldApprovals = new HashMap<>();
 
@@ -88,25 +87,25 @@
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
       ReviewerDeleted reviewerDeleted,
       Provider<IdentifiedUser> user,
       DeleteReviewerSender.Factory deleteReviewerSenderFactory,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
       MessageIdGenerator messageIdGenerator,
-      @Assisted AccountState reviewerAccount,
+      AccountCache accountCache,
+      @Assisted Account reviewerAccount,
       @Assisted DeleteReviewerInput input) {
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
     this.reviewerDeleted = reviewerDeleted;
     this.user = user;
     this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.accountCache = accountCache;
     this.reviewer = reviewerAccount;
     this.input = input;
   }
@@ -114,15 +113,18 @@
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException {
-    Account.Id reviewerId = reviewer.account().id();
+    Account.Id reviewerId = reviewer.id();
     // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
     removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
 
     if (!approvalsUtil.getReviewers(ctx.getNotes()).all().contains(reviewerId)) {
-      throw new ResourceNotFoundException();
+      throw new ResourceNotFoundException(
+          String.format(
+              "Reviewer %s doesn't exist in the change, hence can't delete it",
+              reviewer.getName()));
     }
     currChange = ctx.getChange();
-    currPs = psUtil.current(ctx.getNotes());
+    setPatchSet(psUtil.current(ctx.getNotes()));
 
     LabelTypes labelTypes =
         projectCache
@@ -141,21 +143,23 @@
             ? "cc"
             : "reviewer";
     StringBuilder msg = new StringBuilder();
-    msg.append(String.format("Removed %s %s", ccOrReviewer, reviewer.account().fullName()));
+    msg.append(
+        String.format(
+            "Removed %s %s", ccOrReviewer, AccountTemplateUtil.getAccountTemplate(reviewer.id())));
     StringBuilder removedVotesMsg = new StringBuilder();
     removedVotesMsg.append(" with the following votes:\n\n");
     boolean votesRemoved = false;
     for (PatchSetApproval a : approvals(ctx, reviewerId)) {
       // Check if removing this vote is OK
       removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
-      if (a.patchSetId().equals(currPs.id()) && a.value() != 0) {
+      if (a.patchSetId().equals(patchSet.id()) && a.value() != 0) {
         oldApprovals.put(a.label(), a.value());
         removedVotesMsg
             .append("* ")
             .append(a.label())
             .append(formatLabelValue(a.value()))
             .append(" by ")
-            .append(userFactory.create(a.accountId()).getNameEmail())
+            .append(AccountTemplateUtil.getAccountTemplate(a.accountId()))
             .append("\n");
         votesRemoved = true;
       }
@@ -166,44 +170,55 @@
     } else {
       msg.append(".");
     }
-    ChangeUpdate update = ctx.getUpdate(currPs.id());
+    ChangeUpdate update = ctx.getUpdate(patchSet.id());
     update.removeReviewer(reviewerId);
 
-    changeMessage =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-    cmUtil.addChangeMessage(update, changeMessage);
-
+    mailMessage =
+        cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
+    opResult = Result.builder().setDeletedReviewer(reviewer.id()).build();
+
     NotifyResolver.Result notify = ctx.getNotify(currChange.getId());
-    if (input.notify == null
-        && currChange.isWorkInProgress()
-        && !oldApprovals.isEmpty()
-        && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
-      // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
-      // change.
-      notify = notify.withHandling(NotifyHandling.OWNER);
-    }
-    try {
-      if (notify.shouldNotify()) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage, notify, ctx.getRepoView());
+    if (sendEmail) {
+      if (input.notify == null
+          && currChange.isWorkInProgress()
+          && !oldApprovals.isEmpty()
+          && notify.handling().compareTo(NotifyHandling.OWNER) < 0) {
+        // Override NotifyHandling from the context to notify owner if votes were removed on a WIP
+        // change.
+        notify = notify.withHandling(NotifyHandling.OWNER);
       }
-    } catch (Exception err) {
-      logger.atSevere().withCause(err).log("Cannot email update for change %s", currChange.getId());
+      try {
+        if (notify.shouldNotify()) {
+          emailReviewers(
+              ctx.getProject(), currChange, mailMessage, ctx.getWhen(), notify, ctx.getRepoView());
+        }
+      } catch (Exception err) {
+        logger.atSevere().withCause(err).log(
+            "Cannot email update for change %s", currChange.getId());
+      }
     }
-    reviewerDeleted.fire(
-        currChange,
-        currPs,
-        reviewer,
-        ctx.getAccount(),
-        changeMessage.getMessage(),
-        newApprovals,
-        oldApprovals,
-        notify.handling(),
-        ctx.getWhen());
+
+    NotifyHandling notifyHandling = notify.handling();
+    eventSender =
+        () ->
+            reviewerDeleted.fire(
+                ctx.getChangeData(currChange),
+                patchSet,
+                accountCache.get(reviewer.id()).orElse(AccountState.forAccount(reviewer)),
+                ctx.getAccount(),
+                mailMessage,
+                newApprovals,
+                oldApprovals,
+                notifyHandling,
+                ctx.getWhen());
+    if (sendEvent) {
+      sendEvent();
+    }
   }
 
   private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
@@ -222,20 +237,21 @@
   private void emailReviewers(
       Project.NameKey projectName,
       Change change,
-      ChangeMessage changeMessage,
+      String mailMessage,
+      Timestamp timestamp,
       NotifyResolver.Result notify,
       RepoView repoView)
       throws EmailException {
     Account.Id userId = user.get().getAccountId();
-    if (userId.equals(reviewer.account().id())) {
+    if (userId.equals(reviewer.id())) {
       // The user knows they removed themselves, don't bother emailing them.
       return;
     }
     DeleteReviewerSender emailSender =
         deleteReviewerSenderFactory.create(projectName, change.getId());
     emailSender.setFrom(userId);
-    emailSender.addReviewers(Collections.singleton(reviewer.account().id()));
-    emailSender.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+    emailSender.addReviewers(Collections.singleton(reviewer.id()));
+    emailSender.setChangeMessage(mailMessage, timestamp);
     emailSender.setNotify(notify);
     emailSender.setMessageId(
         messageIdGenerator.fromChangeUpdate(repoView, change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/DeleteReviewersUtil.java b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
new file mode 100644
index 0000000..79ed043
--- /dev/null
+++ b/java/com/google/gerrit/server/change/DeleteReviewersUtil.java
@@ -0,0 +1,84 @@
+// 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.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class DeleteReviewersUtil {
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
+  private final AccountResolver accountResolver;
+  private final ApprovalsUtil approvalsUtil;
+
+  @Inject
+  DeleteReviewersUtil(
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory,
+      AccountResolver accountResolver,
+      ApprovalsUtil approvalsUtil) {
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
+    this.accountResolver = accountResolver;
+    this.approvalsUtil = approvalsUtil;
+  }
+
+  public void addDeleteReviewerOpToBatchUpdate(
+      BatchUpdate batchUpdate, ChangeNotes changeNotes, ReviewerInput reviewerInput)
+      throws IOException, ConfigInvalidException, AuthException, ResourceNotFoundException {
+
+    try {
+      AccountResolver.Result result =
+          accountResolver.resolveIgnoreVisibility(reviewerInput.reviewer);
+      if (fetchAccountIds(changeNotes).contains(result.asUniqueUser().getAccountId())) {
+        DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
+        deleteReviewerInput.notify = reviewerInput.notify;
+        deleteReviewerInput.notifyDetails = reviewerInput.notifyDetails;
+        batchUpdate.addOp(
+            changeNotes.getChangeId(),
+            deleteReviewerOpFactory.create(result.asUnique().account(), deleteReviewerInput));
+        return;
+      }
+      return;
+    } catch (AccountResolver.UnresolvableAccountException e) {
+      if (e.isSelf()) {
+        throw new AuthException(e.getMessage(), e);
+      }
+    }
+    Address address = Address.tryParse(reviewerInput.reviewer);
+    if (address != null && changeNotes.getReviewersByEmail().all().contains(address)) {
+      batchUpdate.addOp(changeNotes.getChangeId(), deleteReviewerByEmailOpFactory.create(address));
+      return;
+    }
+
+    throw new ResourceNotFoundException(reviewerInput.reviewer);
+  }
+
+  private Collection<Account.Id> fetchAccountIds(ChangeNotes changeNotes) {
+    return approvalsUtil.getReviewers(changeNotes).all();
+  }
+}
diff --git a/java/com/google/gerrit/server/change/EmailReviewComments.java b/java/com/google/gerrit/server/change/EmailReviewComments.java
index cacfbe7..3c7ea44 100644
--- a/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.server.CurrentUser;
@@ -34,6 +33,7 @@
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.sql.Timestamp;
 import java.util.List;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Future;
@@ -45,28 +45,30 @@
     // TODO(dborowitz/wyatta): Rationalize these arguments so HTML and text templates are operating
     // on the same set of inputs.
     /**
+     * Creates handle for sending email
+     *
      * @param notify setting for handling notification.
      * @param notes change notes.
      * @param patchSet patch set corresponding to the top-level op
      * @param user user the email should come from.
-     * @param message used by text template only: the full ChangeMessage that will go in the
-     *     database. The contents of this message typically include the "Patch set N" header and "(M
-     *     comments)".
+     * @param message used by text template only. The contents of this message typically include the
+     *     "Patch set N" header and "(M comments)".
+     * @param timestamp timestamp when the comments were added.
      * @param comments inline comments.
      * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
      *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
      *     will be added automatically in soy in a structured way.
      * @param labels labels applied as part of this review operation.
-     * @return handle for sending email.
      */
     EmailReviewComments create(
         NotifyResolver.Result notify,
         ChangeNotes notes,
         PatchSet patchSet,
         IdentifiedUser user,
-        ChangeMessage message,
+        @Assisted("message") String message,
+        Timestamp timestamp,
         List<? extends Comment> comments,
-        String patchSetComment,
+        @Assisted("patchSetComment") String patchSetComment,
         List<LabelVote> labels,
         RepoView repoView);
   }
@@ -81,7 +83,8 @@
   private final ChangeNotes notes;
   private final PatchSet patchSet;
   private final IdentifiedUser user;
-  private final ChangeMessage message;
+  private final String message;
+  private final Timestamp timestamp;
   private final List<? extends Comment> comments;
   private final String patchSetComment;
   private final List<LabelVote> labels;
@@ -98,9 +101,10 @@
       @Assisted ChangeNotes notes,
       @Assisted PatchSet patchSet,
       @Assisted IdentifiedUser user,
-      @Assisted ChangeMessage message,
+      @Assisted("message") String message,
+      @Assisted Timestamp timestamp,
       @Assisted List<? extends Comment> comments,
-      @Nullable @Assisted String patchSetComment,
+      @Nullable @Assisted("patchSetComment") String patchSetComment,
       @Assisted List<LabelVote> labels,
       @Assisted RepoView repoView) {
     this.sendEmailsExecutor = executor;
@@ -113,6 +117,7 @@
     this.patchSet = patchSet;
     this.user = user;
     this.message = message;
+    this.timestamp = timestamp;
     this.comments = COMMENT_ORDER.sortedCopy(comments);
     this.patchSetComment = patchSetComment;
     this.labels = labels;
@@ -132,7 +137,7 @@
           commentSenderFactory.create(notes.getProjectName(), notes.getChangeId());
       emailSender.setFrom(user.getAccountId());
       emailSender.setPatchSet(patchSet, patchSetInfoFactory.get(notes.getProjectName(), patchSet));
-      emailSender.setChangeMessage(message.getMessage(), message.getWrittenOn());
+      emailSender.setChangeMessage(message, timestamp);
       emailSender.setComments(comments);
       emailSender.setPatchSetComment(patchSetComment);
       emailSender.setLabels(labels);
diff --git a/java/com/google/gerrit/server/change/FileContentUtil.java b/java/com/google/gerrit/server/change/FileContentUtil.java
index 49c1fe2..c54b902 100644
--- a/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -76,8 +76,6 @@
    * @param parent A 1-based parent index to get the content from instead. Null if the content
    *     should be obtained from {@code revstr} instead.
    * @return Content of the file as {@code BinaryResult}.
-   * @throws ResourceNotFoundException
-   * @throws IOException
    */
   public BinaryResult getContent(
       ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
diff --git a/java/com/google/gerrit/server/change/FileInfoJson.java b/java/com/google/gerrit/server/change/FileInfoJson.java
index ad6f9c7..ab557dc 100644
--- a/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -46,7 +46,8 @@
    *
    * @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.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   default Map<String, FileInfo> getFileInfoMap(Change change, ObjectId objectId, int parentNum)
@@ -74,7 +75,8 @@
    *
    * @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.
+   * @param parentNum 1-based integer identifying the parent number used for comparison. If zero,
+   *     the only parent will be used or the auto-merge if {@code newCommit} is a merge commit.
    * @return a mapping of the file paths to their related diff information.
    */
   Map<String, FileInfo> getFileInfoMap(Project.NameKey project, ObjectId objectId, int parentNum)
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
deleted file mode 100644
index 228d631..0000000
--- a/java/com/google/gerrit/server/change/FileInfoJsonComparingImpl.java
+++ /dev/null
@@ -1,160 +0,0 @@
-// 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.common.annotations.VisibleForTesting;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Change;
-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.metrics.Counter1;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.server.logging.Metadata;
-import com.google.gerrit.server.patch.DiffExecutor;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Map;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.ObjectId;
-
-/**
- * Implementation of FileInfoJson which uses {@link FileInfoJsonOldImpl}, but also runs {@link
- * FileInfoJsonNewImpl} asynchronously and compares the results. This implementation is temporary
- * and will be used to verify that the results are the same.
- */
-public class FileInfoJsonComparingImpl implements FileInfoJson {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final FileInfoJsonOldImpl oldImpl;
-  private final FileInfoJsonNewImpl newImpl;
-  private final ExecutorService executor;
-  private final Metrics metrics;
-
-  /**
-   * TODO(ghareeb): These metrics are temporary for launching the new diff cache redesign and are
-   * not documented. These will be removed soon.
-   */
-  @VisibleForTesting
-  @Singleton
-  static class Metrics {
-    private enum Status {
-      MATCH,
-      MISMATCH,
-      ERROR
-    }
-
-    final Counter1<Status> diffs;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      diffs =
-          metricMaker.newCounter(
-              "diff/list_files/dark_launch",
-              new Description(
-                      "Total number of matching, non-matching, or error in list-files diffs in the old and new diff cache implementations.")
-                  .setRate()
-                  .setUnit("count"),
-              Field.ofEnum(Status.class, "type", Metadata.Builder::eventType).build());
-    }
-  }
-
-  @Inject
-  public FileInfoJsonComparingImpl(
-      FileInfoJsonOldImpl oldImpl,
-      FileInfoJsonNewImpl newImpl,
-      @DiffExecutor ExecutorService executor,
-      Metrics metrics) {
-    this.oldImpl = oldImpl;
-    this.newImpl = newImpl;
-    this.executor = executor;
-    this.metrics = metrics;
-  }
-
-  @Override
-  public Map<String, FileInfo> getFileInfoMap(
-      Change change, ObjectId objectId, @Nullable PatchSet base)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    Map<String, FileInfo> result = oldImpl.getFileInfoMap(change, objectId, base);
-    @SuppressWarnings("unused")
-    Future<?> ignored =
-        executor.submit(
-            () -> {
-              try {
-                Map<String, FileInfo> fileInfoNew = newImpl.getFileInfoMap(change, objectId, base);
-                compareAndLogMetrics(
-                    result,
-                    fileInfoNew,
-                    String.format(
-                        "Mismatch comparing old and new diff implementations for change: %s, objectId: %s and base: %s",
-                        change, objectId, base == null ? "none" : base.id()));
-              } catch (ResourceConflictException | PatchListNotAvailableException e) {
-                // If an exception happens while evaluating the new diff, increment the non-matching
-                // counter
-                metrics.diffs.increment(Metrics.Status.ERROR);
-                logger.atWarning().withCause(e).log(
-                    "Error comparing old and new diff implementations.");
-              }
-            });
-    return result;
-  }
-
-  @Override
-  public Map<String, FileInfo> getFileInfoMap(
-      Project.NameKey project, ObjectId objectId, int parentNum)
-      throws ResourceConflictException, PatchListNotAvailableException {
-    Map<String, FileInfo> result = oldImpl.getFileInfoMap(project, objectId, parentNum);
-    @SuppressWarnings("unused")
-    Future<?> ignored =
-        executor.submit(
-            () -> {
-              try {
-                Map<String, FileInfo> resultNew =
-                    newImpl.getFileInfoMap(project, objectId, parentNum);
-                compareAndLogMetrics(
-                    result,
-                    resultNew,
-                    String.format(
-                        "Mismatch comparing old and new diff implementations for project: %s, objectId: %s and parentNum: %d",
-                        project, objectId, parentNum));
-              } catch (ResourceConflictException | PatchListNotAvailableException e) {
-                // If an exception happens while evaluating the new diff, increment the non-matching
-                // ctr
-                metrics.diffs.increment(Metrics.Status.ERROR);
-                logger.atWarning().withCause(e).log(
-                    "Error comparing old and new diff implementations.");
-              }
-            });
-    return result;
-  }
-
-  private void compareAndLogMetrics(
-      Map<String, FileInfo> fileInfoMapOld,
-      Map<String, FileInfo> fileInfoMapNew,
-      String warningMessage) {
-    if (fileInfoMapOld.equals(fileInfoMapNew)) {
-      metrics.diffs.increment(Metrics.Status.MATCH);
-      return;
-    }
-    metrics.diffs.increment(Metrics.Status.MISMATCH);
-    logger.atWarning().log(warningMessage);
-  }
-}
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
similarity index 89%
rename from java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
rename to java/com/google/gerrit/server/change/FileInfoJsonImpl.java
index 1ca2c93..b729c11 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonNewImpl.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonImpl.java
@@ -32,12 +32,12 @@
 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 {
+/** Implementation of {@link FileInfoJson} using {@link DiffOperations}. */
+public class FileInfoJsonImpl implements FileInfoJson {
   private final DiffOperations diffs;
 
   @Inject
-  FileInfoJsonNewImpl(DiffOperations diffOperations) {
+  FileInfoJsonImpl(DiffOperations diffOperations) {
     this.diffs = diffOperations;
   }
 
@@ -47,8 +47,11 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       if (base == null) {
+        // Setting parentNum=0 requests the default parent, which is the only parent for
+        // single-parent commits, or the auto-merge otherwise
         return asFileInfo(
-            diffs.listModifiedFilesAgainstParent(change.getProject(), objectId, null));
+            diffs.listModifiedFilesAgainstParent(
+                change.getProject(), objectId, /* parentNum= */ 0));
       }
       return asFileInfo(diffs.listModifiedFiles(change.getProject(), base.commitId(), objectId));
     } catch (DiffNotAvailableException e) {
@@ -63,7 +66,7 @@
       throws ResourceConflictException, PatchListNotAvailableException {
     try {
       Map<String, FileDiffOutput> modifiedFiles =
-          diffs.listModifiedFilesAgainstParent(project, objectId, parent + 1);
+          diffs.listModifiedFilesAgainstParent(project, objectId, parent);
       return asFileInfo(modifiedFiles);
     } catch (DiffNotAvailableException e) {
       convertException(e);
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonModule.java b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
index de116bb..b8e05f0 100644
--- a/java/com/google/gerrit/server/change/FileInfoJsonModule.java
+++ b/java/com/google/gerrit/server/change/FileInfoJsonModule.java
@@ -14,31 +14,12 @@
 
 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 {
-  /** Use the new diff cache implementation {@link FileInfoJsonNewImpl}. */
-  private final boolean useNewDiffCache;
-
-  /** Used to dark launch the new diff cache with the list files endpoint. */
-  private final boolean runNewDiffCacheAsync;
-
-  public FileInfoJsonModule(@GerritServerConfig Config cfg) {
-    this.useNewDiffCache =
-        cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
-    this.runNewDiffCacheAsync =
-        cfg.getBoolean("cache", "diff_cache", "runNewDiffCacheAsync_listFiles", false);
-  }
 
   @Override
   public void configure() {
-    if (runNewDiffCacheAsync) {
-      bind(FileInfoJson.class).to(FileInfoJsonComparingImpl.class);
-      return;
-    }
-    bind(FileInfoJson.class)
-        .to(useNewDiffCache ? FileInfoJsonNewImpl.class : FileInfoJsonOldImpl.class);
+    bind(FileInfoJson.class).to(FileInfoJsonImpl.class);
   }
 }
diff --git a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java b/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
deleted file mode 100644
index 55d162a..0000000
--- a/java/com/google/gerrit/server/change/FileInfoJsonOldImpl.java
+++ /dev/null
@@ -1,128 +0,0 @@
-// 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/GetRelatedChangesUtil.java b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
new file mode 100644
index 0000000..b1f9726
--- /dev/null
+++ b/java/com/google/gerrit/server/change/GetRelatedChangesUtil.java
@@ -0,0 +1,119 @@
+// 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 static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+
+/** Utility class that gets the ancestor changes and the descendent changes of a specific change. */
+@Singleton
+public class GetRelatedChangesUtil {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final RelatedChangesSorter sorter;
+  private final IndexConfig indexConfig;
+  private final ChangeData.Factory changeDataFactory;
+
+  @Inject
+  GetRelatedChangesUtil(
+      Provider<InternalChangeQuery> queryProvider,
+      RelatedChangesSorter sorter,
+      IndexConfig indexConfig,
+      ChangeData.Factory changeDataFactory) {
+    this.queryProvider = queryProvider;
+    this.sorter = sorter;
+    this.indexConfig = indexConfig;
+    this.changeDataFactory = changeDataFactory;
+  }
+
+  /**
+   * Gets related changes of a specific change revision.
+   *
+   * @param changeData the change of the inputted revision.
+   * @param basePs the revision that the method checks for related changes.
+   * @return list of related changes, sorted via {@link RelatedChangesSorter}
+   */
+  public List<RelatedChangesSorter.PatchSetData> getRelated(ChangeData changeData, PatchSet basePs)
+      throws IOException, PermissionBackendException {
+    Set<String> groups = getAllGroups(changeData.patchSets());
+    logger.atFine().log("groups = %s", groups);
+    if (groups.isEmpty()) {
+      return Collections.emptyList();
+    }
+
+    List<ChangeData> cds =
+        InternalChangeQuery.byProjectGroups(
+            queryProvider, indexConfig, changeData.project(), groups);
+    if (cds.isEmpty()) {
+      return Collections.emptyList();
+    }
+    if (cds.size() == 1 && cds.get(0).getId().equals(changeData.getId())) {
+      return Collections.emptyList();
+    }
+
+    cds = reloadChangeIfStale(cds, changeData, basePs);
+
+    return sorter.sort(cds, basePs);
+  }
+
+  private List<ChangeData> reloadChangeIfStale(
+      List<ChangeData> changeDatasFromIndex, ChangeData wantedChange, PatchSet wantedPs) {
+    checkArgument(
+        wantedChange.getId().equals(wantedPs.id().changeId()),
+        "change of wantedPs (%s) doesn't match wantedChange (%s)",
+        wantedPs.id().changeId(),
+        wantedChange.getId());
+
+    List<ChangeData> changeDatas = new ArrayList<>(changeDatasFromIndex.size() + 1);
+    changeDatas.addAll(changeDatasFromIndex);
+
+    // Reload the change in case the patch set is absent.
+    changeDatas.stream()
+        .filter(
+            cd -> cd.getId().equals(wantedPs.id().changeId()) && cd.patchSet(wantedPs.id()) == null)
+        .forEach(ChangeData::reloadChange);
+
+    if (changeDatas.stream().noneMatch(cd -> cd.getId().equals(wantedPs.id().changeId()))) {
+      // The change of the wanted patch set is missing in the result from the index.
+      // Load it from NoteDb and add it to the result.
+      changeDatas.add(changeDataFactory.create(wantedChange.change()));
+    }
+
+    return changeDatas;
+  }
+
+  @VisibleForTesting
+  public static Set<String> getAllGroups(Collection<PatchSet> patchSets) {
+    return patchSets.stream().flatMap(ps -> ps.groups().stream()).collect(toSet());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/IncludedIn.java b/java/com/google/gerrit/server/change/IncludedIn.java
index c06ce82..94498d7 100644
--- a/java/com/google/gerrit/server/change/IncludedIn.java
+++ b/java/com/google/gerrit/server/change/IncludedIn.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.IncludedInInfo;
@@ -37,10 +38,15 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -75,13 +81,26 @@
         throw new ResourceConflictException(err.getMessage());
       }
 
-      IncludedInResolver.Result d = IncludedInResolver.resolve(r, rw, rev);
+      RefDatabase refDb = r.getRefDatabase();
+      Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
+      Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
+      List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
+      allTagsAndBranches.addAll(tags);
+      allTagsAndBranches.addAll(branches);
+
+      Set<String> allMatchingTagsAndBranches =
+          rw.getMergedInto(rev, IncludedInUtil.getSortedRefs(allTagsAndBranches, rw)).stream()
+              .map(Ref::getName)
+              .collect(Collectors.toSet());
 
       // Filter branches and tags according to their visbility by the user
       ImmutableSortedSet<String> filteredBranches =
-          sortedShortNames(filterReadableRefs(project, d.branches()));
+          sortedShortNames(
+              filterReadableRefs(
+                  project, getMatchingRefNames(allMatchingTagsAndBranches, branches)));
       ImmutableSortedSet<String> filteredTags =
-          sortedShortNames(filterReadableRefs(project, d.tags()));
+          sortedShortNames(
+              filterReadableRefs(project, getMatchingRefNames(allMatchingTagsAndBranches, tags)));
 
       ListMultimap<String, String> external = MultimapBuilder.hashKeys().arrayListValues().build();
       externalIncludedIn.runEach(
@@ -115,6 +134,18 @@
     }
   }
 
+  /**
+   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
+   * allRef list.
+   */
+  private static ImmutableList<Ref> getMatchingRefNames(
+      Set<String> matchingRefs, Collection<Ref> allRefs) {
+    return allRefs.stream()
+        .filter(r -> matchingRefs.contains(r.getName()))
+        .distinct()
+        .collect(toImmutableList());
+  }
+
   private ImmutableSortedSet<String> sortedShortNames(Collection<String> refs) {
     return refs.stream()
         .map(Repository::shortenRefName)
diff --git a/java/com/google/gerrit/server/change/IncludedInRefs.java b/java/com/google/gerrit/server/change/IncludedInRefs.java
new file mode 100644
index 0000000..f069251
--- /dev/null
+++ b/java/com/google/gerrit/server/change/IncludedInRefs.java
@@ -0,0 +1,139 @@
+// 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 static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import java.io.IOException;
+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.Set;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class IncludedInRefs {
+  protected final GitRepositoryManager repoManager;
+  protected final PermissionBackend permissionBackend;
+
+  @Inject
+  IncludedInRefs(GitRepositoryManager repoManager, PermissionBackend permissionBackend) {
+    this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public Map<String, Set<String>> apply(
+      Project.NameKey project, Set<String> commits, Set<String> refNames)
+      throws ResourceConflictException, BadRequestException, IOException,
+          PermissionBackendException, ResourceNotFoundException, AuthException {
+    try (Repository repo = repoManager.openRepository(project)) {
+      Set<Ref> visibleRefs = getVisibleRefs(repo, refNames, project);
+
+      if (!visibleRefs.isEmpty()) {
+        try (RevWalk revWalk = new RevWalk(repo)) {
+          revWalk.setRetainBody(false);
+          Set<RevCommit> revCommits = getRevCommits(commits, revWalk);
+
+          if (!revCommits.isEmpty()) {
+            return commitsIncludedIn(
+                revCommits, IncludedInUtil.getSortedRefs(visibleRefs, revWalk), revWalk);
+          }
+        }
+      }
+    }
+    return Collections.EMPTY_MAP;
+  }
+
+  private Set<Ref> getVisibleRefs(Repository repo, Set<String> refNames, Project.NameKey project)
+      throws PermissionBackendException {
+    RefDatabase refDb = repo.getRefDatabase();
+    Set<Ref> refs = new HashSet<>();
+    for (String refName : refNames) {
+      try {
+        Ref ref = refDb.exactRef(refName);
+        if (ref != null) {
+          refs.add(ref);
+        }
+      } catch (IOException e) {
+        // Ignore and continue to process rest of the refs so as to keep
+        // the behavior similar to the ref not being visible to the user.
+        // This will ensure that there is no information leak about the
+        // ref when the ref is corrupted and is not visible to the user.
+      }
+    }
+    return filterReadableRefs(project, refs, repo);
+  }
+
+  private Set<RevCommit> getRevCommits(Set<String> commits, RevWalk revWalk) throws IOException {
+    Set<RevCommit> revCommits = new HashSet<>();
+    for (String commit : commits) {
+      try {
+        revCommits.add(revWalk.parseCommit(ObjectId.fromString(commit)));
+      } catch (MissingObjectException | IncorrectObjectTypeException | IllegalArgumentException e) {
+        // Ignore and continue to process the rest of the commits so as to keep
+        // the behavior similar to the commit not being included in any of the
+        // visible specified refs. This will ensure that there is no information
+        // leak about the commit when the commit is not visible to the user.
+      }
+    }
+    return revCommits;
+  }
+
+  private Map<String, Set<String>> commitsIncludedIn(
+      Collection<RevCommit> commits, Collection<Ref> refs, RevWalk revWalk) throws IOException {
+    Map<String, Set<String>> refsByCommit = new HashMap<>();
+    for (RevCommit commit : commits) {
+      List<Ref> matchingRefs = revWalk.getMergedInto(commit, refs);
+      if (matchingRefs.size() > 0) {
+        refsByCommit.put(
+            commit.getName(), matchingRefs.stream().map(Ref::getName).collect(toSet()));
+      }
+    }
+    return refsByCommit;
+  }
+
+  /**
+   * Filter readable refs according to the caller's refs visibility.
+   *
+   * @param project specific Gerrit project.
+   * @param inputRefs a list of refs
+   * @param repo repository opened for the Gerrit project.
+   * @return set of visible refs to the caller
+   */
+  private Set<Ref> filterReadableRefs(Project.NameKey project, Set<Ref> inputRefs, Repository repo)
+      throws PermissionBackendException {
+    PermissionBackend.ForProject perm = permissionBackend.currentUser().project(project);
+    return perm.filter(inputRefs, repo, RefFilterOptions.defaults()).stream().collect(toSet());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/IncludedInResolver.java b/java/com/google/gerrit/server/change/IncludedInResolver.java
deleted file mode 100644
index 3891700..0000000
--- a/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static java.util.Comparator.comparing;
-import static java.util.stream.Collectors.toList;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.flogger.FluentLogger;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.Constants;
-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.RevFlag;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/** Resolve in which tags and branches a commit is included. */
-public class IncludedInResolver {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static Result resolve(Repository repo, RevWalk rw, RevCommit commit) throws IOException {
-    RevFlag flag = newFlag(rw);
-    try {
-      return new IncludedInResolver(repo, rw, commit, flag).resolve();
-    } finally {
-      rw.disposeFlag(flag);
-    }
-  }
-
-  public static boolean includedInAny(
-      final Repository repo, RevWalk rw, RevCommit commit, Collection<Ref> refs)
-      throws IOException {
-    if (refs.isEmpty()) {
-      return false;
-    }
-    RevFlag flag = newFlag(rw);
-    try {
-      return new IncludedInResolver(repo, rw, commit, flag).includedInOne(refs);
-    } finally {
-      rw.disposeFlag(flag);
-    }
-  }
-
-  private static RevFlag newFlag(RevWalk rw) {
-    return rw.newFlag("CONTAINS_TARGET");
-  }
-
-  private final Repository repo;
-  private final RevWalk rw;
-  private final RevCommit target;
-
-  private final RevFlag containsTarget;
-  private ListMultimap<RevCommit, String> commitToRef;
-  private List<RevCommit> tipsByCommitTime;
-
-  private IncludedInResolver(
-      Repository repo, RevWalk rw, RevCommit target, RevFlag containsTarget) {
-    this.repo = repo;
-    this.rw = rw;
-    this.target = target;
-    this.containsTarget = containsTarget;
-  }
-
-  private Result resolve() throws IOException {
-    RefDatabase refDb = repo.getRefDatabase();
-    Collection<Ref> tags = refDb.getRefsByPrefix(Constants.R_TAGS);
-    Collection<Ref> branches = refDb.getRefsByPrefix(Constants.R_HEADS);
-    List<Ref> allTagsAndBranches = Lists.newArrayListWithCapacity(tags.size() + branches.size());
-    allTagsAndBranches.addAll(tags);
-    allTagsAndBranches.addAll(branches);
-    parseCommits(allTagsAndBranches);
-    Set<String> allMatchingTagsAndBranches = includedIn(tipsByCommitTime, 0);
-
-    return new AutoValue_IncludedInResolver_Result(
-        getMatchingRefNames(allMatchingTagsAndBranches, branches),
-        getMatchingRefNames(allMatchingTagsAndBranches, tags));
-  }
-
-  private boolean includedInOne(Collection<Ref> refs) throws IOException {
-    parseCommits(refs);
-    List<RevCommit> before = new ArrayList<>();
-    List<RevCommit> after = new ArrayList<>();
-    partition(before, after);
-    rw.reset();
-    // It is highly likely that the target is reachable from the "after" set
-    // Within the "before" set we are trying to handle cases arising from clock skew
-    return !includedIn(after, 1).isEmpty() || !includedIn(before, 1).isEmpty();
-  }
-
-  /** Resolves which tip refs include the target commit. */
-  private Set<String> includedIn(Collection<RevCommit> tips, int limit)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException {
-    Set<String> result = new HashSet<>();
-    for (RevCommit tip : tips) {
-      boolean commitFound = false;
-      rw.resetRetain(RevFlag.UNINTERESTING, containsTarget);
-      rw.markStart(tip);
-      for (RevCommit commit : rw) {
-        if (commit.equals(target) || commit.has(containsTarget)) {
-          commitFound = true;
-          tip.add(containsTarget);
-          result.addAll(commitToRef.get(tip));
-          break;
-        }
-      }
-      if (!commitFound) {
-        rw.markUninteresting(tip);
-      } else if (0 < limit && limit < result.size()) {
-        break;
-      }
-    }
-    return result;
-  }
-
-  /**
-   * Partition the reference tips into two sets:
-   *
-   * <ul>
-   *   <li>before = commits with time < target.getCommitTime()
-   *   <li>after = commits with time >= target.getCommitTime()
-   * </ul>
-   *
-   * Each of the before/after lists is sorted by the commit time.
-   *
-   * @param before
-   * @param after
-   */
-  private void partition(List<RevCommit> before, List<RevCommit> after) {
-    int insertionPoint =
-        Collections.binarySearch(tipsByCommitTime, target, comparing(RevCommit::getCommitTime));
-    if (insertionPoint < 0) {
-      insertionPoint = -(insertionPoint + 1);
-    }
-    if (0 < insertionPoint) {
-      before.addAll(tipsByCommitTime.subList(0, insertionPoint));
-    }
-    if (insertionPoint < tipsByCommitTime.size()) {
-      after.addAll(tipsByCommitTime.subList(insertionPoint, tipsByCommitTime.size()));
-    }
-  }
-
-  /**
-   * Returns the short names of refs which are as well in the matchingRefs list as well as in the
-   * allRef list.
-   */
-  private static ImmutableList<Ref> getMatchingRefNames(
-      Set<String> matchingRefs, Collection<Ref> allRefs) {
-    return allRefs.stream()
-        .filter(r -> matchingRefs.contains(r.getName()))
-        .distinct()
-        .collect(ImmutableList.toImmutableList());
-  }
-
-  /** Parse commit of ref and store the relation between ref and commit. */
-  private void parseCommits(Collection<Ref> refs) throws IOException {
-    if (commitToRef != null) {
-      return;
-    }
-    commitToRef = LinkedListMultimap.create();
-    for (Ref ref : refs) {
-      final RevCommit commit;
-      try {
-        commit = rw.parseCommit(ref.getObjectId());
-      } catch (IncorrectObjectTypeException notCommit) {
-        // Its OK for a tag reference to point to a blob or a tree, this
-        // is common in the Linux kernel or git.git repository.
-        //
-        continue;
-      } catch (MissingObjectException notHere) {
-        // Log the problem with this branch, but keep processing.
-        //
-        logger.atWarning().log(
-            "Reference %s in %s points to dangling object %s",
-            ref.getName(), repo.getDirectory(), ref.getObjectId());
-        continue;
-      }
-      commitToRef.put(commit, ref.getName());
-    }
-    tipsByCommitTime =
-        commitToRef.keySet().stream().sorted(comparing(RevCommit::getCommitTime)).collect(toList());
-  }
-
-  @AutoValue
-  public abstract static class Result {
-    public abstract ImmutableList<Ref> branches();
-
-    public abstract ImmutableList<Ref> tags();
-  }
-}
diff --git a/java/com/google/gerrit/server/change/IncludedInUtil.java b/java/com/google/gerrit/server/change/IncludedInUtil.java
new file mode 100644
index 0000000..6f75e0f
--- /dev/null
+++ b/java/com/google/gerrit/server/change/IncludedInUtil.java
@@ -0,0 +1,49 @@
+// 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 static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class IncludedInUtil {
+
+  /**
+   * Sorts the collection of {@code Ref} instances by its tip commit time.
+   *
+   * @param refs collection to be sorted
+   * @param revWalk {@code RevWalk} instance for parsing ref's tip commit
+   * @return sorted list of refs
+   */
+  public static List<Ref> getSortedRefs(Collection<Ref> refs, RevWalk revWalk) {
+    return refs.stream()
+        .sorted(
+            comparing(
+                ref -> {
+                  try {
+                    return revWalk.parseCommit(ref.getObjectId()).getCommitTime();
+                  } catch (IOException e) {
+                    // Ignore and continue to sort
+                  }
+                  return 0;
+                }))
+        .collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/server/change/LabelNormalizer.java b/java/com/google/gerrit/server/change/LabelNormalizer.java
index 30343d4..aeb9db0 100644
--- a/java/com/google/gerrit/server/change/LabelNormalizer.java
+++ b/java/com/google/gerrit/server/change/LabelNormalizer.java
@@ -33,6 +33,7 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 
 /**
  * Normalizes votes on labels according to project config.
@@ -76,10 +77,11 @@
   }
 
   /**
+   * Returns copies of approvals normalized to the defined ranges for the label type. Approvals for
+   * unknown labels are not included in the output
+   *
    * @param notes change notes containing the given approvals.
    * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
-   *     unknown labels are not included in the output.
    */
   public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals) {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
@@ -101,12 +103,12 @@
         unchanged.add(psa);
         continue;
       }
-      LabelType label = labelTypes.byLabel(psa.labelId());
-      if (label == null) {
+      Optional<LabelType> label = labelTypes.byLabel(psa.labelId());
+      if (!label.isPresent()) {
         deleted.add(psa);
         continue;
       }
-      PatchSetApproval copy = applyTypeFloor(label, psa);
+      PatchSetApproval copy = applyTypeFloor(label.get(), psa);
       if (copy.value() != psa.value()) {
         updated.add(copy);
       } else {
diff --git a/java/com/google/gerrit/server/change/LabelsJson.java b/java/com/google/gerrit/server/change/LabelsJson.java
index acff03c..5ce121b 100644
--- a/java/com/google/gerrit/server/change/LabelsJson.java
+++ b/java/com/google/gerrit/server/change/LabelsJson.java
@@ -57,6 +57,7 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 
@@ -103,9 +104,9 @@
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels != null) {
         for (SubmitRecord.Label r : rec.labels) {
-          LabelType type = labelTypes.byLabel(r.label);
-          if (type != null && (!isMerged || type.isAllowPostSubmit())) {
-            toCheck.put(type.getName(), type);
+          Optional<LabelType> type = labelTypes.byLabel(r.label);
+          if (type.isPresent() && (!isMerged || type.get().isAllowPostSubmit())) {
+            toCheck.put(type.get().getName(), type.get());
           }
         }
       }
@@ -120,18 +121,18 @@
         continue;
       }
       for (SubmitRecord.Label r : rec.labels) {
-        LabelType type = labelTypes.byLabel(r.label);
-        if (type == null || (isMerged && !type.isAllowPostSubmit())) {
+        Optional<LabelType> type = labelTypes.byLabel(r.label);
+        if (!type.isPresent() || (isMerged && !type.get().isAllowPostSubmit())) {
           continue;
         }
 
-        for (LabelValue v : type.getValues()) {
-          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
+        for (LabelValue v : type.get().getValues()) {
+          boolean ok = can.contains(new LabelPermission.WithValue(type.get(), v));
           if (isMerged) {
             if (labels == null) {
               labels = currentLabels(filterApprovalsBy, cd);
             }
-            short prev = labels.getOrDefault(type.getName(), (short) 0);
+            short prev = labels.getOrDefault(type.get().getName(), (short) 0);
             ok &= v.getValue() >= prev;
           }
           if (ok) {
@@ -176,21 +177,21 @@
       setAllApprovals(accountLoader, cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-      LabelType type = labelTypes.byLabel(e.getKey());
-      if (type == null) {
+      Optional<LabelType> type = labelTypes.byLabel(e.getKey());
+      if (!type.isPresent()) {
         continue;
       }
       if (standard) {
         for (PatchSetApproval psa : cd.currentApprovals()) {
-          if (type.matches(psa)) {
+          if (type.get().matches(psa)) {
             short val = psa.value();
             Account.Id accountId = psa.accountId();
-            setLabelScores(accountLoader, type, e.getValue(), val, accountId);
+            setLabelScores(accountLoader, type.get(), e.getValue(), val, accountId);
           }
         }
       }
       if (detailed) {
-        setLabelValues(type, e.getValue());
+        setLabelValues(type.get(), e.getValue());
       }
     }
     return labels;
@@ -261,9 +262,9 @@
         MultimapBuilder.hashKeys().hashSetValues().build();
     for (PatchSetApproval a : cd.currentApprovals()) {
       allUsers.add(a.accountId());
-      LabelType type = labelTypes.byLabel(a.labelId());
-      if (type != null) {
-        labelNames.add(type.getName());
+      Optional<LabelType> type = labelTypes.byLabel(a.labelId());
+      if (type.isPresent()) {
+        labelNames.add(type.get().getName());
         // Not worth the effort to distinguish between votable/non-votable for 0
         // values on closed changes, since they can't vote anyway.
         current.put(a.accountId(), a);
@@ -292,8 +293,8 @@
 
     if (detailed) {
       labels.entrySet().stream()
-          .filter(e -> labelTypes.byLabel(e.getKey()) != null)
-          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()), e.getValue()));
+          .filter(e -> labelTypes.byLabel(e.getKey()).isPresent())
+          .forEach(e -> setLabelValues(labelTypes.byLabel(e.getKey()).get(), e.getValue()));
     }
 
     for (Account.Id accountId : allUsers) {
@@ -308,16 +309,16 @@
         }
       }
       for (PatchSetApproval psa : current.get(accountId)) {
-        LabelType type = labelTypes.byLabel(psa.labelId());
-        if (type == null) {
+        Optional<LabelType> type = labelTypes.byLabel(psa.labelId());
+        if (!type.isPresent()) {
           continue;
         }
 
         short val = psa.value();
-        ApprovalInfo info = byLabel.get(type.getName());
+        ApprovalInfo info = byLabel.get(type.get().getName());
         if (info != null) {
           info.value = Integer.valueOf(val);
-          info.permittedVotingRange = pvr.getOrDefault(type.getName(), null);
+          info.permittedVotingRange = pvr.getOrDefault(type.get().getName(), null);
           info.date = psa.granted();
           info.tag = psa.tag().orElse(null);
           if (psa.postSubmit()) {
@@ -328,7 +329,7 @@
           continue;
         }
 
-        setLabelScores(accountLoader, type, labels.get(type.getName()), val, accountId);
+        setLabelScores(accountLoader, type.get(), labels.get(type.get().getName()), val, accountId);
       }
     }
     return labels;
@@ -428,24 +429,24 @@
       PermissionBackend.ForChange perm = permissionBackend.absentUser(accountId).change(cd);
       Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(accountId, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = labelTypes.byLabel(e.getKey());
-        if (lt == null) {
+        Optional<LabelType> lt = labelTypes.byLabel(e.getKey());
+        if (!lt.isPresent()) {
           // Ignore submit record for undefined label; likely the submit rule
           // author didn't intend for the label to show up in the table.
           continue;
         }
         Integer value;
-        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.getName(), null);
+        VotingRangeInfo permittedVotingRange = pvr.getOrDefault(lt.get().getName(), null);
         String tag = null;
         Timestamp date = null;
-        PatchSetApproval psa = current.get(accountId, lt.getName());
+        PatchSetApproval psa = current.get(accountId, lt.get().getName());
         if (psa != null) {
           value = Integer.valueOf(psa.value());
           if (value == 0) {
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = perm.test(new LabelPermission(lt)) ? 0 : null;
+            value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
           }
           tag = psa.tag().orElse(null);
           date = psa.granted();
@@ -456,7 +457,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = perm.test(new LabelPermission(lt)) ? 0 : null;
+          value = perm.test(new LabelPermission(lt.get())) ? 0 : null;
         }
         addApproval(
             e.getValue().label(),
diff --git a/java/com/google/gerrit/server/change/AddReviewersEmail.java b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
similarity index 80%
rename from java/com/google/gerrit/server/change/AddReviewersEmail.java
rename to java/com/google/gerrit/server/change/ModifyReviewersEmail.java
index 4a3f638..cb747f6 100644
--- a/java/com/google/gerrit/server/change/AddReviewersEmail.java
+++ b/java/com/google/gerrit/server/change/ModifyReviewersEmail.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.SendEmailExecutor;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
+import com.google.gerrit.server.mail.send.ModifyReviewerSender;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -33,16 +33,16 @@
 import java.util.concurrent.Future;
 
 @Singleton
-public class AddReviewersEmail {
+public class ModifyReviewersEmail {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final ModifyReviewerSender.Factory addReviewerSenderFactory;
   private final ExecutorService sendEmailsExecutor;
   private final MessageIdGenerator messageIdGenerator;
 
   @Inject
-  AddReviewersEmail(
-      AddReviewerSender.Factory addReviewerSenderFactory,
+  ModifyReviewersEmail(
+      ModifyReviewerSender.Factory addReviewerSenderFactory,
       @SendEmailExecutor ExecutorService sendEmailsExecutor,
       MessageIdGenerator messageIdGenerator) {
     this.addReviewerSenderFactory = addReviewerSenderFactory;
@@ -55,19 +55,25 @@
       Change change,
       Collection<Account.Id> added,
       Collection<Account.Id> copied,
+      Collection<Account.Id> removed,
       Collection<Address> addedByEmail,
       Collection<Address> copiedByEmail,
+      Collection<Address> removedByEmail,
       NotifyResolver.Result notify) {
-    // The user knows they added themselves, don't bother emailing them.
+    // The user knows they added/removed themselves, don't bother emailing them.
     Account.Id userId = user.getAccountId();
     ImmutableList<Account.Id> immutableToMail =
         added.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
     ImmutableList<Account.Id> immutableToCopy =
         copied.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
+    ImmutableList<Account.Id> immutableToRemove =
+        removed.stream().filter(id -> !id.equals(userId)).collect(toImmutableList());
     if (immutableToMail.isEmpty()
         && immutableToCopy.isEmpty()
+        && immutableToRemove.isEmpty()
         && addedByEmail.isEmpty()
-        && copiedByEmail.isEmpty()) {
+        && copiedByEmail.isEmpty()
+        && removedByEmail.isEmpty()) {
       return;
     }
 
@@ -77,13 +83,14 @@
     Project.NameKey projectNameKey = change.getProject();
     ImmutableList<Address> immutableAddedByEmail = ImmutableList.copyOf(addedByEmail);
     ImmutableList<Address> immutableCopiedByEmail = ImmutableList.copyOf(copiedByEmail);
+    ImmutableList<Address> immutableRemovedByEmail = ImmutableList.copyOf(removedByEmail);
 
     @SuppressWarnings("unused")
     Future<?> possiblyIgnoredError =
         sendEmailsExecutor.submit(
             () -> {
               try {
-                AddReviewerSender emailSender =
+                ModifyReviewerSender emailSender =
                     addReviewerSenderFactory.create(projectNameKey, cId);
                 emailSender.setNotify(notify);
                 emailSender.setFrom(userId);
@@ -91,6 +98,8 @@
                 emailSender.addReviewersByEmail(immutableAddedByEmail);
                 emailSender.addExtraCC(immutableToCopy);
                 emailSender.addExtraCCByEmail(immutableCopiedByEmail);
+                emailSender.addRemovedReviewers(immutableToRemove);
+                emailSender.addRemovedByEmailReviewers(immutableRemovedByEmail);
                 emailSender.setMessageId(
                     messageIdGenerator.fromChangeUpdate(
                         change.getProject(), change.currentPatchSetId()));
diff --git a/java/com/google/gerrit/server/change/PatchSetInserter.java b/java/com/google/gerrit/server/change/PatchSetInserter.java
index d2bf3fe..209901d 100644
--- a/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -23,18 +23,17 @@
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
@@ -53,7 +52,7 @@
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -105,12 +104,13 @@
   private boolean allowClosed;
   private boolean sendEmail = true;
   private String topic;
+  private boolean storeCopiedVotes = true;
 
   // Fields set during some phase of BatchUpdate.Op.
   private Change change;
   private PatchSet patchSet;
   private PatchSetInfo patchSetInfo;
-  private ChangeMessage changeMessage;
+  private String mailMessage;
   private ReviewerSet oldReviewers;
   private boolean oldWorkInProgressState;
 
@@ -204,6 +204,17 @@
     return this;
   }
 
+  /**
+   * We always want to store copied votes except when the change is getting submitted and a new
+   * patch-set is created on submit (using submit strategies such as "REBASE_ALWAYS"). In such
+   * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
+   * should not also store the copied votes.
+   */
+  public PatchSetInserter setStoreCopiedVotes(boolean storeCopiedVotes) {
+    this.storeCopiedVotes = storeCopiedVotes;
+    return this;
+  }
+
   public Change getChange() {
     checkState(change != null, "getChange() only valid after executing update");
     return change;
@@ -260,14 +271,9 @@
     }
 
     if (message != null) {
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              patchSet.id(),
-              ctx.getUser(),
-              ctx.getWhen(),
-              message,
-              ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
-      changeMessage.setMessage(message);
+      mailMessage =
+          cmUtil.setChangeMessage(
+              update, message, ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
     }
 
     oldWorkInProgressState = change.isWorkInProgress();
@@ -283,9 +289,6 @@
       change.setStatus(Change.Status.NEW);
     }
     change.setCurrentPatchSet(patchSetInfo);
-    if (changeMessage != null) {
-      cmUtil.addChangeMessage(update, changeMessage);
-    }
     if (topic != null) {
       change.setTopic(topic);
       try {
@@ -294,20 +297,27 @@
         throw new BadRequestException(ex.getMessage());
       }
     }
+
+    // Approvals that are being set in the new patch-set during this operation are not available yet
+    // outside of the scope of this method. Only copied approvals are set here.
+    if (storeCopiedVotes) {
+      approvalsUtil.byPatchSet(ctx.getNotes(), patchSet).forEach(a -> update.putCopiedApproval(a));
+    }
+
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (notify.shouldNotify() && sendEmail) {
-      requireNonNull(changeMessage);
+      requireNonNull(mailMessage);
       try {
         ReplacePatchSetSender emailSender =
             replacePatchSetFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
         emailSender.setPatchSet(patchSet, patchSetInfo);
-        emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.addReviewers(oldReviewers.byState(REVIEWER));
         emailSender.addExtraCC(oldReviewers.byState(CC));
         emailSender.setNotify(notify);
@@ -321,11 +331,12 @@
     }
 
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
+      revisionCreated.fire(
+          ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen(), notify);
     }
 
     if (workInProgress != null && oldWorkInProgressState != workInProgress) {
-      wipStateChanged.fire(change, patchSet, ctx.getAccount(), ctx.getWhen());
+      wipStateChanged.fire(ctx.getChangeData(change), patchSet, ctx.getAccount(), ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/RebaseChangeOp.java b/java/com/google/gerrit/server/change/RebaseChangeOp.java
index b43996e..3e67cca 100644
--- a/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -41,7 +41,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -92,6 +92,7 @@
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
   private boolean sendEmail = true;
+  private boolean storeCopiedVotes = true;
   private boolean matchAuthorToCommitterDate = false;
 
   private CodeReviewCommit rebasedCommit;
@@ -169,6 +170,17 @@
     return this;
   }
 
+  /**
+   * We always want to store copied votes except when the change is getting submitted and a new
+   * patch-set is created on submit (using submit strategies such as "REBASE_ALWAYS"). In such
+   * cases, we already store the votes of the new patch-sets in SubmitStrategyOp#saveApprovals. We
+   * should not also store the copied votes.
+   */
+  public RebaseChangeOp setStoreCopiedVotes(boolean storeCopiedVotes) {
+    this.storeCopiedVotes = storeCopiedVotes;
+    return this;
+  }
+
   public RebaseChangeOp setSendEmail(boolean sendEmail) {
     this.sendEmail = sendEmail;
     return this;
@@ -219,7 +231,10 @@
             .setFireRevisionCreated(fireRevisionCreated)
             .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
             .setValidate(validate)
-            .setSendEmail(sendEmail);
+            .setSendEmail(sendEmail)
+            // The votes are automatically copied and they don't count as copied votes. See
+            // method's javadoc.
+            .setStoreCopiedVotes(storeCopiedVotes);
 
     if (!rebasedCommit.getFilesWithGitConflicts().isEmpty()
         && !notes.getChange().isWorkInProgress()) {
@@ -240,6 +255,8 @@
         patchSetInserter.setGroups(GroupCollector.getDefaultGroups(rebasedCommit));
       }
     }
+
+    ctx.getRevWalk().getObjectReader().getCreatedFromInserter().flush();
     patchSetInserter.updateRepo(ctx);
   }
 
@@ -270,7 +287,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     patchSetInserter.postUpdate(ctx);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
similarity index 95%
rename from java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
rename to java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 1d550f1..547452e 100644
--- a/java/com/google/gerrit/server/restapi/change/RelatedChangesSorter.java
+++ b/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -11,14 +11,14 @@
 // WITHOUT 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;
+package com.google.gerrit.server.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.toMap;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ListMultimap;
@@ -29,6 +29,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -56,7 +57,7 @@
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-class RelatedChangesSorter {
+public class RelatedChangesSorter {
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final ProjectCache projectCache;
@@ -246,28 +247,29 @@
   }
 
   @AutoValue
-  abstract static class PatchSetData {
+  public abstract static class PatchSetData {
     @VisibleForTesting
     static PatchSetData create(ChangeData cd, PatchSet ps, RevCommit commit) {
       return new AutoValue_RelatedChangesSorter_PatchSetData(cd, ps, commit);
     }
 
-    abstract ChangeData data();
+    public abstract ChangeData data();
 
-    abstract PatchSet patchSet();
+    public abstract PatchSet patchSet();
 
-    abstract RevCommit commit();
+    public abstract RevCommit commit();
 
-    PatchSet.Id psId() {
+    public PatchSet.Id psId() {
       return patchSet().id();
     }
 
-    Change.Id id() {
+    public Change.Id id() {
       return psId().changeId();
     }
 
+    @Memoized
     @Override
-    public final int hashCode() {
+    public int hashCode() {
       return Objects.hash(patchSet().id(), commit());
     }
 
diff --git a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
index e532409..50ee9d4 100644
--- a/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
+++ b/java/com/google/gerrit/server/change/RemoveFromAttentionSetOp.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.AttentionSetEmail;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -101,7 +101,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!notify) {
       return;
     }
diff --git a/java/com/google/gerrit/server/change/ReviewerJson.java b/java/com/google/gerrit/server/change/ReviewerJson.java
index 761b57d..6189708 100644
--- a/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -27,8 +27,8 @@
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.permissions.LabelPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -38,6 +38,7 @@
 import com.google.inject.Singleton;
 import java.util.Collection;
 import java.util.List;
+import java.util.Optional;
 import java.util.TreeMap;
 
 @Singleton
@@ -94,7 +95,7 @@
         out,
         reviewerAccountId,
         cd,
-        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId, null, null));
+        approvalsUtil.byPatchSetUser(cd.notes(), psId, reviewerAccountId));
   }
 
   public ReviewerInfo format(
@@ -107,10 +108,8 @@
 
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      LabelType at = labelTypes.byLabel(ca.labelId());
-      if (at != null) {
-        out.approvals.put(at.getName(), formatValue(ca.value()));
-      }
+      Optional<LabelType> at = labelTypes.byLabel(ca.labelId());
+      at.ifPresent(lt -> out.approvals.put(lt.getName(), formatValue(ca.value())));
     }
 
     // Add dummy approvals for all permitted labels for the user even if they
@@ -125,13 +124,13 @@
         }
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
-          LabelType type = labelTypes.byLabel(name);
-          if (out.approvals.containsKey(name) || type == null) {
+          Optional<LabelType> type = labelTypes.byLabel(name);
+          if (out.approvals.containsKey(name) || !type.isPresent()) {
             continue;
           }
 
           try {
-            perm.check(new LabelPermission(type));
+            perm.check(new LabelPermission(type.get()));
             out.approvals.put(name, formatValue((short) 0));
           } catch (AuthException e) {
             // Do nothing.
diff --git a/java/com/google/gerrit/server/change/ReviewerAdder.java b/java/com/google/gerrit/server/change/ReviewerModifier.java
similarity index 69%
rename from java/com/google/gerrit/server/change/ReviewerAdder.java
rename to java/com/google/gerrit/server/change/ReviewerModifier.java
index 5d55b4d..fffb107 100644
--- a/java/com/google/gerrit/server/change/ReviewerAdder.java
+++ b/java/com/google/gerrit/server/change/ReviewerModifier.java
@@ -19,12 +19,14 @@
 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.REMOVED;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.util.Comparator.comparing;
 import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Streams;
@@ -39,10 +41,11 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -69,7 +72,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -85,7 +88,7 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
-public class ReviewerAdder {
+public class ReviewerModifier {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
@@ -102,9 +105,9 @@
   }
 
   // TODO(dborowitz): Subclassing is not the right way to do this. We should instead use an internal
-  // type in the public interfaces of ReviewerAdder, rather than passing around the REST API type
+  // type in the public interfaces of ReviewerModifier, rather than passing around the REST API type
   // internally.
-  public static class InternalAddReviewerInput extends AddReviewerInput {
+  public static class InternalReviewerInput extends ReviewerInput {
     /**
      * Behavior when identifying reviewers fails for any reason <em>besides</em> the input not
      * resolving to an account/group/email.
@@ -112,22 +115,16 @@
     public FailureBehavior otherFailureBehavior = FailureBehavior.FAIL;
   }
 
-  public static InternalAddReviewerInput newAddReviewerInput(
-      Account.Id reviewer, ReviewerState state, NotifyHandling notify) {
-    // AccountResolver always resolves by ID if the input string is numeric.
-    return newAddReviewerInput(reviewer.toString(), state, notify);
-  }
-
-  public static InternalAddReviewerInput newAddReviewerInput(
+  public static InternalReviewerInput newReviewerInput(
       String reviewer, ReviewerState state, NotifyHandling notify) {
-    InternalAddReviewerInput in = new InternalAddReviewerInput();
+    InternalReviewerInput in = new InternalReviewerInput();
     in.reviewer = reviewer;
     in.state = state;
     in.notify = notify;
     return in;
   }
 
-  public static Optional<InternalAddReviewerInput> newAddReviewerInputFromCommitIdentity(
+  public static Optional<InternalReviewerInput> newReviewerInputFromCommitIdentity(
       Change change,
       ObjectId commitId,
       @Nullable Account.Id accountId,
@@ -142,7 +139,7 @@
         "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();
+    InternalReviewerInput in = new InternalReviewerInput();
     in.reviewer = accountId.toString();
     in.state = CC;
     in.notify = notify;
@@ -161,9 +158,11 @@
   private final Provider<AnonymousUser> anonymousProvider;
   private final AddReviewersOp.Factory addReviewersOpFactory;
   private final OutgoingEmailValidator validator;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
-  ReviewerAdder(
+  ReviewerModifier(
       AccountResolver accountResolver,
       PermissionBackend permissionBackend,
       GroupResolver groupResolver,
@@ -174,7 +173,9 @@
       ProjectCache projectCache,
       Provider<AnonymousUser> anonymousProvider,
       AddReviewersOp.Factory addReviewersOpFactory,
-      OutgoingEmailValidator validator) {
+      OutgoingEmailValidator validator,
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
     this.accountResolver = accountResolver;
     this.permissionBackend = permissionBackend;
     this.groupResolver = groupResolver;
@@ -186,10 +187,12 @@
     this.anonymousProvider = anonymousProvider;
     this.addReviewersOpFactory = addReviewersOpFactory;
     this.validator = validator;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   /**
-   * Prepare application of a single {@link AddReviewerInput}.
+   * Prepare application of a single {@link ReviewerInput}.
    *
    * @param notes change notes.
    * @param user user performing the reviewer addition.
@@ -198,12 +201,9 @@
    * @return handle describing the addition operation. If the {@code op} field is present, this
    *     operation may be added to a {@code BatchUpdate}. Otherwise, the {@code error} field
    *     contains information about an error that occurred
-   * @throws IOException
-   * @throws PermissionBackendException
-   * @throws ConfigInvalidException
    */
-  public ReviewerAddition prepare(
-      ChangeNotes notes, CurrentUser user, AddReviewerInput input, boolean allowGroup)
+  public ReviewerModification prepare(
+      ChangeNotes notes, CurrentUser user, ReviewerInput input, boolean allowGroup)
       throws IOException, PermissionBackendException, ConfigInvalidException {
     try (TraceContext.TraceTimer ignored =
         TraceContext.newTimer(getClass().getSimpleName() + "#prepare", Metadata.empty())) {
@@ -215,9 +215,9 @@
               .orElseThrow(illegalState(notes.getProjectName()))
               .is(BooleanProjectConfig.ENABLE_REVIEWER_BY_EMAIL);
 
-      ReviewerAddition byAccountId = addByAccountId(input, notes, user);
+      ReviewerModification byAccountId = byAccountId(input, notes, user);
 
-      ReviewerAddition wholeGroup = null;
+      ReviewerModification wholeGroup = null;
       if (!byAccountId.exactMatchFound) {
         wholeGroup = addWholeGroup(input, notes, user, confirmed, allowGroup, allowByEmail);
         if (wholeGroup != null && wholeGroup.exactMatchFound) {
@@ -245,25 +245,24 @@
     }
   }
 
-  public ReviewerAddition ccCurrentUser(CurrentUser user, RevisionResource revision) {
-    return new ReviewerAddition(
-        newAddReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
+  public ReviewerModification ccCurrentUser(CurrentUser user, RevisionResource revision) {
+    return new ReviewerModification(
+        newReviewerInput(user.getUserName().orElse(null), CC, NotifyHandling.NONE),
         revision.getNotes(),
         revision.getUser(),
-        ImmutableSet.of(user.getAccountId()),
+        ImmutableSet.of(user.asIdentifiedUser().getAccount()),
         null,
         true,
         false);
   }
 
   @Nullable
-  private ReviewerAddition addByAccountId(
-      AddReviewerInput input, ChangeNotes notes, CurrentUser user)
+  private ReviewerModification byAccountId(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException, IOException, ConfigInvalidException {
     IdentifiedUser reviewerUser;
     boolean exactMatchFound = false;
     try {
-      reviewerUser = accountResolver.resolve(input.reviewer).asUniqueUser();
+      reviewerUser = accountResolver.resolveIncludeInactive(input.reviewer).asUniqueUser();
       if (input.reviewer.equalsIgnoreCase(reviewerUser.getName())
           || input.reviewer.equals(String.valueOf(reviewerUser.getAccountId()))) {
         exactMatchFound = true;
@@ -275,11 +274,11 @@
     }
 
     if (isValidReviewer(notes.getChange().getDest(), reviewerUser.getAccount())) {
-      return new ReviewerAddition(
+      return new ReviewerModification(
           input,
           notes,
           user,
-          ImmutableSet.of(reviewerUser.getAccountId()),
+          ImmutableSet.of(reviewerUser.getAccount()),
           null,
           exactMatchFound,
           false);
@@ -291,8 +290,8 @@
   }
 
   @Nullable
-  private ReviewerAddition addWholeGroup(
-      AddReviewerInput input,
+  private ReviewerModification addWholeGroup(
+      ReviewerInput input,
       ChangeNotes notes,
       CurrentUser user,
       boolean confirmed,
@@ -325,7 +324,14 @@
           MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
     }
 
-    Set<Account.Id> reviewers = new HashSet<>();
+    if (input.state().equals(REMOVED)) {
+      return fail(
+          input,
+          FailureType.OTHER,
+          MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, group.getName()));
+    }
+
+    Set<Account> reviewers = new HashSet<>();
     Set<Account> members;
     try {
       members = groupMembers.listAccounts(group.getGroupUUID(), notes.getProjectName());
@@ -362,15 +368,15 @@
 
     for (Account member : members) {
       if (isValidReviewer(notes.getChange().getDest(), member)) {
-        reviewers.add(member.id());
+        reviewers.add(member);
       }
     }
 
-    return new ReviewerAddition(input, notes, user, reviewers, null, true, true);
+    return new ReviewerModification(input, notes, user, reviewers, null, true, true);
   }
 
   @Nullable
-  private ReviewerAddition addByEmail(AddReviewerInput input, ChangeNotes notes, CurrentUser user)
+  private ReviewerModification addByEmail(ReviewerInput input, ChangeNotes notes, CurrentUser user)
       throws PermissionBackendException {
     try {
       permissionBackend.user(anonymousProvider.get()).change(notes).check(ChangePermission.READ);
@@ -388,7 +394,7 @@
           FailureType.NOT_FOUND,
           MessageFormat.format(ChangeMessages.get().reviewerInvalid, input.reviewer));
     }
-    return new ReviewerAddition(input, notes, user, null, ImmutableList.of(adr), true, false);
+    return new ReviewerModification(input, notes, user, null, ImmutableList.of(adr), true, false);
   }
 
   private boolean isValidReviewer(BranchNameKey branch, Account member)
@@ -404,32 +410,32 @@
     }
   }
 
-  private ReviewerAddition fail(AddReviewerInput input, FailureType failureType, String error) {
+  private ReviewerModification fail(ReviewerInput input, FailureType failureType, String error) {
     return fail(input, failureType, false, error);
   }
 
-  private ReviewerAddition fail(
-      AddReviewerInput input, FailureType failureType, boolean confirm, String error) {
-    ReviewerAddition addition = new ReviewerAddition(input, failureType);
+  private ReviewerModification fail(
+      ReviewerInput input, FailureType failureType, boolean confirm, String error) {
+    ReviewerModification addition = new ReviewerModification(input, failureType);
     addition.result.confirm = confirm ? true : null;
     addition.result.error = error;
     return addition;
   }
 
-  public class ReviewerAddition {
-    public final AddReviewerResult result;
-    @Nullable public final AddReviewersOp op;
-    public final ImmutableSet<Account.Id> reviewers;
+  public class ReviewerModification {
+    public final ReviewerResult result;
+    @Nullable public final ReviewerOp op;
+    public final ImmutableSet<Account> reviewers;
     public final ImmutableSet<Address> reviewersByEmail;
     @Nullable final IdentifiedUser caller;
     final boolean exactMatchFound;
-    private final AddReviewerInput input;
+    private final ReviewerInput input;
     @Nullable private final FailureType failureType;
 
-    private ReviewerAddition(AddReviewerInput input, FailureType failureType) {
+    private ReviewerModification(ReviewerInput input, FailureType failureType) {
       this.input = input;
       this.failureType = requireNonNull(failureType);
-      result = new AddReviewerResult(input.reviewer);
+      result = new ReviewerResult(input.reviewer);
       op = null;
       reviewers = ImmutableSet.of();
       reviewersByEmail = ImmutableSet.of();
@@ -437,11 +443,11 @@
       exactMatchFound = false;
     }
 
-    private ReviewerAddition(
-        AddReviewerInput input,
+    private ReviewerModification(
+        ReviewerInput input,
         ChangeNotes notes,
         CurrentUser caller,
-        @Nullable Iterable<Account.Id> reviewers,
+        @Nullable Iterable<Account> reviewers,
         @Nullable Iterable<Address> reviewersByEmail,
         boolean exactMatchFound,
         boolean forGroup) {
@@ -451,21 +457,47 @@
 
       this.input = input;
       this.failureType = null;
-      result = new AddReviewerResult(input.reviewer);
+      result = new ReviewerResult(input.reviewer);
       // Always silently ignore adding the owner as any type of reviewer on their own change. They
       // may still be implicitly added as a reviewer if they vote, but not via the reviewer API.
       this.reviewers = omitOwner(notes, reviewers);
       this.reviewersByEmail =
           reviewersByEmail == null ? ImmutableSet.of() : ImmutableSet.copyOf(reviewersByEmail);
       this.caller = caller.asIdentifiedUser();
-      op = addReviewersOpFactory.create(this.reviewers, this.reviewersByEmail, state(), forGroup);
+      if (state().equals(REMOVED)) {
+        // only one is set.
+        checkState(
+            (this.reviewers.size() == 1 && this.reviewersByEmail.isEmpty())
+                || (this.reviewers.isEmpty() && this.reviewersByEmail.size() == 1));
+        if (this.reviewers.size() >= 1) {
+          checkState(this.reviewers.size() == 1);
+          DeleteReviewerInput deleteReviewerInput = new DeleteReviewerInput();
+          deleteReviewerInput.notify = input.notify;
+          deleteReviewerInput.notifyDetails = input.notifyDetails;
+          op =
+              deleteReviewerOpFactory.create(
+                  Iterables.getOnlyElement(this.reviewers.asList()), deleteReviewerInput);
+        } else {
+          checkState(this.reviewersByEmail.size() == 1);
+          op =
+              deleteReviewerByEmailOpFactory.create(
+                  Iterables.getOnlyElement(this.reviewersByEmail.asList()));
+        }
+      } else {
+        op =
+            addReviewersOpFactory.create(
+                this.reviewers.stream().map(Account::id).collect(toImmutableSet()),
+                this.reviewersByEmail,
+                state(),
+                forGroup);
+      }
       this.exactMatchFound = exactMatchFound;
     }
 
-    private ImmutableSet<Account.Id> omitOwner(ChangeNotes notes, Iterable<Account.Id> reviewers) {
+    private ImmutableSet<Account> omitOwner(ChangeNotes notes, Iterable<Account> reviewers) {
       return reviewers != null
           ? Streams.stream(reviewers)
-              .filter(id -> !id.equals(notes.getChange().getOwner()))
+              .filter(account -> !account.id().equals(notes.getChange().getOwner()))
               .collect(toImmutableSet())
           : ImmutableSet.of();
     }
@@ -476,31 +508,52 @@
 
       // Generate result details and fill AccountLoader. This occurs outside
       // the Op because the accounts are in a different table.
-      AddReviewersOp.Result opResult = op.getResult();
-      if (state() == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
-        for (Account.Id accountId : opResult.addedCCs()) {
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
-        }
-        accountLoaderFactory.create(true).fill(result.ccs);
-        for (Address a : opResult.addedCCsByEmail()) {
-          result.ccs.add(new AccountInfo(a.name(), a.email()));
-        }
-      } else {
-        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
-        for (PatchSetApproval psa : opResult.addedReviewers()) {
-          // New reviewers have value 0, don't bother normalizing.
-          result.reviewers.add(
-              json.format(
-                  new ReviewerInfo(psa.accountId().get()),
-                  psa.accountId(),
-                  cd,
-                  ImmutableList.of(psa)));
-        }
-        accountLoaderFactory.create(true).fill(result.reviewers);
-        for (Address a : opResult.addedReviewersByEmail()) {
-          result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
-        }
+      ReviewerOp.Result opResult = op.getResult();
+      switch (state()) {
+        case CC:
+          result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+          for (Account.Id accountId : opResult.addedCCs()) {
+            result.ccs.add(json.format(new ReviewerInfo(accountId.get()), accountId, cd));
+          }
+          accountLoaderFactory.create(true).fill(result.ccs);
+          for (Address a : opResult.addedCCsByEmail()) {
+            result.ccs.add(new AccountInfo(a.name(), a.email()));
+          }
+          break;
+        case REVIEWER:
+          result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+          for (PatchSetApproval psa : opResult.addedReviewers()) {
+            // New reviewers have value 0, don't bother normalizing.
+            result.reviewers.add(
+                json.format(
+                    new ReviewerInfo(psa.accountId().get()),
+                    psa.accountId(),
+                    cd,
+                    ImmutableList.of(psa)));
+          }
+          accountLoaderFactory.create(true).fill(result.reviewers);
+          for (Address a : opResult.addedReviewersByEmail()) {
+            result.reviewers.add(ReviewerInfo.byEmail(a.name(), a.email()));
+          }
+          break;
+        case REMOVED:
+          if (opResult.deletedReviewer().isPresent()) {
+            result.removed =
+                json.format(
+                    new ReviewerInfo(opResult.deletedReviewer().get().get()),
+                    opResult.deletedReviewer().get(),
+                    cd);
+            accountLoaderFactory.create(true).fill(ImmutableList.of(result.removed));
+          } else if (opResult.deletedReviewerByEmail().isPresent()) {
+            result.removed =
+                new AccountInfo(
+                    opResult.deletedReviewerByEmail().get().name(),
+                    opResult.deletedReviewerByEmail().get().email());
+          }
+          break;
+        default:
+          throw new IllegalStateException(
+              String.format("Illegal ReviewerState argument is %s", state().name()));
       }
     }
 
@@ -515,8 +568,8 @@
     public boolean isIgnorableFailure() {
       checkState(failureType != null);
       FailureBehavior behavior =
-          (input instanceof InternalAddReviewerInput)
-              ? ((InternalAddReviewerInput) input).otherFailureBehavior
+          (input instanceof InternalReviewerInput)
+              ? ((InternalReviewerInput) input).otherFailureBehavior
               : FailureBehavior.FAIL;
       return failureType == FailureType.OTHER && behavior == FailureBehavior.IGNORE;
     }
@@ -526,10 +579,10 @@
     return !SystemGroupBackend.isSystemGroup(groupUUID);
   }
 
-  public ReviewerAdditionList prepare(
+  public ReviewerModificationList prepare(
       ChangeNotes notes,
       CurrentUser user,
-      Iterable<? extends AddReviewerInput> inputs,
+      Iterable<? extends ReviewerInput> inputs,
       boolean allowGroup)
       throws IOException, PermissionBackendException, ConfigInvalidException {
     // Process CC ops before reviewer ops, so a user that appears in both lists ends up as a
@@ -539,39 +592,39 @@
     // TODO(dborowitz): Consider changing interface to allow excluding reviewers that were
     // previously processed, to proactively prevent overlap so we don't have to rely on this subtle
     // behavior.
-    ImmutableList<AddReviewerInput> sorted =
+    ImmutableList<ReviewerInput> sorted =
         Streams.stream(inputs)
             .sorted(
                 comparing(
-                    AddReviewerInput::state,
+                    ReviewerInput::state,
                     Ordering.explicit(ReviewerState.CC, ReviewerState.REVIEWER)))
             .collect(toImmutableList());
-    List<ReviewerAddition> additions = new ArrayList<>();
-    for (AddReviewerInput input : sorted) {
-      ReviewerAddition addition = prepare(notes, user, input, allowGroup);
+    List<ReviewerModification> additions = new ArrayList<>();
+    for (ReviewerInput input : sorted) {
+      ReviewerModification addition = prepare(notes, user, input, allowGroup);
       if (addition.op != null) {
         // Assume any callers preparing a list of batch insertions are handling their own email.
         addition.op.suppressEmail();
       }
       additions.add(addition);
     }
-    return new ReviewerAdditionList(additions);
+    return new ReviewerModificationList(additions);
   }
 
   // TODO(dborowitz): This class works, but ultimately feels wrong. It seems like an op but isn't
   // really an op, it's a collection of ops, and it's only called from the body of other ops. We
   // could make this class an op, but we would still have AddReviewersOp. Better would probably be
-  // to design a single op that supports combining multiple AddReviewerInputs together. That would
+  // to design a single op that supports combining multiple ReviewerInputs together. That would
   // probably also subsume the Addition class itself, which would be a good thing.
-  public static class ReviewerAdditionList {
-    private final ImmutableList<ReviewerAddition> additions;
+  public static class ReviewerModificationList {
+    private final ImmutableList<ReviewerModification> modifications;
 
-    private ReviewerAdditionList(List<ReviewerAddition> additions) {
-      this.additions = ImmutableList.copyOf(additions);
+    private ReviewerModificationList(List<ReviewerModification> modifications) {
+      this.modifications = ImmutableList.copyOf(modifications);
     }
 
-    public ImmutableList<ReviewerAddition> getFailures() {
-      return additions.stream()
+    public ImmutableList<ReviewerModification> getFailures() {
+      return modifications.stream()
           .filter(a -> a.isFailure() && !a.isIgnorableFailure())
           .collect(toImmutableList());
     }
@@ -579,15 +632,15 @@
     // We never call updateRepo on the addition ops, which is only ok because it's a no-op.
 
     public void updateChange(ChangeContext ctx, PatchSet patchSet)
-        throws RestApiException, IOException {
-      for (ReviewerAddition addition : additions()) {
+        throws RestApiException, IOException, PermissionBackendException {
+      for (ReviewerModification addition : modifications()) {
         addition.op.setPatchSet(patchSet);
         addition.op.updateChange(ctx);
       }
     }
 
-    public void postUpdate(Context ctx) throws Exception {
-      for (ReviewerAddition addition : additions()) {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
+      for (ReviewerModification addition : modifications()) {
         if (addition.op != null) {
           addition.op.postUpdate(ctx);
         }
@@ -596,20 +649,20 @@
 
     public <T> ImmutableSet<T> flattenResults(
         Function<AddReviewersOp.Result, ? extends Collection<T>> func) {
-      additions()
+      modifications()
           .forEach(
               a ->
                   checkArgument(
                       a.op != null && a.op.getResult() != null, "missing result on %s", a));
-      return additions().stream()
+      return modifications().stream()
           .map(a -> a.op.getResult())
           .map(func)
           .flatMap(Collection::stream)
           .collect(toImmutableSet());
     }
 
-    private ImmutableList<ReviewerAddition> additions() {
-      return additions.stream()
+    private ImmutableList<ReviewerModification> modifications() {
+      return modifications.stream()
           .filter(
               a -> {
                 if (a.isFailure()) {
diff --git a/java/com/google/gerrit/server/change/ReviewerOp.java b/java/com/google/gerrit/server/change/ReviewerOp.java
new file mode 100644
index 0000000..12227c2
--- /dev/null
+++ b/java/com/google/gerrit/server/change/ReviewerOp.java
@@ -0,0 +1,109 @@
+// 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 static com.google.common.base.Preconditions.checkState;
+import static java.util.Objects.requireNonNull;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import java.io.IOException;
+import java.util.Optional;
+
+public class ReviewerOp implements BatchUpdateOp {
+  protected boolean sendEmail = true;
+  protected boolean sendEvent = true;
+  protected Runnable eventSender = () -> {};
+  protected PatchSet patchSet;
+  protected Result opResult;
+
+  // TODO(dborowitz): This mutable setter is ugly, but a) it's less ugly than adding boolean args
+  // all the way through the constructor stack, and b) this class is slated to be completely
+  // rewritten.
+  public void suppressEmail() {
+    this.sendEmail = false;
+  }
+
+  public void suppressEvent() {
+    this.sendEvent = false;
+  }
+
+  public void sendEvent() {
+    eventSender.run();
+  }
+
+  void setPatchSet(PatchSet patchSet) {
+    this.patchSet = requireNonNull(patchSet);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Address> addedReviewersByEmail();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    public abstract ImmutableList<Address> addedCCsByEmail();
+
+    public abstract Optional<Account.Id> deletedReviewer();
+
+    public abstract Optional<Address> deletedReviewerByEmail();
+
+    static Builder builder() {
+      return new AutoValue_ReviewerOp_Result.Builder()
+          .setAddedReviewers(ImmutableList.of())
+          .setAddedReviewersByEmail(ImmutableList.of())
+          .setAddedCCs(ImmutableList.of())
+          .setAddedCCsByEmail(ImmutableList.of());
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(Iterable<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedReviewersByEmail(Iterable<Address> addedReviewersByEmail);
+
+      abstract Builder setAddedCCs(Iterable<Account.Id> addedCCs);
+
+      abstract Builder setAddedCCsByEmail(Iterable<Address> addedCCsByEmail);
+
+      abstract Builder setDeletedReviewerByEmail(Address deletedReviewerByEmail);
+
+      abstract Builder setDeletedReviewer(Account.Id deletedReviewer);
+
+      abstract Result build();
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, IOException, PermissionBackendException {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/RevisionJson.java b/java/com/google/gerrit/server/change/RevisionJson.java
index b702440..33f3d4f 100644
--- a/java/com/google/gerrit/server/change/RevisionJson.java
+++ b/java/com/google/gerrit/server/change/RevisionJson.java
@@ -182,9 +182,14 @@
     info.message = commit.getFullMessage();
 
     if (addLinks) {
-      ImmutableList<WebLinkInfo> links =
+      ImmutableList<WebLinkInfo> patchSetLinks =
           webLinks.getPatchSetLinks(project, commit.name(), commit.getFullMessage(), branchName);
-      info.webLinks = links.isEmpty() ? null : links;
+      info.webLinks = patchSetLinks.isEmpty() ? null : patchSetLinks;
+      ImmutableList<WebLinkInfo> resolveConflictsLinks =
+          webLinks.getResolveConflictsLinks(
+              project, commit.name(), commit.getFullMessage(), branchName);
+      info.resolveConflictsWebLinks =
+          resolveConflictsLinks.isEmpty() ? null : resolveConflictsLinks;
     }
 
     for (RevCommit parent : commit.getParents()) {
diff --git a/java/com/google/gerrit/server/change/SetAssigneeOp.java b/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 411c9b6..fd3e972 100644
--- a/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -30,7 +29,8 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -98,29 +98,27 @@
     update.setAssignee(newAssignee.getAccountId());
     // reviewdb
     change.setAssignee(newAssignee.getAccountId());
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
     if (oldAssignee == null) {
       msg.append("added: ");
-      msg.append(newAssignee.getNameEmail());
+      msg.append(AccountTemplateUtil.getAccountTemplate(newAssignee.getAccountId()));
     } else {
       msg.append("changed from: ");
-      msg.append(oldAssignee.getNameEmail());
+      msg.append(AccountTemplateUtil.getAccountTemplate(oldAssignee.getAccountId()));
       msg.append(" to: ");
-      msg.append(newAssignee.getNameEmail());
+      msg.append(AccountTemplateUtil.getAccountTemplate(newAssignee.getAccountId()));
     }
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_ASSIGNEE);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     try {
       SetAssigneeSender emailSender =
           setAssigneeSenderFactory.create(
@@ -134,6 +132,9 @@
           "Cannot send email to new assignee of change %s", change.getId());
     }
     assigneeChanged.fire(
-        change, ctx.getAccount(), oldAssignee != null ? oldAssignee.state() : null, ctx.getWhen());
+        ctx.getChangeData(change),
+        ctx.getAccount(),
+        oldAssignee != null ? oldAssignee.state() : null,
+        ctx.getWhen());
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetHashtagsOp.java b/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 712e1f3..bfc4834 100644
--- a/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -22,7 +22,6 @@
 import com.google.common.collect.Ordering;
 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.HashtagsInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -35,7 +34,7 @@
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -106,7 +105,7 @@
         updated.addAll(toAdd);
         updated.removeAll(toRemove);
         update.setHashtags(updated);
-        addMessage(ctx, update);
+        addMessage(ctx);
       }
 
       updatedHashtags = ImmutableSortedSet.copyOf(updated);
@@ -116,13 +115,11 @@
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_SET_HASHTAGS);
   }
 
   private void appendHashtagMessage(StringBuilder b, String action, Set<String> hashtags) {
@@ -144,10 +141,15 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (updated() && fireEvent) {
       hashtagsEdited.fire(
-          change, ctx.getAccount(), updatedHashtags, toAdd, toRemove, ctx.getWhen());
+          ctx.getChangeData(change),
+          ctx.getAccount(),
+          updatedHashtags,
+          toAdd,
+          toRemove,
+          ctx.getWhen());
     }
   }
 
diff --git a/java/com/google/gerrit/server/change/SetPrivateOp.java b/java/com/google/gerrit/server/change/SetPrivateOp.java
index 382a4f6..1274a5ed 100644
--- a/java/com/google/gerrit/server/change/SetPrivateOp.java
+++ b/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -17,7 +17,6 @@
 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.entities.PatchSet;
 import com.google.gerrit.extensions.common.InputWithMessage;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -83,18 +82,18 @@
     change.setPrivate(isPrivate);
     change.setLastUpdatedOn(ctx.getWhen());
     update.setPrivate(isPrivate);
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!isNoOp) {
-      privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+      privateStateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     }
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     Change c = ctx.getChange();
     StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
 
@@ -104,13 +103,9 @@
       buf.append(m);
     }
 
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(
-            ctx,
-            buf.toString(),
-            c.isPrivate()
-                ? ChangeMessagesUtil.TAG_SET_PRIVATE
-                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(
+        ctx,
+        buf.toString(),
+        c.isPrivate() ? ChangeMessagesUtil.TAG_SET_PRIVATE : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
   }
 }
diff --git a/java/com/google/gerrit/server/change/SetTopicOp.java b/java/com/google/gerrit/server/change/SetTopicOp.java
index c4a49b0..ee35d1d 100644
--- a/java/com/google/gerrit/server/change/SetTopicOp.java
+++ b/java/com/google/gerrit/server/change/SetTopicOp.java
@@ -17,14 +17,13 @@
 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.restapi.BadRequestException;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -74,16 +73,14 @@
     } catch (ValidationException ex) {
       throw new BadRequestException(ex.getMessage());
     }
-    ChangeMessage cmsg =
-        ChangeMessagesUtil.newMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
-    cmUtil.addChangeMessage(update, cmsg);
+    cmUtil.setChangeMessage(ctx, summary, ChangeMessagesUtil.TAG_SET_TOPIC);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (change != null) {
-      topicEdited.fire(change, ctx.getAccount(), oldTopicName, ctx.getWhen());
+      topicEdited.fire(ctx.getChangeData(change), ctx.getAccount(), oldTopicName, ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/change/SubmitRequirementsJson.java b/java/com/google/gerrit/server/change/SubmitRequirementsJson.java
new file mode 100644
index 0000000..8eeec62
--- /dev/null
+++ b/java/com/google/gerrit/server/change/SubmitRequirementsJson.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.change;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.SubmitRequirementExpressionInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+
+/**
+ * Produces submit requirements related entities like {@link SubmitRequirementResultInfo}s, which
+ * are serialized to JSON afterwards.
+ */
+public class SubmitRequirementsJson {
+  private SubmitRequirementsJson() {}
+
+  public static SubmitRequirementResultInfo toInfo(
+      SubmitRequirement req, SubmitRequirementResult result) {
+    SubmitRequirementResultInfo info = new SubmitRequirementResultInfo();
+    info.name = req.name();
+    info.description = req.description().orElse(null);
+    if (req.applicabilityExpression().isPresent()) {
+      info.applicabilityExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.applicabilityExpression().get(), result.applicabilityExpressionResult().get());
+    }
+    if (req.overrideExpression().isPresent()) {
+      info.overrideExpressionResult =
+          submitRequirementExpressionToInfo(
+              req.overrideExpression().get(), result.overrideExpressionResult().get());
+    }
+    info.submittabilityExpressionResult =
+        submitRequirementExpressionToInfo(
+            req.submittabilityExpression(), result.submittabilityExpressionResult());
+    info.status = SubmitRequirementResultInfo.Status.valueOf(result.status().toString());
+    info.isLegacy = result.isLegacy();
+    return info;
+  }
+
+  private static SubmitRequirementExpressionInfo submitRequirementExpressionToInfo(
+      SubmitRequirementExpression expression, SubmitRequirementExpressionResult result) {
+    SubmitRequirementExpressionInfo info = new SubmitRequirementExpressionInfo();
+    info.expression = expression.expressionString();
+    info.fulfilled = result.status().equals(SubmitRequirementExpressionResult.Status.PASS);
+    info.passingAtoms = result.passingAtoms();
+    info.failingAtoms = result.failingAtoms();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/server/change/WorkInProgressOp.java b/java/com/google/gerrit/server/change/WorkInProgressOp.java
index f0ebb80..1409170 100644
--- a/java/com/google/gerrit/server/change/WorkInProgressOp.java
+++ b/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -30,7 +29,7 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -65,7 +64,7 @@
   private Change change;
   private ChangeNotes notes;
   private PatchSet ps;
-  private ChangeMessage cmsg;
+  private String mailMessage;
 
   @Inject
   WorkInProgressOp(
@@ -99,11 +98,11 @@
     }
     change.setLastUpdatedOn(ctx.getWhen());
     update.setWorkInProgress(workInProgress);
-    addMessage(ctx, update);
+    addMessage(ctx);
     return true;
   }
 
-  private void addMessage(ChangeContext ctx, ChangeUpdate update) {
+  private void addMessage(ChangeContext ctx) {
     Change c = ctx.getChange();
     StringBuilder buf =
         new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
@@ -114,20 +113,18 @@
       buf.append(m);
     }
 
-    cmsg =
-        ChangeMessagesUtil.newMessage(
+    mailMessage =
+        cmUtil.setChangeMessage(
             ctx,
             buf.toString(),
             c.isWorkInProgress()
                 ? ChangeMessagesUtil.TAG_SET_WIP
                 : ChangeMessagesUtil.TAG_SET_READY);
-
-    cmUtil.addChangeMessage(update, cmsg);
   }
 
   @Override
-  public void postUpdate(Context ctx) {
-    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+  public void postUpdate(PostUpdateContext ctx) {
+    stateChanged.fire(ctx.getChangeData(change), ps, ctx.getAccount(), ctx.getWhen());
     NotifyResolver.Result notify = ctx.getNotify(change.getId());
     if (workInProgress
         || notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) < 0
@@ -147,9 +144,10 @@
             notes,
             ps,
             ctx.getIdentifiedUser(),
-            cmsg,
+            mailMessage,
+            ctx.getWhen(),
             ImmutableList.of(),
-            cmsg.getMessage(),
+            mailMessage,
             ImmutableList.of(),
             repoView)
         .sendAsync();
diff --git a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
index e12b538..5be41d4 100644
--- a/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
+++ b/java/com/google/gerrit/server/comment/CommentContextCacheImpl.java
@@ -99,25 +99,26 @@
       Iterable<CommentContextKey> inputKeys) {
     ImmutableMap.Builder<CommentContextKey, CommentContext> result = ImmutableMap.builder();
 
-    List<CommentContextKey> adjustedKeys =
+    // We do two transformations to the input keys: first we adjust the max context padding, and
+    // second we hash the file path. The transformed keys are used to request context from the
+    // cache. Keeping a map of the original inputKeys to the transformed keys
+    Map<CommentContextKey, CommentContextKey> inputKeysToCacheKeys =
         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()));
+                    k ->
+                        adjustMaxContextPadding(k)
+                            .toBuilder()
+                            .path(Loader.hashPath(k.path()))
+                            .build()));
 
     try {
       ImmutableMap<CommentContextKey, CommentContext> allContext =
-          contextCache.getAll(keysToCacheKeys.values());
+          contextCache.getAll(inputKeysToCacheKeys.values());
 
       for (CommentContextKey inputKey : inputKeys) {
-        CommentContextKey cacheKey = keysToCacheKeys.get(adjustMaxContextPadding(inputKey));
+        CommentContextKey cacheKey = inputKeysToCacheKeys.get(inputKey);
         result.put(inputKey, allContext.get(cacheKey));
       }
       return result.build();
@@ -255,14 +256,17 @@
       List<HumanComment> allComments =
           Streams.concat(humanComments.stream(), drafts.stream()).collect(Collectors.toList());
       CommentContextLoader loader = factory.create(project);
-      Map<ContextInput, CommentContextKey> commentsToKeys = new HashMap<>();
+      Map<CommentContextKey, ContextInput> keysToComments = new HashMap<>();
       for (CommentContextKey key : keys) {
         Comment comment = getCommentForKey(allComments, key);
-        commentsToKeys.put(ContextInput.fromComment(comment, key.contextPadding()), key);
+        keysToComments.put(key, ContextInput.fromComment(comment, key.contextPadding()));
       }
-      Map<ContextInput, CommentContext> allContext = loader.getContext(commentsToKeys.keySet());
-      return allContext.entrySet().stream()
-          .collect(Collectors.toMap(e -> commentsToKeys.get(e.getKey()), Map.Entry::getValue));
+      Map<ContextInput, CommentContext> allContext =
+          loader.getContext(
+              keysToComments.values().stream().distinct().collect(Collectors.toList()));
+      return keys.stream()
+          .collect(
+              Collectors.toMap(Function.identity(), k -> allContext.get(keysToComments.get(k))));
     }
 
     /**
diff --git a/java/com/google/gerrit/server/comment/CommentContextLoader.java b/java/com/google/gerrit/server/comment/CommentContextLoader.java
index a5aca48..8fbb259 100644
--- a/java/com/google/gerrit/server/comment/CommentContextLoader.java
+++ b/java/com/google/gerrit/server/comment/CommentContextLoader.java
@@ -46,6 +46,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
@@ -101,7 +103,16 @@
     try (Repository repo = repoManager.openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
       for (ObjectId commitId : commentsByCommitId.keySet()) {
-        RevCommit commit = rw.parseCommit(commitId);
+        RevCommit commit;
+        try {
+          commit = rw.parseCommit(commitId);
+        } catch (IncorrectObjectTypeException | MissingObjectException e) {
+          logger.atWarning().log("Commit %s is missing or has an incorrect object type", commitId);
+          commentsByCommitId
+              .get(commitId)
+              .forEach(contextInput -> result.put(contextInput, CommentContext.empty()));
+          continue;
+        }
         for (ContextInput contextInput : commentsByCommitId.get(commitId)) {
           Optional<Range> range = getStartAndEndLines(contextInput);
           if (!range.isPresent()) {
diff --git a/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
new file mode 100644
index 0000000..27ae41f
--- /dev/null
+++ b/java/com/google/gerrit/server/config/AllProjectsConfigProvider.java
@@ -0,0 +1,8 @@
+package com.google.gerrit.server.config;
+
+import java.util.Optional;
+import org.eclipse.jgit.lib.StoredConfig;
+
+public interface AllProjectsConfigProvider {
+  Optional<StoredConfig> get(AllProjectsName allProjectsName);
+}
diff --git a/java/com/google/gerrit/server/config/AuthConfig.java b/java/com/google/gerrit/server/config/AuthConfig.java
index de57d04..b6ffcee 100644
--- a/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/java/com/google/gerrit/server/config/AuthConfig.java
@@ -64,6 +64,8 @@
   private final boolean cookieSecure;
   private final SignedToken emailReg;
   private final boolean allowRegisterNewEmail;
+  private final boolean userNameCaseInsensitive;
+  private final boolean userNameCaseInsensitiveMigrationMode;
   private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
@@ -95,6 +97,9 @@
     useContributorAgreements = cfg.getBoolean("auth", "contributoragreements", false);
     userNameToLowerCase = cfg.getBoolean("auth", "userNameToLowerCase", false);
     allowRegisterNewEmail = cfg.getBoolean("auth", "allowRegisterNewEmail", true);
+    userNameCaseInsensitive = cfg.getBoolean("auth", "userNameCaseInsensitive", false);
+    userNameCaseInsensitiveMigrationMode =
+        cfg.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
 
     if (gitBasicAuthPolicy == GitBasicAuthPolicy.HTTP_LDAP
         && authType != AuthType.LDAP
@@ -227,7 +232,7 @@
     return trustContainerAuth;
   }
 
-  /** @return true if users with Run As capability can impersonate others. */
+  /** Returns true if users with Run As capability can impersonate others. */
   public boolean isRunAsEnabled() {
     return enableRunAs;
   }
@@ -237,6 +242,16 @@
     return userNameToLowerCase;
   }
 
+  /** Whether user name should be matched case insenitive */
+  public boolean isUserNameCaseInsensitive() {
+    return userNameCaseInsensitive;
+  }
+
+  /** Whether user name case insensitive migration is in progress */
+  public boolean isUserNameCaseInsensitiveMigrationMode() {
+    return userNameCaseInsensitiveMigrationMode;
+  }
+
   public GitBasicAuthPolicy getGitBasicAuthPolicy() {
     return gitBasicAuthPolicy;
   }
diff --git a/java/com/google/gerrit/server/config/CapabilityConstants.java b/java/com/google/gerrit/server/config/CapabilityConstants.java
index 4ab97f8..59819bb 100644
--- a/java/com/google/gerrit/server/config/CapabilityConstants.java
+++ b/java/com/google/gerrit/server/config/CapabilityConstants.java
@@ -39,10 +39,10 @@
   public String runAs;
   public String runGC;
   public String streamEvents;
+  public String viewAccess;
   public String viewAllAccounts;
   public String viewCaches;
   public String viewConnections;
   public String viewPlugins;
   public String viewQueue;
-  public String viewAccess;
 }
diff --git a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
index b37e489..4032e63 100644
--- a/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
+++ b/java/com/google/gerrit/server/config/ConfigUpdatedEvent.java
@@ -32,9 +32,9 @@
  * <p>1. Help the callers figure out if any action should be taken, depending on which entries are
  * updated in gerrit.config.
  *
- * <p>2. Provide the callers with a mechanism to accept/reject the entries of interest: @see
- * accept(Set<ConfigKey> entries), @see accept(String section), @see reject(Set<ConfigKey> entries)
- * (+ various overloaded versions of these)
+ * <p>2. Provide the callers with a mechanism to accept/reject the entries of interest: {@link
+ * #accept(Set)}, {@link #accept(String)}, {@link #reject(Set)} (+ various overloaded versions of
+ * these)
  */
 public class ConfigUpdatedEvent {
   public static final ImmutableMultimap<UpdateResult, ConfigUpdateEntry> NO_UPDATES =
diff --git a/java/com/google/gerrit/server/config/ConfigUtil.java b/java/com/google/gerrit/server/config/ConfigUtil.java
index 27ded63..c44b0fd 100644
--- a/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -282,7 +282,6 @@
    * @param sub subsection
    * @param s instance of class with config values
    * @param defaults instance of class with default values
-   * @throws ConfigInvalidException
    */
   public static <T> void storeSection(Config cfg, String section, String sub, T s, T defaults)
       throws ConfigInvalidException {
@@ -341,7 +340,6 @@
    * @param i instance to merge during the load. When present, the boolean fields are not nullified
    *     when their values are false
    * @return loaded instance
-   * @throws ConfigInvalidException
    */
   public static <T> T loadSection(Config cfg, String section, String sub, T s, T defaults, T i)
       throws ConfigInvalidException {
diff --git a/java/com/google/gerrit/server/config/DefaultUrlFormatter.java b/java/com/google/gerrit/server/config/DefaultUrlFormatter.java
index 060ee3f..095fa3e 100644
--- a/java/com/google/gerrit/server/config/DefaultUrlFormatter.java
+++ b/java/com/google/gerrit/server/config/DefaultUrlFormatter.java
@@ -25,7 +25,7 @@
 public class DefaultUrlFormatter implements UrlFormatter {
   private final Provider<String> canonicalWebUrlProvider;
 
-  public static class Module extends AbstractModule {
+  public static class DefaultUrlFormatterModule extends AbstractModule {
     @Override
     protected void configure() {
       DynamicItem.itemOf(binder(), UrlFormatter.class);
diff --git a/java/com/google/gerrit/server/config/DownloadConfig.java b/java/com/google/gerrit/server/config/DownloadConfig.java
index 58ce098..00df1e6 100644
--- a/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.CoreDownloadSchemes;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.DownloadCommand;
 import com.google.gerrit.server.change.ArchiveFormatInternal;
@@ -35,6 +36,8 @@
  */
 @Singleton
 public class DownloadConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ImmutableSet<String> downloadSchemes;
   private final ImmutableSet<DownloadCommand> downloadCommands;
   private final ImmutableSet<ArchiveFormatInternal> archiveFormats;
@@ -51,7 +54,8 @@
       for (String s : allSchemes) {
         String core = toCoreScheme(s);
         if (core == null) {
-          throw new IllegalArgumentException("not a core download scheme: " + s);
+          logger.atWarning().log("not a core download scheme: " + s);
+          continue;
         }
         normalized.add(core);
       }
diff --git a/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
new file mode 100644
index 0000000..ebb0e50
--- /dev/null
+++ b/java/com/google/gerrit/server/config/FileBasedAllProjectsConfigProvider.java
@@ -0,0 +1,33 @@
+package com.google.gerrit.server.config;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.inject.Inject;
+import java.util.Optional;
+import javax.inject.Singleton;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class FileBasedAllProjectsConfigProvider implements AllProjectsConfigProvider {
+  private final SitePaths sitePaths;
+
+  @VisibleForTesting
+  @Inject
+  public FileBasedAllProjectsConfigProvider(SitePaths sitePaths) {
+    this.sitePaths = sitePaths;
+  }
+
+  @Override
+  public Optional<StoredConfig> get(AllProjectsName allProjectsName) {
+    return Optional.of(
+        new FileBasedConfig(
+            sitePaths
+                .etc_dir
+                .resolve(allProjectsName.get())
+                .resolve(ProjectConfig.PROJECT_CONFIG)
+                .toFile(),
+            FS.DETECTED));
+  }
+}
diff --git a/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
new file mode 100644
index 0000000..098d2c2
--- /dev/null
+++ b/java/com/google/gerrit/server/config/FileBasedGlobalPluginConfigProvider.java
@@ -0,0 +1,57 @@
+// 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.config;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class FileBasedGlobalPluginConfigProvider implements GlobalPluginConfigProvider {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+  private final SitePaths site;
+
+  @Inject
+  FileBasedGlobalPluginConfigProvider(SitePaths site) {
+    this.site = site;
+  }
+
+  @Override
+  public Config get(String pluginName) {
+    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
+    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    if (!cfg.getFile().exists()) {
+      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
+      return cfg;
+    }
+
+    try {
+      cfg.load();
+    } catch (ConfigInvalidException e) {
+      // This is an error in user input, don't spam logs with a stack trace.
+      logger.atWarning().log(
+          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
+    } catch (IOException e) {
+      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
+    }
+    return cfg;
+  }
+}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index bb851e2..a522135 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -66,23 +66,26 @@
 import com.google.gerrit.extensions.validators.CommentValidator;
 import com.google.gerrit.extensions.webui.BranchWebLink;
 import com.google.gerrit.extensions.webui.DiffWebLink;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileHistoryWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.gerrit.extensions.webui.TopMenu;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.DeadlineChecker;
 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.PerformanceMetrics;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.TraceRequestListener;
 import com.google.gerrit.server.account.AccountCacheImpl;
@@ -90,6 +93,8 @@
 import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.AccountExternalIdCreator;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AccountModule;
+import com.google.gerrit.server.account.AccountTagProvider;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.account.EmailExpander;
@@ -98,7 +103,11 @@
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.ServiceUserClassifierImpl;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalIdCacheModule;
 import com.google.gerrit.server.account.externalids.ExternalIdModule;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.approval.ApprovalCacheImpl;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
 import com.google.gerrit.server.avatar.AvatarProvider;
@@ -125,6 +134,7 @@
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.git.MergedByPushOp;
+import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.NotesBranchUtil;
 import com.google.gerrit.server.git.PureRevertCache;
 import com.google.gerrit.server.git.ReceivePackInitializer;
@@ -163,6 +173,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.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.patch.DiffOperationsImpl;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
 import com.google.gerrit.server.patch.PatchScriptFactory;
@@ -176,7 +187,9 @@
 import com.google.gerrit.server.project.ProjectCacheImpl;
 import com.google.gerrit.server.project.ProjectNameLockManager;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluatorImpl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
+import com.google.gerrit.server.query.approval.ApprovalModule;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -185,8 +198,8 @@
 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;
-import com.google.gerrit.server.rules.IgnoreSelfApprovalRule;
+import com.google.gerrit.server.rules.DefaultSubmitRule.DefaultSubmitRuleModule;
+import com.google.gerrit.server.rules.IgnoreSelfApprovalRule.IgnoreSelfApprovalRuleModule;
 import com.google.gerrit.server.rules.PrologModule;
 import com.google.gerrit.server.rules.RulesCache;
 import com.google.gerrit.server.rules.SubmitRule;
@@ -235,6 +248,7 @@
     bind(RulesCache.class);
     bind(BlameCache.class).to(BlameCacheImpl.class);
     install(AccountCacheImpl.module());
+    install(ApprovalCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
     install(ChangeFinder.module());
@@ -252,28 +266,34 @@
     install(TagCache.module());
     install(PureRevertCache.module());
     install(CommentContextCacheImpl.module());
+    install(SubmitRequirementsEvaluatorImpl.module());
 
     install(new AccessControlModule());
+    install(new AccountModule());
     install(new CmdLineParserModule());
     install(new EmailModule());
+    install(new ExternalIdCacheModule());
     install(new ExternalIdModule());
     install(new GitModule());
     install(new GroupDbModule());
     install(new GroupModule());
     install(new NoteDbModule());
     install(new PrologModule());
-    install(new DefaultSubmitRule.Module());
-    install(new IgnoreSelfApprovalRule.Module());
+    install(new DefaultSubmitRuleModule());
+    install(new IgnoreSelfApprovalRuleModule());
     install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
-    install(new FileInfoJsonModule(cfg));
+    install(new FileInfoJsonModule());
     install(ThreadLocalRequestContext.module());
+    install(new ApprovalModule());
 
     factory(CapabilityCollection.Factory.class);
     factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(ChangeIsVisibleToPredicate.Factory.class);
+    factory(DeadlineChecker.Factory.class);
     factory(MergeUtil.Factory.class);
+    factory(MultiProgressMonitor.Factory.class);
     factory(PatchScriptFactory.Factory.class);
     factory(PatchScriptFactoryForAutoFix.Factory.class);
     factory(ProjectState.Factory.class);
@@ -391,10 +411,12 @@
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PluginPushOption.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
+    DynamicSet.setOf(binder(), ResolveConflictsWebLink.class);
     DynamicSet.setOf(binder(), ParentWebLink.class);
     DynamicSet.setOf(binder(), FileWebLink.class);
     DynamicSet.setOf(binder(), FileHistoryWebLink.class);
     DynamicSet.setOf(binder(), DiffWebLink.class);
+    DynamicSet.setOf(binder(), EditWebLink.class);
     DynamicSet.setOf(binder(), ProjectWebLink.class);
     DynamicSet.setOf(binder(), BranchWebLink.class);
     DynamicSet.setOf(binder(), TagWebLink.class);
@@ -410,6 +432,9 @@
     DynamicSet.setOf(binder(), SubmitRule.class);
     DynamicSet.setOf(binder(), QuotaEnforcer.class);
     DynamicSet.setOf(binder(), PerformanceLogger.class);
+    if (cfg.getBoolean("tracing", "exportPerformanceMetrics", false)) {
+      DynamicSet.bind(binder(), PerformanceLogger.class).to(PerformanceMetrics.class);
+    }
     DynamicSet.setOf(binder(), RequestListener.class);
     DynamicSet.bind(binder(), RequestListener.class).to(TraceRequestListener.class);
     DynamicSet.setOf(binder(), ChangeETagComputation.class);
@@ -417,6 +442,7 @@
     DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
     DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
     DynamicSet.setOf(binder(), OnPostReview.class);
+    DynamicMap.mapOf(binder(), AccountTagProvider.class);
 
     DynamicMap.mapOf(binder(), MailFilter.class);
     bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);
@@ -458,6 +484,7 @@
     factory(MergedByPushOp.Factory.class);
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
+    factory(StoreSubmitRequirementsOp.Factory.class);
 
     bind(AccountManager.class);
     bind(SubscriptionGraph.Factory.class).to(ConfiguredSubscriptionGraphFactory.class);
@@ -468,5 +495,6 @@
     bind(ReloadPluginListener.class)
         .annotatedWith(UniqueAnnotations.create())
         .to(PluginConfigFactory.class);
+    DynamicMap.mapOf(binder(), ExternalIdUpsertPreprocessor.class);
   }
 }
diff --git a/java/com/google/gerrit/server/config/GerritIsReplica.java b/java/com/google/gerrit/server/config/GerritIsReplica.java
index 154fdcd..ab6aa8b 100644
--- a/java/com/google/gerrit/server/config/GerritIsReplica.java
+++ b/java/com/google/gerrit/server/config/GerritIsReplica.java
@@ -19,7 +19,7 @@
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 
-/* Marker on {@link Boolean} indicating whether Gerrit is run as a read-only replica. */
+/** Marker on {@link Boolean} indicating whether Gerrit is run as a read-only replica. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface GerritIsReplica {}
diff --git a/java/com/google/gerrit/server/config/GerritOptions.java b/java/com/google/gerrit/server/config/GerritOptions.java
index d9edf23..0390620 100644
--- a/java/com/google/gerrit/server/config/GerritOptions.java
+++ b/java/com/google/gerrit/server/config/GerritOptions.java
@@ -14,15 +14,23 @@
 
 package com.google.gerrit.server.config;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
+import java.util.Optional;
+
 public class GerritOptions {
   private final boolean headless;
   private final boolean slave;
-  private final String devCdn;
+  private final Optional<String> devCdn;
 
-  public GerritOptions(boolean headless, boolean slave, String devCdn) {
+  public GerritOptions(boolean headless, boolean slave) {
+    this(headless, slave, null);
+  }
+
+  public GerritOptions(boolean headless, boolean slave, @Nullable String devCdn) {
     this.headless = headless;
     this.slave = slave;
-    this.devCdn = devCdn;
+    this.devCdn = headless ? Optional.empty() : Optional.ofNullable(Strings.emptyToNull(devCdn));
   }
 
   public boolean headless() {
@@ -33,11 +41,7 @@
     return !slave;
   }
 
-  public String devCdn() {
+  public Optional<String> devCdn() {
     return devCdn;
   }
-
-  public boolean useDevCdn() {
-    return !headless && devCdn.length() > 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 3777a55..8ddcdac 100644
--- a/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -20,8 +20,6 @@
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.securestore.SecureStoreProvider;
 import com.google.inject.AbstractModule;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
 import com.google.inject.ProvisionException;
 import java.io.IOException;
 import java.nio.file.Path;
@@ -42,22 +40,13 @@
   }
 
   private static String getSecureStoreFromGerritConfig(Path sitePath) {
-    AbstractModule m =
-        new AbstractModule() {
-          @Override
-          protected void configure() {
-            bind(Path.class).annotatedWith(SitePath.class).toInstance(sitePath);
-            bind(SitePaths.class);
-          }
-        };
-    Injector injector = Guice.createInjector(m);
-    SitePaths site = injector.getInstance(SitePaths.class);
-    FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
-    if (!cfg.getFile().exists()) {
-      return DefaultSecureStore.class.getName();
-    }
-
     try {
+      SitePaths site = new SitePaths(sitePath);
+      FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
+      if (!cfg.getFile().exists()) {
+        return DefaultSecureStore.class.getName();
+      }
+
       cfg.load();
       String className = cfg.getString("gerrit", null, "secureStoreClass");
       return nullToDefault(className);
@@ -77,6 +66,8 @@
     bind(Config.class)
         .annotatedWith(GerritServerConfig.class)
         .toProvider(GerritServerConfigProvider.class);
+    bind(AllProjectsConfigProvider.class).to(FileBasedAllProjectsConfigProvider.class);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
     bind(SecureStore.class).toProvider(SecureStoreProvider.class).in(SINGLETON);
     bind(Boolean.class)
         .annotatedWith(GerritIsReplica.class)
diff --git a/java/com/google/gerrit/server/config/GitwebCgiConfig.java b/java/com/google/gerrit/server/config/GitwebCgiConfig.java
index d7fb83c..1ed0f16 100644
--- a/java/com/google/gerrit/server/config/GitwebCgiConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebCgiConfig.java
@@ -118,22 +118,22 @@
     this.logoPng = null;
   }
 
-  /** @return local path to the CGI executable; null if we shouldn't execute. */
+  /** Returns local path to the CGI executable; null if we shouldn't execute. */
   public Path getGitwebCgi() {
     return cgi;
   }
 
-  /** @return local path of the {@code gitweb.css} matching the CGI. */
+  /** Returns local path of the {@code gitweb.css} matching the CGI. */
   public Path getGitwebCss() {
     return css;
   }
 
-  /** @return local path of the {@code gitweb.js} for the CGI. */
+  /** Returns local path of the {@code gitweb.js} for the CGI. */
   public Path getGitwebJs() {
     return js;
   }
 
-  /** @return local path of the {@code git-logo.png} for the CGI. */
+  /** Returns local path of the {@code git-logo.png} for the CGI. */
   public Path getGitLogoPng() {
     return logoPng;
   }
diff --git a/java/com/google/gerrit/server/config/GitwebConfig.java b/java/com/google/gerrit/server/config/GitwebConfig.java
index 0bc4380..c477bb5 100644
--- a/java/com/google/gerrit/server/config/GitwebConfig.java
+++ b/java/com/google/gerrit/server/config/GitwebConfig.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.extensions.webui.ParentWebLink;
 import com.google.gerrit.extensions.webui.PatchSetWebLink;
 import com.google.gerrit.extensions.webui.ProjectWebLink;
+import com.google.gerrit.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.extensions.webui.TagWebLink;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
@@ -81,6 +82,7 @@
         if (!isNullOrEmpty(type.getRevision())) {
           DynamicSet.bind(binder(), PatchSetWebLink.class).to(GitwebLinks.class);
           DynamicSet.bind(binder(), ParentWebLink.class).to(GitwebLinks.class);
+          DynamicSet.bind(binder(), ResolveConflictsWebLink.class).to(GitwebLinks.class);
         }
 
         if (!isNullOrEmpty(type.getProject())) {
@@ -209,16 +211,16 @@
     }
   }
 
-  /** @return GitwebType for gitweb viewer. */
+  /** Returns GitwebType for gitweb viewer. */
   @Nullable
   public GitwebType getGitwebType() {
     return type;
   }
 
   /**
-   * @return URL of the entry point into gitweb. This URL may be relative to our context if gitweb
-   *     is hosted by ourselves; or absolute if its hosted elsewhere; or null if gitweb has not been
-   *     configured.
+   * Returns URL of the entry point into gitweb. This URL may be relative to our context if gitweb
+   * is hosted by ourselves; or absolute if its hosted elsewhere; or null if gitweb has not been
+   * configured.
    */
   public String getUrl() {
     return url;
@@ -258,6 +260,7 @@
           PatchSetWebLink,
           ParentWebLink,
           ProjectWebLink,
+          ResolveConflictsWebLink,
           TagWebLink {
     private final String url;
     private final GitwebType type;
@@ -343,6 +346,13 @@
     }
 
     @Override
+    public WebLinkInfo getResolveConflictsWebLink(
+        String projectName, String commit, String commitMessage, String branchName) {
+      // For Gitweb treat resolve conflicts links the same as patch set links
+      return getPatchSetWebLink(projectName, commit, commitMessage, branchName);
+    }
+
+    @Override
     public WebLinkInfo getParentWebLink(
         String projectName, String commit, String commitMessage, String branchName) {
       // For Gitweb treat parent revision links the same as patch set links
diff --git a/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
similarity index 70%
rename from java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
rename to java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
index d90b80f..847708a 100644
--- a/java/com/google/gerrit/elasticsearch/bulk/IndexRequest.java
+++ b/java/com/google/gerrit/server/config/GlobalPluginConfigProvider.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2018 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,11 +12,10 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.elasticsearch.bulk;
+package com.google.gerrit.server.config;
 
-public class IndexRequest extends ActionRequest {
+import org.eclipse.jgit.lib.Config;
 
-  public IndexRequest(String id, String index) {
-    super("index", id, index);
-  }
+public interface GlobalPluginConfigProvider {
+  Config get(String pluginName);
 }
diff --git a/java/com/google/gerrit/server/config/PluginConfigFactory.java b/java/com/google/gerrit/server/config/PluginConfigFactory.java
index 2d0f9a5..bd4b661 100644
--- a/java/com/google/gerrit/server/config/PluginConfigFactory.java
+++ b/java/com/google/gerrit/server/config/PluginConfigFactory.java
@@ -16,7 +16,6 @@
 
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
@@ -28,48 +27,37 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Path;
 import java.util.HashMap;
 import java.util.Map;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.internal.storage.file.FileSnapshot;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 
 @Singleton
 public class PluginConfigFactory implements ReloadPluginListener {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final String EXTENSION = ".config";
 
-  private final SitePaths site;
+  private final GlobalPluginConfigProvider globalPluginConfigProvider;
   private final Provider<Config> cfgProvider;
   private final ProjectCache projectCache;
   private final ProjectState.Factory projectStateFactory;
   private final SecureStore secureStore;
   private final Map<String, Config> pluginConfigs;
 
-  private volatile FileSnapshot cfgSnapshot;
   private volatile Config cfg;
 
   @Inject
   PluginConfigFactory(
-      SitePaths site,
       @GerritServerConfig Provider<Config> cfgProvider,
+      GlobalPluginConfigProvider globalPluginConfigProvider,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       SecureStore secureStore) {
-    this.site = site;
+    this.globalPluginConfigProvider = globalPluginConfigProvider;
     this.cfgProvider = cfgProvider;
     this.projectCache = projectCache;
     this.projectStateFactory = projectStateFactory;
     this.secureStore = secureStore;
 
     this.pluginConfigs = new HashMap<>();
-    this.cfgSnapshot = FileSnapshot.save(site.gerrit_config.toFile());
     this.cfg = cfgProvider.get();
   }
 
@@ -103,12 +91,10 @@
    * @return the plugin configuration from the 'gerrit.config' file
    */
   public PluginConfig getFromGerritConfig(String pluginName, boolean refresh) {
-    if (refresh && secureStore.isOutdated()) {
-      secureStore.reload();
-    }
-    File configFile = site.gerrit_config.toFile();
-    if (refresh && cfgSnapshot.isModified(configFile)) {
-      cfgSnapshot = FileSnapshot.save(configFile);
+    if (refresh) {
+      if (secureStore.isOutdated()) {
+        secureStore.reload();
+      }
       cfg = cfgProvider.get();
     }
     return PluginConfig.createFromGerritConfig(pluginName, cfg);
@@ -217,25 +203,9 @@
       return pluginConfigs.get(pluginName);
     }
 
-    Path pluginConfigFile = site.etc_dir.resolve(pluginName + ".config");
-    FileBasedConfig cfg = new FileBasedConfig(pluginConfigFile.toFile(), FS.DETECTED);
+    Config cfg = globalPluginConfigProvider.get(pluginName);
     GlobalPluginConfig pluginConfig = new GlobalPluginConfig(pluginName, cfg, secureStore);
     pluginConfigs.put(pluginName, pluginConfig);
-    if (!cfg.getFile().exists()) {
-      logger.atInfo().log("No %s; assuming defaults", pluginConfigFile.toAbsolutePath());
-      return pluginConfig;
-    }
-
-    try {
-      cfg.load();
-    } catch (ConfigInvalidException e) {
-      // This is an error in user input, don't spam logs with a stack trace.
-      logger.atWarning().log(
-          "Failed to load %s: %s", pluginConfigFile.toAbsolutePath(), e.getMessage());
-    } catch (IOException e) {
-      logger.atWarning().withCause(e).log("Failed to load %s", pluginConfigFile.toAbsolutePath());
-    }
-
     return pluginConfig;
   }
 
diff --git a/java/com/google/gerrit/server/config/ProjectConfigEntry.java b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
index fcfa5e9..c09988e3 100644
--- a/java/com/google/gerrit/server/config/ProjectConfigEntry.java
+++ b/java/com/google/gerrit/server/config/ProjectConfigEntry.java
@@ -206,16 +206,18 @@
   }
 
   /**
+   * Returns whether the project is editable
+   *
    * @param project project state.
-   * @return whether the project is editable.
    */
   public boolean isEditable(ProjectState project) {
     return true;
   }
 
   /**
+   * Returns any warning associated with the project
+   *
    * @param project project state.
-   * @return any warning associated with the project.
    */
   public String getWarning(ProjectState project) {
     return null;
diff --git a/java/com/google/gerrit/server/diff/DiffInfoCreator.java b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
index c29ffc8..606e42b 100644
--- a/java/com/google/gerrit/server/diff/DiffInfoCreator.java
+++ b/java/com/google/gerrit/server/diff/DiffInfoCreator.java
@@ -73,6 +73,8 @@
 
     ImmutableList<DiffWebLinkInfo> links = webLinksProvider.getDiffLinks();
     result.webLinks = links.isEmpty() ? null : links;
+    ImmutableList<WebLinkInfo> editLinks = webLinksProvider.getEditWebLinks();
+    result.editWebLinks = editLinks.isEmpty() ? null : editLinks;
 
     if (ps.isBinary()) {
       result.binary = true;
@@ -156,8 +158,8 @@
         FileContentUtil.resolveContentType(
             state, side.fileName(), fileInfo.mode, fileInfo.mimeType);
     result.lines = fileInfo.content.getSize();
-    ImmutableList<WebLinkInfo> links = webLinksProvider.getFileWebLinks(side.type());
-    result.webLinks = links.isEmpty() ? null : links;
+    ImmutableList<WebLinkInfo> fileLinks = webLinksProvider.getFileWebLinks(side.type());
+    result.webLinks = fileLinks.isEmpty() ? null : fileLinks;
     result.commitId = fileInfo.commitId;
     return Optional.of(result);
   }
diff --git a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
index 0f71b17..2590ebc 100644
--- a/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
+++ b/java/com/google/gerrit/server/diff/DiffWebLinksProvider.java
@@ -24,6 +24,9 @@
   /** Returns links associated with the diff view */
   ImmutableList<DiffWebLinkInfo> getDiffLinks();
 
-  /** Returns links associated with the diff side */
+  /** Returns edit links associated with the diff view */
+  ImmutableList<WebLinkInfo> getEditWebLinks();
+
+  /** Returns file links associated with the diff side */
   ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType);
 }
diff --git a/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 710916e..6b018ce 100644
--- a/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -146,9 +146,6 @@
    * @param edit change edit to publish
    * @param notify Notify handling that defines to whom email notifications should be sent after the
    *     change edit is published.
-   * @throws IOException
-   * @throws UpdateException
-   * @throws RestApiException
    */
   public void publish(
       BatchUpdate.Factory updateFactory,
@@ -209,7 +206,6 @@
    * Delete change edit.
    *
    * @param edit change edit to delete
-   * @throws IOException
    */
   public void delete(ChangeEdit edit) throws IOException {
     Change change = edit.getChange();
diff --git a/java/com/google/gerrit/server/events/EventBroker.java b/java/com/google/gerrit/server/events/EventBroker.java
index 0fcb64e..4001a48 100644
--- a/java/com/google/gerrit/server/events/EventBroker.java
+++ b/java/com/google/gerrit/server/events/EventBroker.java
@@ -47,7 +47,7 @@
 public class EventBroker implements EventDispatcher {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends LifecycleModule {
+  public static class EventBrokerModule extends LifecycleModule {
     @Override
     protected void configure() {
       DynamicItem.itemOf(binder(), EventDispatcher.class);
diff --git a/java/com/google/gerrit/server/events/EventFactory.java b/java/com/google/gerrit/server/events/EventFactory.java
index b648255..a7fea3c 100644
--- a/java/com/google/gerrit/server/events/EventFactory.java
+++ b/java/com/google/gerrit/server/events/EventFactory.java
@@ -35,11 +35,11 @@
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.index.IndexConfig;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.data.AccountAttribute;
@@ -56,13 +56,13 @@
 import com.google.gerrit.server.data.SubmitRequirementAttribute;
 import com.google.gerrit.server.data.TrackingIdAttribute;
 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.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -71,6 +71,7 @@
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -83,37 +84,40 @@
 
   private final AccountCache accountCache;
   private final DynamicItem<UrlFormatter> urlFormatter;
+  private final DiffOperations diffOperations;
   private final Emails emails;
-  private final PatchListCache patchListCache;
   private final Provider<PersonIdent> myIdent;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeKindCache changeKindCache;
   private final Provider<InternalChangeQuery> queryProvider;
   private final IndexConfig indexConfig;
+  private final AccountTemplateUtil accountTemplateUtil;
 
   @Inject
   EventFactory(
       AccountCache accountCache,
       Emails emails,
       DynamicItem<UrlFormatter> urlFormatter,
-      PatchListCache patchListCache,
+      DiffOperations diffOperations,
       @GerritPersonIdent Provider<PersonIdent> myIdent,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeKindCache changeKindCache,
       Provider<InternalChangeQuery> queryProvider,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      AccountTemplateUtil accountTemplateUtil) {
     this.accountCache = accountCache;
     this.urlFormatter = urlFormatter;
     this.emails = emails;
-    this.patchListCache = patchListCache;
+    this.diffOperations = diffOperations;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.changeKindCache = changeKindCache;
     this.queryProvider = queryProvider;
     this.indexConfig = indexConfig;
+    this.accountTemplateUtil = accountTemplateUtil;
   }
 
   public ChangeAttribute asChangeAttribute(Change change) {
@@ -394,23 +398,24 @@
   public void addPatchSetFileNames(
       PatchSetAttribute patchSetAttribute, Change change, PatchSet patchSet) {
     try {
-      PatchList patchList = patchListCache.get(change, patchSet);
-      for (PatchListEntry patch : patchList.getPatches()) {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+
+      for (FileDiffOutput diff : modifiedFiles.values()) {
         if (patchSetAttribute.files == null) {
           patchSetAttribute.files = new ArrayList<>();
         }
 
         PatchAttribute p = new PatchAttribute();
-        p.file = patch.getNewName();
-        p.fileOld = patch.getOldName();
-        p.type = patch.getChangeType();
-        p.deletions -= patch.getDeletions();
-        p.insertions = patch.getInsertions();
+        p.file = FilePathAdapter.getNewPath(diff.oldPath(), diff.newPath(), diff.changeType());
+        p.fileOld = FilePathAdapter.getOldPath(diff.oldPath(), diff.changeType());
+        p.type = diff.changeType();
+        p.deletions -= diff.deletions();
+        p.insertions = diff.insertions();
         patchSetAttribute.files.add(p);
       }
-    } catch (PatchListObjectTooLargeException e) {
-      logger.atWarning().log("Cannot get patch list: %s", e.getMessage());
-    } catch (PatchListNotAvailableException e) {
+    } catch (DiffNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot get patch list");
     }
   }
@@ -450,15 +455,17 @@
         p.author = asAccountAttribute(author.getAccount());
       }
 
-      PatchList patchList = patchListCache.get(change, patchSet);
-      p.sizeDeletions = patchList.getDeletions();
-      p.sizeInsertions = patchList.getInsertions();
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFilesAgainstParent(
+              change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
+      for (FileDiffOutput fileDiff : modifiedFiles.values()) {
+        p.sizeDeletions += fileDiff.deletions();
+        p.sizeInsertions += fileDiff.insertions();
+      }
       p.kind = changeKindCache.getChangeKind(change, patchSet);
     } catch (IOException | StorageException e) {
       logger.atSevere().withCause(e).log("Cannot load patch set data for %s", patchSet.id());
-    } catch (PatchListObjectTooLargeException e) {
-      logger.atWarning().log("Cannot get size information for %s: %s", pId, e.getMessage());
-    } catch (PatchListNotAvailableException e) {
+    } catch (DiffNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot get size information for %s.", pId);
     }
     return p;
@@ -529,10 +536,8 @@
     a.grantedOn = approval.granted().getTime() / 1000L;
     a.oldValue = null;
 
-    LabelType lt = labelTypes.byLabel(approval.labelId());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
+    Optional<LabelType> lt = labelTypes.byLabel(approval.labelId());
+    lt.ifPresent(l -> a.description = l.getName());
     return a;
   }
 
@@ -543,7 +548,7 @@
         message.getAuthor() != null
             ? asAccountAttribute(message.getAuthor())
             : asAccountAttribute(myIdent.get());
-    a.message = message.getMessage();
+    a.message = accountTemplateUtil.replaceTemplates(message.getMessage());
     return a;
   }
 
diff --git a/java/com/google/gerrit/server/events/EventsMetrics.java b/java/com/google/gerrit/server/events/EventsMetrics.java
index 3c87cca..6d48c37 100644
--- a/java/com/google/gerrit/server/events/EventsMetrics.java
+++ b/java/com/google/gerrit/server/events/EventsMetrics.java
@@ -32,7 +32,9 @@
         metricMaker.newCounter(
             "events",
             new Description("Triggered events").setRate().setUnit("triggered events"),
-            Field.ofString("type", Metadata.Builder::eventType).build());
+            Field.ofString("type", Metadata.Builder::eventType)
+                .description("The type of the event.")
+                .build());
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 439f53e..abacb85 100644
--- a/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -90,7 +89,7 @@
         VoteDeletedListener {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends AbstractModule {
+  public static class StreamEventsApiListenerModule extends AbstractModule {
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), AssigneeChangedListener.class).to(StreamEventsApiListener.class);
@@ -202,10 +201,7 @@
         a.oldValue = Short.toString(oldApprovals.get(approval.getKey()));
       }
     }
-    LabelType lt = labelTypes.byLabel(approval.getKey());
-    if (lt != null) {
-      a.description = lt.getName();
-    }
+    labelTypes.byLabel(approval.getKey()).ifPresent(lt -> a.description = lt.getName());
     if (approval.getValue() != null) {
       a.value = Short.toString(approval.getValue());
     }
diff --git a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
index f526935..227deb5 100644
--- a/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
+++ b/java/com/google/gerrit/server/experiments/ConfigExperimentFeatures.java
@@ -31,7 +31,7 @@
 @Singleton
 public class ConfigExperimentFeatures implements ExperimentFeatures {
 
-  public static class Module extends AbstractModule {
+  public static class ConfigExperimentFeaturesModule extends AbstractModule {
     @Override
     protected void configure() {
       bind(ExperimentFeatures.class).to(ConfigExperimentFeatures.class);
diff --git a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
index af49438..1486559 100644
--- a/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
+++ b/java/com/google/gerrit/server/experiments/ExperimentFeaturesConstants.java
@@ -22,6 +22,29 @@
   /** Features that are known experiments and can be referenced in the code. */
   public static String UI_FEATURE_PATCHSET_COMMENTS = "UiFeature__patchset_comments";
 
+  public static String GERRIT_BACKEND_REQUEST_FEATURE_REMOVE_REVISION_ETAG =
+      "GerritBackendRequestFeature__remove_revision_etag";
+
+  /** Enable storing submit requirements in NoteDb when the change is merged. */
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE =
+      "GerritBackendRequestFeature__store_submit_requirements_on_merge";
+
+  /**
+   * Allow legacy {@link com.google.gerrit.entities.SubmitRecord}s to be converted and returned as
+   * submit requirements by the {@link
+   * com.google.gerrit.server.project.SubmitRequirementsEvaluator}.
+   */
+  public static final String GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS =
+      "GerritBackendRequestFeature__enable_submit_requirements";
+
+  /**
+   * Allow SubmitRequirements to be computed freshly on dashboards irrespective of the value we
+   * retrieved from the change index.
+   */
+  public static final String
+      GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD =
+          "GerritBackendRequestFeature__enable_submit_requirements_backfilling_on_dashboard";
+
   /** 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/extensions/events/AssigneeChanged.java b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index 2189690..e31a1b5 100644
--- a/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.AssigneeChangedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -42,14 +42,14 @@
   }
 
   public void fire(
-      Change change, AccountState accountState, AccountState oldAssignee, Timestamp when) {
+      ChangeData changeData, AccountState accountState, AccountState oldAssignee, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
+              util.changeInfo(changeData),
               util.accountInfo(accountState),
               util.accountInfo(oldAssignee),
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index c7a9283..cbe7c6b 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,7 +49,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState abandoner,
       String reason,
@@ -61,8 +61,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(abandoner),
               reason,
               when,
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
index 1ed6209..23a4583 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.ChangeDeletedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -41,12 +41,12 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState deleter, Timestamp when) {
+  public void fire(ChangeData changeData, AccountState deleter, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.accountInfo(deleter), when);
+      Event event = new Event(util.changeInfo(changeData), util.accountInfo(deleter), when);
       listeners.runEach(l -> l.onChangeDeleted(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 06d0008..e4896df 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,15 +49,19 @@
   }
 
   public void fire(
-      Change change, PatchSet ps, AccountState merger, String newRevisionId, Timestamp when) {
+      ChangeData changeData,
+      PatchSet ps,
+      AccountState merger,
+      String newRevisionId,
+      Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(merger),
               newRevisionId,
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 1af56d0..8bd222a 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -29,6 +28,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -49,15 +49,15 @@
   }
 
   public void fire(
-      Change change, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
+      ChangeData changeData, PatchSet ps, AccountState restorer, String reason, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(restorer),
               reason,
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index d608c52..4a46eb0 100644
--- a/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -39,12 +39,12 @@
     this.util = util;
   }
 
-  public void fire(Change change, Change revertChange, Timestamp when) {
+  public void fire(ChangeData changeData, ChangeData revertChangeData, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
-      Event event = new Event(util.changeInfo(change), util.changeInfo(revertChange), when);
+      Event event = new Event(util.changeInfo(changeData), util.changeInfo(revertChangeData), when);
       listeners.runEach(l -> l.onChangeReverted(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 151298c..20c54cf 100644
--- a/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState author,
       String comment,
@@ -64,8 +64,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(author),
               comment,
               util.approvals(author, approvals, when),
diff --git a/java/com/google/gerrit/server/extensions/events/EventUtil.java b/java/com/google/gerrit/server/extensions/events/EventUtil.java
index a35140a..f0d038a 100644
--- a/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -18,7 +18,6 @@
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -84,8 +83,8 @@
     this.changeOptions = parseChangeListOptions(gerritConfig);
   }
 
-  public ChangeInfo changeInfo(Change change) {
-    return changeJsonFactory.create(changeOptions).format(change);
+  public ChangeInfo changeInfo(ChangeData changeData) {
+    return changeJsonFactory.create(changeOptions).format(changeData);
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
diff --git a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index 5d9c5c2..846257c 100644
--- a/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -24,6 +23,7 @@
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -45,7 +45,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       AccountState editor,
       ImmutableSortedSet<String> hashtags,
       Set<String> added,
@@ -57,7 +57,12 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change), util.accountInfo(editor), hashtags, added, removed, when);
+              util.changeInfo(changeData),
+              util.accountInfo(editor),
+              hashtags,
+              added,
+              removed,
+              when);
       listeners.runEach(l -> l.onHashtagsEdited(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
index bcc6b8e..d81068c 100644
--- a/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -28,6 +27,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -47,15 +47,15 @@
     this.util = util;
   }
 
-  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(account),
               when);
       listeners.runEach(l -> l.onPrivateStateChanged(event));
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 35e7828..ba73ca1 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       List<AccountState> reviewers,
       AccountState adder,
@@ -63,8 +63,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               Lists.transform(reviewers, util::accountInfo),
               util.accountInfo(adder),
               when);
diff --git a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 147f980..80037bc 100644
--- a/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       AccountState reviewer,
       AccountState remover,
@@ -66,8 +66,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(reviewer),
               util.accountInfo(remover),
               message,
diff --git a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 8179e9a..4c78216 100644
--- a/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -44,7 +44,7 @@
       new RevisionCreated() {
         @Override
         public void fire(
-            Change change,
+            ChangeData changeData,
             PatchSet patchSet,
             AccountState uploader,
             Timestamp when,
@@ -66,7 +66,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet patchSet,
       AccountState uploader,
       Timestamp when,
@@ -77,8 +77,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(uploader),
               when,
               notify.handling());
diff --git a/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index e4089b1..08b47f1 100644
--- a/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -23,6 +22,7 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.sql.Timestamp;
@@ -41,13 +41,14 @@
     this.util = util;
   }
 
-  public void fire(Change change, AccountState account, String oldTopicName, Timestamp when) {
+  public void fire(
+      ChangeData changeData, AccountState account, String oldTopicName, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
-          new Event(util.changeInfo(change), util.accountInfo(account), oldTopicName, when);
+          new Event(util.changeInfo(changeData), util.accountInfo(account), oldTopicName, when);
       listeners.runEach(l -> l.onTopicEdited(event));
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Couldn't fire event");
diff --git a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index ef4e461..244e46c 100644
--- a/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -30,6 +29,7 @@
 import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -51,7 +51,7 @@
   }
 
   public void fire(
-      Change change,
+      ChangeData changeData,
       PatchSet ps,
       AccountState reviewer,
       Map<String, Short> approvals,
@@ -66,8 +66,8 @@
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), ps),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), ps),
               util.accountInfo(reviewer),
               util.approvals(remover, approvals, when),
               util.approvals(remover, oldApprovals, when),
diff --git a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
index 06b244b..bfc068d 100644
--- a/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
+++ b/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.extensions.events;
 
 import com.google.common.flogger.FluentLogger;
-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.NotifyHandling;
@@ -28,6 +27,7 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -41,7 +41,8 @@
   public static final WorkInProgressStateChanged DISABLED =
       new WorkInProgressStateChanged() {
         @Override
-        public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {}
+        public void fire(
+            ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {}
       };
 
   private final PluginSetContext<WorkInProgressStateChangedListener> listeners;
@@ -59,15 +60,15 @@
     this.util = null;
   }
 
-  public void fire(Change change, PatchSet patchSet, AccountState account, Timestamp when) {
+  public void fire(ChangeData changeData, PatchSet patchSet, AccountState account, Timestamp when) {
     if (listeners.isEmpty()) {
       return;
     }
     try {
       Event event =
           new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
+              util.changeInfo(changeData),
+              util.revisionInfo(changeData.project(), patchSet),
               util.accountInfo(account),
               when);
       listeners.runEach(l -> l.onWorkInProgressStateChanged(event));
diff --git a/java/com/google/gerrit/server/extensions/webui/UiActions.java b/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 0bc3d5c..34c3c20 100644
--- a/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -72,7 +72,9 @@
             new com.google.gerrit.metrics.Description("Latency for RestView#getDescription calls")
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
-            Field.ofString("view", Metadata.Builder::restViewName).build());
+            Field.ofString("view", Metadata.Builder::restViewName)
+                .description("view implementation class")
+                .build());
   }
 
   public <R extends RestResource> Iterable<UiAction.Description> from(
@@ -143,11 +145,12 @@
     }
 
     String name = e.getExportName().substring(d + 1);
-    UiAction.Description dsc;
+    UiAction.Description dsc = null;
     try (Timer1.Context<String> ignored = uiActionLatency.start(name)) {
       dsc = ((UiAction<R>) view).getDescription(resource);
+    } catch (Exception ex) {
+      logger.atSevere().withCause(ex).log("Unable to render UIAction. Will omit from actions");
     }
-
     if (dsc == null) {
       return null;
     }
diff --git a/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java b/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java
index 53b88b1..d90618c 100644
--- a/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java
+++ b/java/com/google/gerrit/server/fixes/testing/GitEditSubject.java
@@ -22,7 +22,6 @@
 import com.google.gerrit.jgit.diff.ReplaceEdit;
 import com.google.gerrit.truth.ListSubject;
 import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.Edit.Type;
 
 public class GitEditSubject extends Subject {
 
@@ -49,32 +48,32 @@
     check("endB").that(edit.getEndB()).isEqualTo(endB);
   }
 
-  public void hasType(Type type) {
+  public void hasType(Edit.Type type) {
     isNotNull();
     check("getType").that(edit.getType()).isEqualTo(type);
   }
 
   public void isInsert(int insertPos, int beginB, int insertedLength) {
     isNotNull();
-    hasType(Type.INSERT);
+    hasType(Edit.Type.INSERT);
     hasRegions(insertPos, insertPos, beginB, beginB + insertedLength);
   }
 
   public void isDelete(int deletePos, int deletedLength, int posB) {
     isNotNull();
-    hasType(Type.DELETE);
+    hasType(Edit.Type.DELETE);
     hasRegions(deletePos, deletePos + deletedLength, posB, posB);
   }
 
   public void isReplace(int originalPos, int originalLength, int newPos, int newLength) {
     isNotNull();
-    hasType(Type.REPLACE);
+    hasType(Edit.Type.REPLACE);
     hasRegions(originalPos, originalPos + originalLength, newPos, newPos + newLength);
   }
 
   public void isEmpty() {
     isNotNull();
-    hasType(Type.EMPTY);
+    hasType(Edit.Type.EMPTY);
   }
 
   public ListSubject<GitEditSubject, Edit> internalEdits() {
diff --git a/java/com/google/gerrit/server/git/CommitUtil.java b/java/com/google/gerrit/server/git/CommitUtil.java
index 47cbd60..73378f6 100644
--- a/java/com/google/gerrit/server/git/CommitUtil.java
+++ b/java/com/google/gerrit/server/git/CommitUtil.java
@@ -20,9 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Account.Id;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RevertInput;
@@ -30,12 +28,12 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -48,7 +46,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.inject.Inject;
@@ -266,7 +264,7 @@
     Change changeToRevert = notes.getChange();
     Change.Id changeId = Change.id(seq.nextChangeId());
     if (input.workInProgress) {
-      input.notify = NotifyHandling.OWNER;
+      input.notify = firstNonNull(input.notify, NotifyHandling.OWNER);
     }
     NotifyResolver.Result notify =
         notifyResolver.resolve(firstNonNull(input.notify, NotifyHandling.ALL), input.notifyDetails);
@@ -279,7 +277,7 @@
 
     ReviewerSet reviewerSet = approvalsUtil.getReviewers(notes);
 
-    Set<Id> reviewers = new HashSet<>();
+    Set<Account.Id> reviewers = new HashSet<>();
     reviewers.add(changeToRevert.getOwner());
     reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
     reviewers.remove(user.getAccountId());
@@ -310,8 +308,9 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
-      changeReverted.fire(change, ins.getChange(), ctx.getWhen());
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
+      changeReverted.fire(
+          ctx.getChangeData(change), ctx.getChangeData(ins.getChange()), ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
@@ -335,14 +334,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.name(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.setChangeMessage(
+          ctx,
+          "Created a revert of this change as I" + computedChangeId.name(),
+          ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/git/DelegateRefDatabase.java b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
index decae05..bc5dd00 100644
--- a/java/com/google/gerrit/server/git/DelegateRefDatabase.java
+++ b/java/com/google/gerrit/server/git/DelegateRefDatabase.java
@@ -15,10 +15,12 @@
 package com.google.gerrit.server.git;
 
 import java.io.IOException;
+import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.eclipse.jgit.annotations.NonNull;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
@@ -34,7 +36,7 @@
 
   private Repository delegate;
 
-  DelegateRefDatabase(Repository delegate) {
+  public DelegateRefDatabase(Repository delegate) {
     this.delegate = delegate;
   }
 
@@ -49,11 +51,21 @@
   }
 
   @Override
+  public boolean hasVersioning() {
+    return delegate.getRefDatabase().hasVersioning();
+  }
+
+  @Override
   public boolean isNameConflicting(String name) throws IOException {
     return delegate.getRefDatabase().isNameConflicting(name);
   }
 
   @Override
+  public Collection<String> getConflictingNames(String name) throws IOException {
+    return delegate.getRefDatabase().getConflictingNames(name);
+  }
+
+  @Override
   public RefUpdate newUpdate(String name, boolean detach) throws IOException {
     return delegate.getRefDatabase().newUpdate(name, detach);
   }
@@ -64,10 +76,35 @@
   }
 
   @Override
+  public BatchRefUpdate newBatchUpdate() {
+    return delegate.getRefDatabase().newBatchUpdate();
+  }
+
+  @Override
+  public boolean performsAtomicTransactions() {
+    return delegate.getRefDatabase().performsAtomicTransactions();
+  }
+
+  @Override
   public Ref exactRef(String name) throws IOException {
     return delegate.getRefDatabase().exactRef(name);
   }
 
+  @Override
+  public Map<String, Ref> exactRef(String... refs) throws IOException {
+    return delegate.getRefDatabase().exactRef(refs);
+  }
+
+  @Override
+  public Ref firstExactRef(String... refs) throws IOException {
+    return delegate.getRefDatabase().firstExactRef(refs);
+  }
+
+  @Override
+  public List<Ref> getRefs() throws IOException {
+    return delegate.getRefDatabase().getRefs();
+  }
+
   @SuppressWarnings("deprecation")
   @Override
   public Map<String, Ref> getRefs(String prefix) throws IOException {
@@ -75,12 +112,38 @@
   }
 
   @Override
+  public List<Ref> getRefsByPrefix(String prefix) throws IOException {
+    return delegate.getRefDatabase().getRefsByPrefix(prefix);
+  }
+
+  @Override
+  public List<Ref> getRefsByPrefixWithExclusions(String include, Set<String> excludes)
+      throws IOException {
+    return delegate.getRefDatabase().getRefsByPrefixWithExclusions(include, excludes);
+  }
+
+  @Override
+  public List<Ref> getRefsByPrefix(String... prefixes) throws IOException {
+    return delegate.getRefDatabase().getRefsByPrefix(prefixes);
+  }
+
+  @Override
   @NonNull
   public Set<Ref> getTipsWithSha1(ObjectId id) throws IOException {
     return delegate.getRefDatabase().getTipsWithSha1(id);
   }
 
   @Override
+  public boolean hasFastTipsWithSha1() throws IOException {
+    return delegate.getRefDatabase().hasFastTipsWithSha1();
+  }
+
+  @Override
+  public boolean hasRefs() throws IOException {
+    return delegate.getRefDatabase().hasRefs();
+  }
+
+  @Override
   public List<Ref> getAdditionalRefs() throws IOException {
     return delegate.getRefDatabase().getAdditionalRefs();
   }
@@ -90,7 +153,12 @@
     return delegate.getRefDatabase().peel(ref);
   }
 
-  Repository getDelegate() {
+  @Override
+  public void refresh() {
+    delegate.getRefDatabase().refresh();
+  }
+
+  protected Repository getDelegate() {
     return delegate;
   }
 }
diff --git a/java/com/google/gerrit/server/git/GarbageCollection.java b/java/com/google/gerrit/server/git/GarbageCollection.java
index c37572d..6ae4d62 100644
--- a/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -18,6 +18,7 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.common.data.GarbageCollectionResult.GcError;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.server.config.GcConfig;
@@ -78,9 +79,7 @@
     Set<Project.NameKey> projectsToGc = gcQueue.addAll(projectNames);
     for (Project.NameKey projectName :
         Sets.difference(Sets.newHashSet(projectNames), projectsToGc)) {
-      result.addError(
-          new GarbageCollectionResult.Error(
-              GarbageCollectionResult.Error.Type.GC_ALREADY_SCHEDULED, projectName));
+      result.addError(new GcError(GcError.Type.GC_ALREADY_SCHEDULED, projectName));
     }
     for (Project.NameKey p : projectsToGc) {
       try (Repository repo = repoManager.openRepository(p)) {
@@ -102,13 +101,10 @@
         fire(p, statistics);
       } catch (RepositoryNotFoundException e) {
         logGcError(writer, p, e);
-        result.addError(
-            new GarbageCollectionResult.Error(
-                GarbageCollectionResult.Error.Type.REPOSITORY_NOT_FOUND, p));
+        result.addError(new GcError(GcError.Type.REPOSITORY_NOT_FOUND, p));
       } catch (Exception e) {
         logGcError(writer, p, e);
-        result.addError(
-            new GarbageCollectionResult.Error(GarbageCollectionResult.Error.Type.GC_FAILED, p));
+        result.addError(new GcError(GcError.Type.GC_FAILED, p));
       } finally {
         gcQueue.gcFinished(p);
       }
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManager.java b/java/com/google/gerrit/server/git/GitRepositoryManager.java
index e4d0696..8dba3e1 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManager.java
@@ -30,6 +30,23 @@
  */
 @ImplementedBy(value = LocalDiskRepositoryManager.class)
 public interface GitRepositoryManager {
+
+  /** Status of the repository. */
+  enum Status {
+    /** Repository exists and is available on host. */
+    ACTIVE,
+    /** Repository does not exist. */
+    NON_EXISTENT,
+    /**
+     * Repository might exist but can not be opened. This can for example be the case when the
+     * repository is pending deletion / the caller does not have permissions / repository is broken.
+     */
+    UNAVAILABLE;
+  }
+
+  /** Get {@link Status} of the repository by name. */
+  Status getRepositoryStatus(Project.NameKey name);
+
   /**
    * Get (or open) a repository by name.
    *
@@ -47,15 +64,16 @@
    * @param name the repository name, relative to the base directory.
    * @return the cached Repository instance. Caller must call {@code close()} when done to decrement
    *     the resource handle.
+   * @throws RepositoryExistsException repository exists.
    * @throws RepositoryCaseMismatchException the name collides with an existing repository name, but
    *     only in case of a character within the name.
    * @throws RepositoryNotFoundException the name is invalid.
    * @throws IOException the repository cannot be created.
    */
   Repository createRepository(Project.NameKey name)
-      throws RepositoryCaseMismatchException, RepositoryNotFoundException, IOException;
+      throws RepositoryNotFoundException, RepositoryExistsException, IOException;
 
-  /** @return set of all known projects, sorted by natural NameKey order. */
+  /** Returns set of all known projects, sorted by natural NameKey order. */
   SortedSet<Project.NameKey> list();
 
   /**
diff --git a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
index e4137b0..dfbe663 100644
--- a/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
+++ b/java/com/google/gerrit/server/git/GitRepositoryManagerModule.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.ModuleImpl;
 import com.google.gerrit.server.config.RepositoryConfig;
+import com.google.gerrit.server.git.LocalDiskRepositoryManager.LocalDiskRepositoryManagerModule;
+import com.google.gerrit.server.git.MultiBaseLocalDiskRepositoryManager.MultiBaseLocalDiskRepositoryManagerModule;
 import com.google.inject.Inject;
 
 /**
@@ -37,9 +39,9 @@
   @Override
   protected void configure() {
     if (repoConfig.getAllBasePaths().isEmpty()) {
-      install(new LocalDiskRepositoryManager.Module());
+      install(new LocalDiskRepositoryManagerModule());
     } else {
-      install(new MultiBaseLocalDiskRepositoryManager.Module());
+      install(new MultiBaseLocalDiskRepositoryManagerModule());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 10220d8..8527ff8 100644
--- a/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -16,6 +16,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.lifecycle.LifecycleModule;
@@ -52,7 +53,7 @@
 public class LocalDiskRepositoryManager implements GitRepositoryManager {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends LifecycleModule {
+  public static class LocalDiskRepositoryManagerModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(LocalDiskRepositoryManager.Lifecycle.class);
@@ -128,6 +129,29 @@
   }
 
   @Override
+  public Status getRepositoryStatus(NameKey name) {
+    if (isUnreasonableName(name)) {
+      return Status.NON_EXISTENT;
+    }
+    Path path = getBasePath(name);
+    File dir = FileKey.resolve(path.resolve(name.get()).toFile(), FS.DETECTED);
+    if (dir == null) {
+      return Status.NON_EXISTENT;
+    }
+    Repository repo;
+    try {
+      // Try to open with mustExist, so that it does not attempt to create a repository.
+      repo = RepositoryCache.open(FileKey.lenient(dir, FS.DETECTED), /*mustExist=*/ true);
+    } catch (RepositoryNotFoundException e) {
+      return Status.NON_EXISTENT;
+    } catch (IOException e) {
+      return Status.UNAVAILABLE;
+    }
+    // If object database does not exist, the repository is unusable
+    return repo.getObjectDatabase().exists() ? Status.ACTIVE : Status.UNAVAILABLE;
+  }
+
+  @Override
   public Repository openRepository(Project.NameKey name) throws RepositoryNotFoundException {
     return openRepository(getBasePath(name), name);
   }
@@ -147,7 +171,7 @@
 
   @Override
   public Repository createRepository(Project.NameKey name)
-      throws RepositoryNotFoundException, RepositoryCaseMismatchException, IOException {
+      throws RepositoryNotFoundException, RepositoryExistsException, IOException {
     if (isUnreasonableName(name)) {
       throw new RepositoryNotFoundException("Invalid name: " + name);
     }
@@ -162,8 +186,7 @@
       if (!onDiskName.equals(name)) {
         throw new RepositoryCaseMismatchException(name);
       }
-
-      throw new IllegalStateException("Repository already exists: " + name);
+      throw new RepositoryExistsException(name);
     }
 
     // It doesn't exist under any of the standard permutations
diff --git a/java/com/google/gerrit/server/git/MergeTip.java b/java/com/google/gerrit/server/git/MergeTip.java
index 204f453..4ffa1a8 100644
--- a/java/com/google/gerrit/server/git/MergeTip.java
+++ b/java/com/google/gerrit/server/git/MergeTip.java
@@ -52,8 +52,8 @@
   }
 
   /**
-   * @return the initial tip of the branch before the merge operation started; may be null,
-   *     indicating a previously unborn branch.
+   * Returns the initial tip of the branch before the merge operation started; may be null,
+   * indicating a previously unborn branch.
    */
   public CodeReviewCommit getInitialTip() {
     return initialTip;
@@ -82,8 +82,8 @@
   }
 
   /**
-   * @return The current tip of the current merge operation; may be null, indicating an unborn
-   *     branch.
+   * Returns The current tip of the current merge operation; may be null, indicating an unborn
+   * branch.
    */
   @Nullable
   public CodeReviewCommit getCurrentTip() {
diff --git a/java/com/google/gerrit/server/git/MergeUtil.java b/java/com/google/gerrit/server/git/MergeUtil.java
index 16a1ae6..c1333cb 100644
--- a/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/java/com/google/gerrit/server/git/MergeUtil.java
@@ -47,9 +47,9 @@
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -223,9 +223,8 @@
       int parentIndex,
       boolean ignoreIdenticalTree,
       boolean allowConflicts)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException,
-          MergeIdenticalTreeException, MergeConflictException, MethodNotAllowedException,
-          InvalidMergeStrategyException {
+      throws IOException, MergeIdenticalTreeException, MergeConflictException,
+          MethodNotAllowedException, InvalidMergeStrategyException {
 
     ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     m.setBase(originalCommit.getParent(parentIndex));
@@ -247,7 +246,10 @@
       }
     } else {
       if (!allowConflicts) {
-        throw new MergeConflictException("merge conflict");
+        throw new MergeConflictException(
+            String.format(
+                "merge conflict while merging commits %s and %s",
+                mergeTip.toObjectId(), originalCommit.toObjectId()));
       }
 
       if (!useContentMerge) {
@@ -510,9 +512,6 @@
    *   <li>Change-Id
    * </ul>
    *
-   * @param n
-   * @param notes
-   * @param psId
    * @return new message
    */
   private String createDetailedCommitMessage(RevCommit n, ChangeNotes notes, PatchSet.Id psId) {
@@ -600,11 +599,11 @@
       } else if (isVerified(a.labelId())) {
         tag = "Tested-by";
       } else {
-        final LabelType lt = project.getLabelTypes().byLabel(a.labelId());
-        if (lt == null) {
+        final Optional<LabelType> lt = project.getLabelTypes().byLabel(a.labelId());
+        if (!lt.isPresent()) {
           continue;
         }
-        tag = lt.getName();
+        tag = lt.get().getName();
       }
 
       if (!contains(footers, new FooterKey(tag), identbuf.toString())) {
@@ -628,10 +627,6 @@
    * Plugins implementing {@link ChangeMessageModifier} can modify the resulting commit message
    * arbitrarily.
    *
-   * @param n
-   * @param mergeTip
-   * @param notes
-   * @param id
    * @return new message
    */
   public String createCommitMessageOnSubmit(
@@ -650,7 +645,7 @@
 
   private Iterable<PatchSetApproval> safeGetApprovals(ChangeNotes notes, PatchSet.Id psId) {
     try {
-      return approvalsUtil.byPatchSet(notes, psId, null, null);
+      return approvalsUtil.byPatchSet(notes, psId);
     } catch (StorageException e) {
       logger.atSevere().withCause(e).log("Can't read approval records for %s", psId);
       return Collections.emptyList();
diff --git a/java/com/google/gerrit/server/git/MergedByPushOp.java b/java/com/google/gerrit/server/git/MergedByPushOp.java
index 78cb013..426f8db 100644
--- a/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -18,7 +18,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetInfo;
@@ -33,7 +32,7 @@
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -169,16 +168,13 @@
       }
     }
     msgBuf.append(".");
-    ChangeMessage msg =
-        ChangeMessagesUtil.newMessage(
-            psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
-    cmUtil.addChangeMessage(update, msg);
+    cmUtil.setChangeMessage(update, msgBuf.toString(), ChangeMessagesUtil.TAG_MERGED);
     update.putApproval(LabelId.legacySubmit().get(), (short) 1);
     return true;
   }
 
   @Override
-  public void postUpdate(Context ctx) {
+  public void postUpdate(PostUpdateContext ctx) {
     if (!correctBranch) {
       return;
     }
@@ -214,7 +210,8 @@
                   }
                 }));
 
-    changeMerged.fire(change, patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
+    changeMerged.fire(
+        ctx.getChangeData(change), patchSet, ctx.getAccount(), mergeResultRevId, ctx.getWhen());
   }
 
   private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
diff --git a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
index fd6506a..72e2df4 100644
--- a/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
+++ b/java/com/google/gerrit/server/git/MultiBaseLocalDiskRepositoryManager.java
@@ -35,7 +35,7 @@
 @Singleton
 public class MultiBaseLocalDiskRepositoryManager extends LocalDiskRepositoryManager {
 
-  public static class Module extends LifecycleModule {
+  public static class MultiBaseLocalDiskRepositoryManagerModule extends LifecycleModule {
     @Override
     protected void configure() {
       bind(GitRepositoryManager.class).to(MultiBaseLocalDiskRepositoryManager.class);
diff --git a/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 15fbe3f..4985288 100644
--- a/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -14,14 +14,21 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.gerrit.server.DeadlineChecker.getTimeoutFormatter;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
 
 import com.google.common.base.Strings;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
+import com.google.gerrit.server.CancellationMetrics;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.CancellationException;
 import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutionException;
@@ -48,8 +55,26 @@
  * <p>Callers should try to keep task and sub-task descriptions short, since the output should fit
  * on one terminal line. (Note that git clients do not accept terminal control characters, so true
  * multi-line progress messages would be impossible.)
+ *
+ * <p>Whether the client is disconnected or the deadline is exceeded can be checked by {@link
+ * #checkIfCancelled(RequestStateProvider.OnCancelled)}. This allows the worker thread to react to
+ * cancellations and abort its execution and finish gracefully. After a cancellation has been
+ * signaled the worker thread has 10 * {@link #maxIntervalNanos} to react to the cancellation and
+ * finish gracefully. If the worker thread doesn't finish gracefully in time after the cancellation
+ * has been signaled, the future executing the task is forcefully cancelled which means that the
+ * worker thread gets interrupted and an internal error is returned to the client. To react to
+ * cancellations it is recommended that the task opens a {@link
+ * com.google.gerrit.server.cancellation.RequestStateContext} in a try-with-resources block to
+ * register the {@link MultiProgressMonitor} as a {@link RequestStateProvider}. This way the worker
+ * thread gets aborted by a {@link com.google.gerrit.server.cancellation.RequestCancelledException}
+ * when the request is cancelled which allows the worker thread to handle the cancellation
+ * gracefully by catching this exception (e.g. to return a proper error message). {@link
+ * com.google.gerrit.server.cancellation.RequestCancelledException} is only thrown when the worker
+ * thread checks for cancellation via {@link
+ * com.google.gerrit.server.cancellation.RequestStateContext#abortIfCancelled()}. E.g. this is done
+ * whenever {@link com.google.gerrit.server.logging.TraceContext.TraceTimer} is opened/closed.
  */
-public class MultiProgressMonitor {
+public class MultiProgressMonitor implements RequestStateProvider {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   /** Constant indicating the total work units cannot be predicted. */
@@ -58,6 +83,11 @@
   private static final char[] SPINNER_STATES = new char[] {'-', '\\', '|', '/'};
   private static final char NO_SPINNER = ' ';
 
+  public enum TaskKind {
+    INDEXING,
+    RECEIVE_COMMITS;
+  }
+
   /** Handle for a sub-task. */
   public class Task implements ProgressMonitor {
     private final String name;
@@ -185,13 +215,29 @@
     }
   }
 
+  public interface Factory {
+    MultiProgressMonitor create(OutputStream out, TaskKind taskKind, String taskName);
+
+    MultiProgressMonitor create(
+        OutputStream out,
+        TaskKind taskKind,
+        String taskName,
+        long maxIntervalTime,
+        TimeUnit maxIntervalUnit);
+  }
+
+  private final CancellationMetrics cancellationMetrics;
   private final OutputStream out;
+  private final TaskKind taskKind;
   private final String taskName;
   private final List<Task> tasks = new CopyOnWriteArrayList<>();
   private int spinnerIndex;
   private char spinnerState = NO_SPINNER;
   private boolean done;
-  private boolean write = true;
+  private boolean clientDisconnected;
+  private boolean deadlineExceeded;
+  private boolean forcefulTermination;
+  private Optional<Long> timeout = Optional.empty();
 
   private final long maxIntervalNanos;
 
@@ -201,8 +247,13 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
-  public MultiProgressMonitor(OutputStream out, String taskName) {
-    this(out, taskName, 500, TimeUnit.MILLISECONDS);
+  @AssistedInject
+  private MultiProgressMonitor(
+      CancellationMetrics cancellationMetrics,
+      @Assisted OutputStream out,
+      @Assisted TaskKind taskKind,
+      @Assisted String taskName) {
+    this(cancellationMetrics, out, taskKind, taskName, 500, MILLISECONDS);
   }
 
   /**
@@ -213,9 +264,17 @@
    * @param maxIntervalTime maximum interval between progress messages.
    * @param maxIntervalUnit time unit for progress interval.
    */
-  public MultiProgressMonitor(
-      OutputStream out, String taskName, long maxIntervalTime, TimeUnit maxIntervalUnit) {
+  @AssistedInject
+  private MultiProgressMonitor(
+      CancellationMetrics cancellationMetrics,
+      @Assisted OutputStream out,
+      @Assisted TaskKind taskKind,
+      @Assisted String taskName,
+      @Assisted long maxIntervalTime,
+      @Assisted TimeUnit maxIntervalUnit) {
+    this.cancellationMetrics = cancellationMetrics;
     this.out = out;
+    this.taskKind = taskKind;
     this.taskName = taskName;
     maxIntervalNanos = NANOSECONDS.convert(maxIntervalTime, maxIntervalUnit);
   }
@@ -223,11 +282,16 @@
   /**
    * Wait for a task managed by a {@link Future}, with no timeout.
    *
-   * @see #waitFor(Future, long, TimeUnit)
+   * @see #waitFor(Future, long, TimeUnit, long, TimeUnit)
    */
   public <T> T waitFor(Future<T> workerFuture) {
     try {
-      return waitFor(workerFuture, 0, null);
+      return waitFor(
+          workerFuture,
+          /* taskTimeoutTime= */ 0,
+          /* taskTimeoutUnit= */ null,
+          /* cancellationTimeoutTime= */ 0,
+          /* cancellationTimeoutUnit= */ null);
     } catch (TimeoutException e) {
       throw new IllegalStateException("timout exception without setting a timeout", e);
     }
@@ -242,15 +306,30 @@
    *
    * @see #waitForNonFinalTask(Future, long, TimeUnit)
    * @param workerFuture a future that returns when worker threads are finished.
-   * @param timeoutTime overall timeout for the task; the future is forcefully cancelled if the task
-   *     exceeds the timeout. Non-positive values indicate no timeout.
-   * @param timeoutUnit unit for overall task timeout.
+   * @param taskTimeoutTime overall timeout for the task; the future gets a cancellation signal
+   *     after this timeout is exceeded; non-positive values indicate no timeout.
+   * @param taskTimeoutUnit unit for overall task timeout.
+   * @param cancellationTimeoutTime timeout for the task to react to the cancellation signal; if the
+   *     task doesn't terminate within this time it is forcefully cancelled; non-positive values
+   *     indicate no timeout.
+   * @param cancellationTimeoutUnit unit for the cancellation timeout.
    * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was
    *     cancelled, or timed out waiting for a worker to call {@link #end()}.
    */
-  public <T> T waitFor(Future<T> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
+  public <T> T waitFor(
+      Future<T> workerFuture,
+      long taskTimeoutTime,
+      TimeUnit taskTimeoutUnit,
+      long cancellationTimeoutTime,
+      TimeUnit cancellationTimeoutUnit)
       throws TimeoutException {
-    T t = waitForNonFinalTask(workerFuture, timeoutTime, timeoutUnit);
+    T t =
+        waitForNonFinalTask(
+            workerFuture,
+            taskTimeoutTime,
+            taskTimeoutUnit,
+            cancellationTimeoutTime,
+            cancellationTimeoutUnit);
     synchronized (this) {
       if (!done) {
         // The worker may not have called end() explicitly, which is likely a
@@ -270,7 +349,7 @@
    */
   public <T> T waitForNonFinalTask(Future<T> workerFuture) {
     try {
-      return waitForNonFinalTask(workerFuture, 0, null);
+      return waitForNonFinalTask(workerFuture, 0, null, 0, null);
     } catch (TimeoutException e) {
       throw new IllegalStateException("timout exception without setting a timeout", e);
     }
@@ -281,18 +360,32 @@
    * call {@link #end()}. It is intended to be used to track a non-final task.
    *
    * @param workerFuture a future that returns when worker threads are finished.
-   * @param timeoutTime overall timeout for the task; the future is forcefully cancelled if the task
-   *     exceeds the timeout. Non-positive values indicate no timeout.
-   * @param timeoutUnit unit for overall task timeout.
+   * @param taskTimeoutTime overall timeout for the task; the future is forcefully cancelled if the
+   *     task exceeds the timeout. Non-positive values indicate no timeout.
+   * @param taskTimeoutUnit unit for overall task timeout.
+   * @param cancellationTimeoutTime timeout for the task to react to the cancellation signal; if the
+   *     task doesn't terminate within this time it is forcefully cancelled; non-positive values
+   *     indicate no timeout.
+   * @param cancellationTimeoutUnit unit for the cancellation timeout.
    * @throws TimeoutException if this thread or a worker thread was interrupted, the worker was
    *     cancelled, or timed out waiting for a worker to call {@link #end()}.
    */
-  public <T> T waitForNonFinalTask(Future<T> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
+  public <T> T waitForNonFinalTask(
+      Future<T> workerFuture,
+      long taskTimeoutTime,
+      TimeUnit taskTimeoutUnit,
+      long cancellationTimeoutTime,
+      TimeUnit cancellationTimeoutUnit)
       throws TimeoutException {
     long overallStart = System.nanoTime();
+    long cancellationNanos =
+        cancellationTimeoutTime > 0
+            ? NANOSECONDS.convert(cancellationTimeoutTime, cancellationTimeoutUnit)
+            : 0;
     long deadline;
-    if (timeoutTime > 0) {
-      deadline = overallStart + NANOSECONDS.convert(timeoutTime, timeoutUnit);
+    if (taskTimeoutTime > 0) {
+      timeout = Optional.of(NANOSECONDS.convert(taskTimeoutTime, taskTimeoutUnit));
+      deadline = overallStart + timeout.get();
     } else {
       deadline = 0;
     }
@@ -312,14 +405,35 @@
         long now = System.nanoTime();
 
         if (deadline > 0 && now > deadline) {
-          workerFuture.cancel(true);
-          if (workerFuture.isCancelled()) {
-            logger.atWarning().log(
-                "MultiProgressMonitor worker killed after %sms: (timeout %sms, cancelled)",
-                TimeUnit.MILLISECONDS.convert(now - overallStart, NANOSECONDS),
-                TimeUnit.MILLISECONDS.convert(now - deadline, NANOSECONDS));
+          if (!deadlineExceeded) {
+            logger.atFine().log(
+                "deadline exceeded after %sms, signaling cancellation (timeout=%sms, task=%s(%s))",
+                MILLISECONDS.convert(now - overallStart, NANOSECONDS),
+                MILLISECONDS.convert(now - deadline, NANOSECONDS),
+                taskKind,
+                taskName);
           }
-          break;
+          deadlineExceeded = true;
+
+          // After setting deadlineExceeded = true give the cancellationNanos to react to the
+          // cancellation and return gracefully.
+          if (now > deadline + cancellationNanos) {
+            // The worker didn't react to the cancellation, cancel it forcefully by an interrupt.
+            workerFuture.cancel(true);
+            forcefulTermination = true;
+            if (workerFuture.isCancelled()) {
+              logger.atWarning().log(
+                  "MultiProgressMonitor worker killed after %sms, cancelled (timeout=%sms, task=%s(%s))",
+                  MILLISECONDS.convert(now - overallStart, NANOSECONDS),
+                  MILLISECONDS.convert(now - deadline, NANOSECONDS),
+                  taskKind,
+                  taskName);
+              if (taskKind == TaskKind.RECEIVE_COMMITS) {
+                cancellationMetrics.countForcefulReceiveTimeout();
+              }
+            }
+            break;
+          }
         }
 
         left -= now - start;
@@ -329,15 +443,19 @@
         }
         sendUpdate();
       }
+      if (deadlineExceeded && !forcefulTermination && taskKind == TaskKind.RECEIVE_COMMITS) {
+        cancellationMetrics.countGracefulReceiveTimeout();
+      }
       wakeUp();
     }
 
     // The loop exits as soon as the worker calls end(), but we give it another
-    // maxInterval to finish up and return.
+    // 2 x maxIntervalNanos to finish up and return.
     try {
-      return workerFuture.get(maxIntervalNanos, NANOSECONDS);
+      return workerFuture.get(2 * maxIntervalNanos, NANOSECONDS);
     } catch (InterruptedException | CancellationException e) {
-      logger.atWarning().withCause(e).log("unable to finish processing");
+      logger.atWarning().withCause(e).log(
+          "unable to finish processing (task=%s(%s))", taskKind, taskName);
       throw new UncheckedExecutionException(e);
     } catch (TimeoutException e) {
       workerFuture.cancel(true);
@@ -451,15 +569,32 @@
   }
 
   private void send(StringBuilder s) {
-    if (write) {
+    if (!clientDisconnected) {
       try {
         out.write(Constants.encode(s.toString()));
         out.flush();
       } catch (IOException e) {
         logger.atWarning().withCause(e).log(
-            "Sending progress to client failed. Stop sending updates for task %s", taskName);
-        write = false;
+            "Sending progress to client failed. Stop sending updates for task %s(%s)",
+            taskKind, taskName);
+        clientDisconnected = true;
       }
     }
   }
+
+  @Override
+  public void checkIfCancelled(OnCancelled onCancelled) {
+    if (clientDisconnected) {
+      onCancelled.onCancel(RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* message= */ null);
+    } else if (deadlineExceeded) {
+      onCancelled.onCancel(
+          RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+          timeout
+              .map(
+                  taskKind == TaskKind.RECEIVE_COMMITS
+                      ? getTimeoutFormatter("receive.timeout")
+                      : getTimeoutFormatter("timeout"))
+              .orElse(null));
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
index 7950dc6..f66a089 100644
--- a/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
+++ b/java/com/google/gerrit/server/git/PermissionAwareReadOnlyRefDatabase.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
@@ -32,8 +33,10 @@
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.eclipse.jgit.annotations.NonNull;
 import org.eclipse.jgit.annotations.Nullable;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefRename;
@@ -60,6 +63,11 @@
   }
 
   @Override
+  public Collection<String> getConflictingNames(String name) throws IOException {
+    throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
+  }
+
+  @Override
   public RefUpdate newUpdate(String name, boolean detach) {
     throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
   }
@@ -70,6 +78,11 @@
   }
 
   @Override
+  public BatchRefUpdate newBatchUpdate() {
+    throw new UnsupportedOperationException("PermissionAwareReadOnlyRefDatabase is read-only");
+  }
+
+  @Override
   public Ref exactRef(String name) throws IOException {
     Ref ref = getDelegate().getRefDatabase().exactRef(name);
     if (ref == null) {
@@ -130,6 +143,25 @@
   }
 
   @Override
+  public List<Ref> getRefsByPrefixWithExclusions(String include, Set<String> excludes)
+      throws IOException {
+    Stream<Ref> refs = getRefs(include).values().stream();
+    for (String exclude : excludes) {
+      refs = refs.filter(r -> !r.getName().startsWith(exclude));
+    }
+    return Collections.unmodifiableList(refs.collect(Collectors.toList()));
+  }
+
+  @Override
+  public List<Ref> getRefsByPrefix(String... prefixes) throws IOException {
+    List<Ref> result = new ArrayList<>();
+    for (String prefix : prefixes) {
+      result.addAll(getRefsByPrefix(prefix));
+    }
+    return Collections.unmodifiableList(result);
+  }
+
+  @Override
   @NonNull
   public Map<String, Ref> exactRef(String... refs) throws IOException {
     Map<String, Ref> result = new HashMap<>(refs.length);
@@ -155,6 +187,11 @@
   }
 
   @Override
+  public List<Ref> getRefs() throws IOException {
+    return getRefsByPrefix(ALL);
+  }
+
+  @Override
   @NonNull
   public Set<Ref> getTipsWithSha1(ObjectId id) throws IOException {
     Set<Ref> unfiltered = super.getTipsWithSha1(id);
@@ -166,4 +203,9 @@
     }
     return result;
   }
+
+  @Override
+  public boolean hasRefs() throws IOException {
+    return !getRefs().isEmpty();
+  }
 }
diff --git a/java/com/google/gerrit/server/git/RepoRefCache.java b/java/com/google/gerrit/server/git/RepoRefCache.java
index 9086da7..7f22111 100644
--- a/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -58,7 +58,7 @@
     return id;
   }
 
-  /** @return an unmodifiable view of the refs that have been cached by this instance. */
+  /** Returns an unmodifiable view of the refs that have been cached by this instance. */
   public Map<String, Optional<ObjectId>> getCachedRefs() {
     return Collections.unmodifiableMap(ids);
   }
diff --git a/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java b/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
index 8535cd2..9e10c67 100644
--- a/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
+++ b/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.git;
 
 import com.google.gerrit.entities.Project;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 /**
  * This exception is thrown if a project cannot be created because a project with the same name in a
@@ -23,12 +22,12 @@
  * (e.g. Windows), because in this case the name for the git repository in the file system is
  * already occupied by the existing project.
  */
-public class RepositoryCaseMismatchException extends RepositoryNotFoundException {
+public class RepositoryCaseMismatchException extends RepositoryExistsException {
 
   private static final long serialVersionUID = 1L;
 
   /** @param projectName name of the project that cannot be created */
   public RepositoryCaseMismatchException(Project.NameKey projectName) {
-    super("Name occupied in other case. Project " + projectName.get() + " cannot be created.");
+    super(projectName, "Name occupied in other case.");
   }
 }
diff --git a/java/com/google/gerrit/server/git/RepositoryExistsException.java b/java/com/google/gerrit/server/git/RepositoryExistsException.java
new file mode 100644
index 0000000..563b078
--- /dev/null
+++ b/java/com/google/gerrit/server/git/RepositoryExistsException.java
@@ -0,0 +1,37 @@
+// 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.git;
+
+import com.google.gerrit.entities.Project;
+import java.io.IOException;
+
+/** Thrown when trying to create a repository that exist. */
+public class RepositoryExistsException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  /**
+   * @param projectName name of the project that cannot be created
+   * @param reason reason why the project cannot be created
+   */
+  public RepositoryExistsException(Project.NameKey projectName, String reason) {
+    super(
+        String.format("Repository %s exists and cannot be created. %s", projectName.get(), reason));
+  }
+
+  /** @param projectName name of the project that cannot be created */
+  public RepositoryExistsException(Project.NameKey projectName) {
+    super(String.format("Repository %s exists and cannot be created.", projectName.get()));
+  }
+}
diff --git a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
index fed6541..ff5bcc2 100644
--- a/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
+++ b/java/com/google/gerrit/server/git/SearchingChangeCacheImpl.java
@@ -59,14 +59,14 @@
 
   static final String ID_CACHE = "changes";
 
-  public static class Module extends CacheModule {
+  public static class SearchingChangeCacheImplModule extends CacheModule {
     private final boolean slave;
 
-    public Module() {
+    public SearchingChangeCacheImplModule() {
       this(false);
     }
 
-    public Module(boolean slave) {
+    public SearchingChangeCacheImplModule(boolean slave) {
       this.slave = slave;
     }
 
diff --git a/java/com/google/gerrit/server/git/TagSet.java b/java/com/google/gerrit/server/git/TagSet.java
index d6220a2..57fcf71 100644
--- a/java/com/google/gerrit/server/git/TagSet.java
+++ b/java/com/google/gerrit/server/git/TagSet.java
@@ -43,6 +43,17 @@
 import org.eclipse.jgit.revwalk.RevSort;
 import org.eclipse.jgit.revwalk.RevWalk;
 
+/**
+ * Builds a set of tags, and tracks which tags are reachable from which non-tag, non-special refs.
+ * An instance is constructed from a snapshot of the ref database. TagSets can be incrementally
+ * updated to newer states of the RefDatabase using the refresh method. The updateFastForward method
+ * can do partial updates based on individual refs moving forward.
+ *
+ * <p>This set is used to determine which tags should be advertised when only a subset of refs is
+ * visible to a user.
+ *
+ * <p>TagSets can be serialized for use in a persisted TagCache
+ */
 class TagSet {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
   private static final ImmutableSet<String> SKIPPABLE_REF_PREFIXES =
@@ -53,7 +64,14 @@
           RefNames.REFS_STARRED_CHANGES);
 
   private final Project.NameKey projectName;
+
+  /**
+   * refName => ref. CachedRef is a ref that has an integer identity, used for indexing into
+   * BitSets.
+   */
   private final Map<String, CachedRef> refs;
+
+  /** ObjectId-pointed-to-by-tag => Tag */
   private final ObjectIdOwnerMap<Tag> tags;
 
   TagSet(Project.NameKey projectName) {
@@ -86,13 +104,16 @@
     return tags;
   }
 
+  /** Record a fast-forward update of the given ref. This is called from multiple threads. */
   boolean updateFastForward(String refName, ObjectId oldValue, ObjectId newValue) {
+    // this looks fishy: refs is not a thread-safe data structure, but it is mutated in build() and
+    // rebuild(). TagSetHolder prohibits concurrent writes through the buildLock mutex, but it does
+    // not prohibit concurrent read/write.
     CachedRef ref = refs.get(refName);
     if (ref != null) {
       // compareAndSet works on reference equality, but this operation
       // wants to use object equality. Switch out oldValue with cur so the
       // compareAndSet will function correctly for this operation.
-      //
       ObjectId cur = ref.get();
       if (cur.equals(oldValue)) {
         return ref.compareAndSet(cur, newValue);
@@ -391,6 +412,9 @@
   }
 
   static final class Tag extends ObjectIdOwnerMap.Entry {
+
+    // a RefCache.flag => isVisible map. This reference is aliased to the
+    // bitset in TagCommit.refFlags.
     @VisibleForTesting final BitSet refFlags;
 
     Tag(AnyObjectId id, BitSet flags) {
@@ -407,11 +431,12 @@
       return MoreObjects.toStringHelper(this).addValue(name()).add("refFlags", refFlags).toString();
     }
   }
-
+  /** A ref along with its index into BitSet. */
   @VisibleForTesting
   static final class CachedRef extends AtomicReference<ObjectId> {
     private static final long serialVersionUID = 1L;
 
+    /** unique identifier for this ref within the TagSet. */
     final int flag;
 
     CachedRef(Ref ref, int flag) {
@@ -444,7 +469,9 @@
     }
   }
 
+  // TODO(hanwen): this would be better named as CommitWithReachability, as it also holds non-tags.
   private static final class TagCommit extends RevCommit {
+    /** CachedRef.flag => isVisible, indicating if this commit is reachable from the ref. */
     final BitSet refFlags;
 
     TagCommit(AnyObjectId id) {
diff --git a/java/com/google/gerrit/server/git/TransferConfig.java b/java/com/google/gerrit/server/git/TransferConfig.java
index 55b9448..728e4ed 100644
--- a/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/java/com/google/gerrit/server/git/TransferConfig.java
@@ -52,7 +52,7 @@
     packConfig.fromConfig(cfg);
   }
 
-  /** @return configured timeout, in seconds. 0 if the timeout is infinite. */
+  /** Returns configured timeout, in seconds. 0 if the timeout is infinite. */
   public int getTimeout() {
     return timeout;
   }
diff --git a/java/com/google/gerrit/server/git/UploadPackMetricsHook.java b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
index 4afff2b..1619add 100644
--- a/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
+++ b/java/com/google/gerrit/server/git/UploadPackMetricsHook.java
@@ -45,7 +45,9 @@
   @Inject
   UploadPackMetricsHook(MetricMaker metricMaker) {
     Field<Operation> operationField =
-        Field.ofEnum(Operation.class, "operation", Metadata.Builder::gitOperation).build();
+        Field.ofEnum(Operation.class, "operation", Metadata.Builder::gitOperation)
+            .description("The name of the operation (CLONE, FETCH).")
+            .build();
     requestCount =
         metricMaker.newCounter(
             "git/upload-pack/request_count",
diff --git a/java/com/google/gerrit/server/git/WorkQueue.java b/java/com/google/gerrit/server/git/WorkQueue.java
index 4b08040..8b59474 100644
--- a/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/java/com/google/gerrit/server/git/WorkQueue.java
@@ -76,7 +76,7 @@
     }
   }
 
-  public static class Module extends LifecycleModule {
+  public static class WorkQueueModule extends LifecycleModule {
     @Override
     protected void configure() {
       bind(WorkQueue.class);
diff --git a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
index e90f58b..27d5da9 100644
--- a/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
+++ b/java/com/google/gerrit/server/git/meta/MetaDataUpdate.java
@@ -160,7 +160,7 @@
       return create(name, null);
     }
 
-    /** @see User#create(Project.NameKey, IdentifiedUser, BatchRefUpdate) */
+    /** See {@link User#create(Project.NameKey, IdentifiedUser, BatchRefUpdate)} */
     public MetaDataUpdate create(Project.NameKey name, BatchRefUpdate batch)
         throws RepositoryNotFoundException, IOException {
       Repository repo = mgr.openRepository(name);
@@ -234,7 +234,7 @@
     this.closeRepository = closeRepository;
   }
 
-  /** @return batch in which to run the update, or {@code null} for no batch. */
+  /** Returns batch in which to run the update, or {@code null} for no batch. */
   BatchRefUpdate getBatch() {
     return batch;
   }
diff --git a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
index ea594dd..61bd8a8 100644
--- a/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
+++ b/java/com/google/gerrit/server/git/meta/VersionedMetaData.java
@@ -35,6 +35,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Objects;
+import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -99,7 +100,7 @@
   protected ObjectInserter inserter;
   protected DirCache newTree;
 
-  /** @return name of the reference storing this configuration. */
+  /** Returns name of the reference storing this configuration. */
   protected abstract String getRefName();
 
   /** Set up the metadata, parsing any state from the loaded revision. */
@@ -109,13 +110,11 @@
    * Save any changes to the metadata in a commit.
    *
    * @return true if the commit should proceed, false to abort.
-   * @throws IOException
-   * @throws ConfigInvalidException
    */
   protected abstract boolean onSave(CommitBuilder commit)
       throws IOException, ConfigInvalidException;
 
-  /** @return revision of the metadata that was loaded. */
+  /** Returns revision of the metadata that was loaded. */
   @Nullable
   public ObjectId getRevision() {
     return ObjectIds.copyOrNull(revision);
@@ -129,8 +128,6 @@
    *
    * @param projectName the name of the project
    * @param db repository to access.
-   * @throws IOException
-   * @throws ConfigInvalidException
    */
   public void load(Project.NameKey projectName, Repository db)
       throws IOException, ConfigInvalidException {
@@ -151,8 +148,6 @@
    * @param projectName the name of the project
    * @param db repository to access.
    * @param id revision to load.
-   * @throws IOException
-   * @throws ConfigInvalidException
    */
   public void load(Project.NameKey projectName, Repository db, @Nullable ObjectId id)
       throws IOException, ConfigInvalidException {
@@ -175,8 +170,6 @@
    * @param projectName the name of the project
    * @param walk open walk to access to access.
    * @param id revision to load.
-   * @throws IOException
-   * @throws ConfigInvalidException
    */
   public void load(Project.NameKey projectName, RevWalk walk, ObjectId id)
       throws IOException, ConfigInvalidException {
@@ -515,12 +508,12 @@
   }
 
   protected Config readConfig(String fileName) throws IOException, ConfigInvalidException {
-    return readConfig(fileName, null);
+    return readConfig(fileName, Optional.empty());
   }
 
-  protected Config readConfig(String fileName, Config baseConfig)
+  protected Config readConfig(String fileName, Optional<? extends Config> baseConfig)
       throws IOException, ConfigInvalidException {
-    Config rc = new Config(baseConfig);
+    Config rc = new Config(baseConfig.isPresent() ? baseConfig.get() : null);
     String text = readUTF8(fileName);
     if (!text.isEmpty()) {
       try {
diff --git a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
index d037994..85d7db0 100644
--- a/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.ReceiveCommitsExecutor;
 import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.TaskKind;
 import com.google.gerrit.server.git.PermissionAwareRepositoryManager;
 import com.google.gerrit.server.git.ProjectRunnable;
 import com.google.gerrit.server.git.TransferConfig;
@@ -92,7 +93,9 @@
 public class AsyncReceiveCommits {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
+  private static final String RECEIVE_OVERALL_TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
+  private static final String RECEIVE_CANCELLATION_TIMEOUT_NAME =
+      "ReceiveCommitsCancellationTimeout";
 
   public interface Factory {
     AsyncReceiveCommits create(
@@ -102,7 +105,7 @@
         @Nullable MessageSender messageSender);
   }
 
-  public static class Module extends PrivateModule {
+  public static class AsyncReceiveCommitsModule extends PrivateModule {
     @Override
     public void configure() {
       install(new FactoryModuleBuilder().build(LazyPostReceiveHookChain.Factory.class));
@@ -117,15 +120,29 @@
 
     @Provides
     @Singleton
-    @Named(TIMEOUT_NAME)
-    long getTimeoutMillis(@GerritServerConfig Config cfg) {
+    @Named(RECEIVE_OVERALL_TIMEOUT_NAME)
+    long getReceiveTimeoutMillis(@GerritServerConfig Config cfg) {
       return ConfigUtil.getTimeUnit(
           cfg, "receive", null, "timeout", TimeUnit.MINUTES.toMillis(4), TimeUnit.MILLISECONDS);
     }
+
+    @Provides
+    @Singleton
+    @Named(RECEIVE_CANCELLATION_TIMEOUT_NAME)
+    long getCancellationTimeoutMillis(@GerritServerConfig Config cfg) {
+      return ConfigUtil.getTimeUnit(
+          cfg,
+          "receive",
+          null,
+          "cancellationTimeout",
+          TimeUnit.SECONDS.toMillis(5),
+          TimeUnit.MILLISECONDS);
+    }
   }
 
-  private static MultiProgressMonitor newMultiProgressMonitor(MessageSender messageSender) {
-    return new MultiProgressMonitor(
+  private static MultiProgressMonitor newMultiProgressMonitor(
+      MultiProgressMonitor.Factory multiProgressMonitorFactory, MessageSender messageSender) {
+    return multiProgressMonitorFactory.create(
         new OutputStream() {
           @Override
           public void write(int b) {
@@ -147,6 +164,7 @@
             messageSender.flush();
           }
         },
+        TaskKind.RECEIVE_COMMITS,
         "Processing changes");
   }
 
@@ -204,6 +222,7 @@
     }
   }
 
+  private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
   private final Metrics metrics;
   private final ReceiveCommits receiveCommits;
   private final PermissionBackend.ForProject perm;
@@ -212,7 +231,8 @@
   private final RequestScopePropagator scopePropagator;
   private final ReceiveConfig receiveConfig;
   private final ContributorAgreementsChecker contributorAgreements;
-  private final long timeoutMillis;
+  private final long receiveTimeoutMillis;
+  private final long cancellationTimeoutMillis;
   private final ProjectState projectState;
   private final IdentifiedUser user;
   private final Repository repo;
@@ -220,6 +240,7 @@
 
   @Inject
   AsyncReceiveCommits(
+      MultiProgressMonitor.Factory multiProgressMonitorFactory,
       ReceiveCommits.Factory factory,
       PermissionBackend permissionBackend,
       Provider<InternalChangeQuery> queryProvider,
@@ -233,17 +254,20 @@
       QuotaBackend quotaBackend,
       UsersSelfAdvertiseRefsHook usersSelfAdvertiseRefsHook,
       AllUsersName allUsersName,
-      @Named(TIMEOUT_NAME) long timeoutMillis,
+      @Named(RECEIVE_OVERALL_TIMEOUT_NAME) long receiveTimeoutMillis,
+      @Named(RECEIVE_CANCELLATION_TIMEOUT_NAME) long cancellationTimeoutMillis,
       @Assisted ProjectState projectState,
       @Assisted IdentifiedUser user,
       @Assisted Repository repo,
       @Assisted @Nullable MessageSender messageSender)
       throws PermissionBackendException {
+    this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.executor = executor;
     this.scopePropagator = scopePropagator;
     this.receiveConfig = receiveConfig;
     this.contributorAgreements = contributorAgreements;
-    this.timeoutMillis = timeoutMillis;
+    this.receiveTimeoutMillis = receiveTimeoutMillis;
+    this.cancellationTimeoutMillis = cancellationTimeoutMillis;
     this.projectState = projectState;
     this.user = user;
     this.repo = repo;
@@ -331,10 +355,6 @@
       try {
         result = preReceive(commands);
       } catch (TimeoutException e) {
-        metrics.timeouts.increment();
-        logger.atWarning().withCause(e).log(
-            "Timeout in ReceiveCommits while processing changes for project %s",
-            projectState.getName());
         receivePack.sendError("timeout while processing changes");
         rejectCommandsNotAttempted(commands);
         return;
@@ -361,7 +381,8 @@
       return ReceiveCommitsResult.empty();
     }
     String currentThreadName = Thread.currentThread().getName();
-    MultiProgressMonitor monitor = newMultiProgressMonitor(receiveCommits.getMessageSender());
+    MultiProgressMonitor monitor =
+        newMultiProgressMonitor(multiProgressMonitorFactory, receiveCommits.getMessageSender());
     Callable<ReceiveCommitsResult> callable =
         () -> {
           String oldName = Thread.currentThread().getName();
@@ -379,12 +400,22 @@
           ProjectRunnable.fromCallable(
               callable, receiveCommits.getProject().getNameKey(), "receive-commits", null, false);
       monitor.waitFor(
-          executor.submit(scopePropagator.wrap(runnable)), timeoutMillis, TimeUnit.MILLISECONDS);
+          executor.submit(scopePropagator.wrap(runnable)),
+          receiveTimeoutMillis,
+          TimeUnit.MILLISECONDS,
+          cancellationTimeoutMillis,
+          TimeUnit.MILLISECONDS);
       if (!runnable.isDone()) {
         // At this point we are either done or have thrown a TimeoutException and bailed out.
         throw new IllegalStateException("unable to get receive commits result");
       }
       return runnable.get();
+    } catch (TimeoutException e) {
+      metrics.timeouts.increment();
+      logger.atWarning().withCause(e).log(
+          "Timeout in ReceiveCommits while processing changes for project %s",
+          projectState.getName());
+      throw e;
     } catch (InterruptedException | ExecutionException e) {
       throw new UncheckedExecutionException(e);
     }
diff --git a/java/com/google/gerrit/server/git/receive/BUILD b/java/com/google/gerrit/server/git/receive/BUILD
index b59d431..5c1cf52 100644
--- a/java/com/google/gerrit/server/git/receive/BUILD
+++ b/java/com/google/gerrit/server/git/receive/BUILD
@@ -18,6 +18,7 @@
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
         "//java/com/google/gerrit/server/restapi",
         "//java/com/google/gerrit/server/util/time",
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
index cca8681..55d7af0 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -46,6 +46,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
+import com.google.common.base.Throwables;
 import com.google.common.collect.BiMap;
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
@@ -100,17 +101,28 @@
 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.ApprovalsUtil;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Counter3;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.CancellationMetrics;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CreateGroupPermissionSyncer;
+import com.google.gerrit.server.DeadlineChecker;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.InvalidDeadlineException;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.PublishCommentsOp;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateContext;
+import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.SetHashtagsOp;
@@ -172,7 +184,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.RetryHelper;
@@ -187,6 +199,7 @@
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.util.Providers;
 import java.io.IOException;
@@ -309,6 +322,37 @@
     return RestApiException.wrap("Error inserting change/patchset", e);
   }
 
+  @Singleton
+  private static class Metrics {
+    private final Counter0 psRevisionMissing;
+    private final Counter3<String, String, String> pushCount;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      psRevisionMissing =
+          metricMaker.newCounter(
+              "receivecommits/ps_revision_missing",
+              new Description("errors due to patch set revision missing"));
+      pushCount =
+          metricMaker.newCounter(
+              "receivecommits/push_count",
+              new Description("number of pushes"),
+              Field.ofString("kind", (metadataBuilder, fieldValue) -> {})
+                  .description("The push kind (direct vs. magic).")
+                  .build(),
+              Field.ofString(
+                      "project",
+                      (metadataBuilder, fieldValue) -> metadataBuilder.projectName(fieldValue))
+                  .description("The name of the project for which the push is done.")
+                  .build(),
+              Field.ofString("type", (metadataBuilder, fieldValue) -> {})
+                  .description(
+                      "The type of the update (CREATE, UPDATE, CREATE/UPDATE,"
+                          + " UPDATE_NONFASTFORWARD, DELETE).")
+                  .build());
+    }
+  }
+
   // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
   // somewhat, and kept sorted lexicographically within sections, except where later assignments
   // depend on previous ones.
@@ -317,6 +361,7 @@
   private final AccountResolver accountResolver;
   private final AllProjectsName allProjectsName;
   private final BatchUpdate.Factory batchUpdateFactory;
+  private final CancellationMetrics cancellationMetrics;
   private final ChangeEditUtil editUtil;
   private final ChangeIndexer indexer;
   private final ChangeInserter.Factory changeInserterFactory;
@@ -329,10 +374,12 @@
   private final Config config;
   private final CreateGroupPermissionSyncer createGroupPermissionSyncer;
   private final CreateRefControl createRefControl;
+  private final DeadlineChecker.Factory deadlineCheckerFactory;
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final DynamicSet<PluginPushOption> pluginPushOptions;
   private final PluginSetContext<ReceivePackInitializer> initializers;
   private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final Metrics metrics;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final DynamicSet<PerformanceLogger> performanceLoggers;
@@ -401,6 +448,7 @@
       AccountResolver accountResolver,
       AllProjectsName allProjectsName,
       BatchUpdate.Factory batchUpdateFactory,
+      CancellationMetrics cancellationMetrics,
       ProjectConfig.Factory projectConfigFactory,
       @GerritServerConfig Config config,
       ChangeEditUtil editUtil,
@@ -413,11 +461,13 @@
       BranchCommitValidator.Factory commitValidatorFactory,
       CreateGroupPermissionSyncer createGroupPermissionSyncer,
       CreateRefControl createRefControl,
+      DeadlineChecker.Factory deadlineCheckerFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       DynamicSet<PluginPushOption> pluginPushOptions,
       PluginSetContext<ReceivePackInitializer> initializers,
       PluginSetContext<CommentValidator> commentValidators,
       MergedByPushOp.Factory mergedByPushOpFactory,
+      Metrics metrics,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       DynamicSet<PerformanceLogger> performanceLoggers,
@@ -454,6 +504,7 @@
     this.accountResolver = accountResolver;
     this.allProjectsName = allProjectsName;
     this.batchUpdateFactory = batchUpdateFactory;
+    this.cancellationMetrics = cancellationMetrics;
     this.changeFormatter = changeFormatterProvider.get();
     this.changeInserterFactory = changeInserterFactory;
     this.commentsUtil = commentsUtil;
@@ -462,6 +513,7 @@
     this.config = config;
     this.createRefControl = createRefControl;
     this.createGroupPermissionSyncer = createGroupPermissionSyncer;
+    this.deadlineCheckerFactory = deadlineCheckerFactory;
     this.editUtil = editUtil;
     this.hashtagsFactory = hashtagsFactory;
     this.setTopicFactory = setTopicFactory;
@@ -472,6 +524,7 @@
     this.notesFactory = notesFactory;
     this.optionParserFactory = optionParserFactory;
     this.ormProvider = ormProvider;
+    this.metrics = metrics;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.permissionBackend = permissionBackend;
     this.pluginConfigEntries = pluginConfigEntries;
@@ -594,17 +647,20 @@
   ReceiveCommitsResult processCommands(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) throws StorageException {
     checkState(!used, "Tried to re-use a ReceiveCommits objects that is single-use only");
+    long start = TimeUtil.nowNanos();
     parsePushOptions();
+    String clientProvidedDeadlineValue =
+        Iterables.getLast(pushOptions.get("deadline"), /* defaultValue= */ null);
     int commandCount = commands.size();
     try (TraceContext traceContext =
             TraceContext.newTrace(
                 tracePushOption.isPresent(),
                 tracePushOption.orElse(null),
                 (tagName, traceId) -> addMessage(tagName + ": " + traceId));
-        TraceTimer traceTimer =
-            newTimer("processCommands", Metadata.builder().resourceCount(commandCount));
         PerformanceLogContext performanceLogContext =
-            new PerformanceLogContext(config, performanceLoggers)) {
+            new PerformanceLogContext(config, performanceLoggers);
+        TraceTimer traceTimer =
+            newTimer("processCommands", Metadata.builder().resourceCount(commandCount))) {
       RequestInfo requestInfo =
           RequestInfo.builder(RequestInfo.RequestType.GIT_RECEIVE, user, traceContext)
               .project(project.getNameKey())
@@ -619,8 +675,33 @@
       Task commandProgress = progress.beginSubTask("refs", UNKNOWN);
       commands =
           commands.stream().map(c -> wrapReceiveCommand(c, commandProgress)).collect(toList());
-      processCommandsUnsafe(commands, progress);
-      rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+
+      try (RequestStateContext requestStateContext =
+          RequestStateContext.open()
+              .addRequestStateProvider(progress)
+              .addRequestStateProvider(
+                  deadlineCheckerFactory.create(start, requestInfo, clientProvidedDeadlineValue))) {
+        processCommandsUnsafe(commands, progress);
+        rejectRemaining(commands, INTERNAL_SERVER_ERROR);
+      } catch (InvalidDeadlineException e) {
+        rejectRemaining(commands, e.getMessage());
+      } catch (RuntimeException e) {
+        Optional<RequestCancelledException> requestCancelledException =
+            RequestCancelledException.getFromCausalChain(e);
+        if (!requestCancelledException.isPresent()) {
+          Throwables.throwIfUnchecked(e);
+        }
+        cancellationMetrics.countCancelledRequest(
+            requestInfo, requestCancelledException.get().getCancellationReason());
+        StringBuilder msg =
+            new StringBuilder(requestCancelledException.get().formatCancellationReason());
+        if (requestCancelledException.get().getCancellationMessage().isPresent()) {
+          msg.append(
+              String.format(
+                  " (%s)", requestCancelledException.get().getCancellationMessage().get()));
+        }
+        rejectRemaining(commands, msg.toString());
+      }
 
       // This sends error messages before the 'done' string of the progress monitor is sent.
       // Currently, the test framework relies on this ordering to understand if pushes completed
@@ -628,17 +709,19 @@
       sendErrorMessages();
 
       commandProgress.end();
-      progress.end();
       loggingTags = traceContext.getTags();
       logger.atFine().log("Processing commands done.");
     }
+    progress.end();
     return result.build();
   }
 
   // Process as many commands as possible, but may leave some commands in state NOT_ATTEMPTED.
   private void processCommandsUnsafe(
       Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    logger.atFine().log("Calling user: %s", user.getLoggableName());
+    logger.atFine().log("Calling user: %s, commands: %d", user.getLoggableName(), commands.size());
+
+    // If the list of groups is large, the log entry may get dropped, so separate out.
     logger.atFine().log("Groups: %s", lazy(() -> user.getEffectiveGroups().getKnownGroups()));
 
     if (!projectState.getProject().getState().permitsWrite()) {
@@ -648,8 +731,6 @@
       return;
     }
 
-    logger.atFine().log("Parsing %d commands", commands.size());
-
     List<ReceiveCommand> magicCommands = new ArrayList<>();
     List<ReceiveCommand> regularCommands = new ArrayList<>();
 
@@ -666,6 +747,13 @@
       return;
     }
 
+    if (!magicCommands.isEmpty()) {
+      metrics.pushCount.increment("magic", project.getName(), getUpdateType(magicCommands));
+    }
+    if (!regularCommands.isEmpty()) {
+      metrics.pushCount.increment("direct", project.getName(), getUpdateType(regularCommands));
+    }
+
     try {
       if (!regularCommands.isEmpty()) {
         handleRegularCommands(regularCommands, progress);
@@ -716,6 +804,15 @@
         lazy(() -> commands.stream().map(ReceiveCommits::commandToString).collect(joining(","))));
   }
 
+  private String getUpdateType(List<ReceiveCommand> commands) {
+    return commands.stream()
+        .map(ReceiveCommand::getType)
+        .map(ReceiveCommand.Type::name)
+        .distinct()
+        .sorted()
+        .collect(joining("/"));
+  }
+
   private void sendErrorMessages() {
     if (!errors.isEmpty()) {
       logger.atFine().log("Handling error conditions: %s", errors.keySet());
@@ -1519,6 +1616,12 @@
     @Option(name = "--trace", metaVar = "NAME", usage = "enable tracing")
     String trace;
 
+    @Option(
+        name = "--deadline",
+        metaVar = "NAME",
+        usage = "deadline after which the push should be aborted")
+    String deadline;
+
     @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
     List<ObjectId> base;
 
@@ -1531,6 +1634,14 @@
     @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
     boolean removePrivate;
 
+    /**
+     * The skip-validation option is defined to allow parsing it using the {@link #cmdLineParser}.
+     * However we do not allow this option for pushes to magic branches. This option is used to fail
+     * with a proper error message.
+     */
+    @Option(name = "--skip-validation", usage = "skips commit validation")
+    boolean skipValidation;
+
     @Option(
         name = "--wip",
         aliases = {"-work-in-progress"},
@@ -1651,9 +1762,16 @@
     }
 
     @UsedAt(UsedAt.Project.GOOGLE)
+    @SuppressWarnings("unused") // unused in upstream, but used at Google
     @Option(name = "--create-cod-token", usage = "create a token for consistency-on-demand")
     private boolean createCodToken;
 
+    @Option(
+        name = "--ignore-automatic-attention-set-rules",
+        aliases = {"-ias", "-ignore-attention-set"},
+        usage = "do not change the attention set on this push")
+    boolean ignoreAttentionSet;
+
     MagicBranchInput(
         IdentifiedUser user, ProjectState projectState, ReceiveCommand cmd, LabelTypes labelTypes) {
       this.user = user;
@@ -1675,7 +1793,7 @@
      * account IDs computed from the commit message itself.
      *
      * @param additionalRecipients recipients parsed from the commit.
-     * @return set of reviewer strings to pass to {@code ReviewerAdder}.
+     * @return set of reviewer strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCombinedReviewers(MailRecipients additionalRecipients) {
       return getCombinedReviewers(reviewer, additionalRecipients.getReviewers());
@@ -1689,7 +1807,7 @@
      * account IDs computed from the commit message itself.
      *
      * @param additionalRecipients recipients parsed from the commit.
-     * @return set of CC strings to pass to {@code ReviewerAdder}.
+     * @return set of CC strings to pass to {@code ReviewerModifier}.
      */
     ImmutableSet<String> getCombinedCcs(MailRecipients additionalRecipients) {
       return getCombinedReviewers(cc, additionalRecipients.getCcOnly());
@@ -1814,6 +1932,14 @@
         ref = null; // never happens
       }
 
+      if (magicBranch.skipValidation) {
+        reject(
+            cmd,
+            String.format(
+                "\"--%s\" option is only supported for direct push", PUSH_OPTION_SKIP_VALIDATION));
+        return;
+      }
+
       if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
         reject(
             cmd, String.format("topic length exceeds the limit (%d)", ChangeUtil.TOPIC_MAX_LENGTH));
@@ -2636,6 +2762,9 @@
           if (!Strings.isNullOrEmpty(magicBranch.topic)) {
             bu.addOp(changeId, setTopicFactory.create(magicBranch.topic));
           }
+          if (magicBranch.ignoreAttentionSet) {
+            bu.addOp(changeId, new AttentionSetUnchangedOp());
+          }
           bu.addOp(
               changeId,
               new BatchUpdateOp() {
@@ -2790,8 +2919,6 @@
      * </ul>
      *
      * @return whether the new commit is valid
-     * @throws IOException
-     * @throws PermissionBackendException
      */
     boolean validateNewPatchSet() throws IOException, PermissionBackendException {
       try (TraceTimer traceTimer = newTimer("validateNewPatchSet")) {
@@ -2836,6 +2963,7 @@
         Change change = notes.getChange();
         priorPatchSet = change.currentPatchSetId();
         if (!revisions.containsValue(priorPatchSet)) {
+          metrics.psRevisionMissing.increment();
           logger.atWarning().log(
               "Change %d is missing revision for patch set %s"
                   + " (it has revisions for these patch sets: %s)",
@@ -3143,7 +3271,7 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       String refName = cmd.getRefName();
       if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
         logger.atFine().log("Updating tag cache on fast-forward of %s", cmd.getRefName());
diff --git a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
index 6c1f097..cc203ad 100644
--- a/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
+++ b/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -27,10 +27,9 @@
 import com.google.gerrit.server.git.HookUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.query.change.OwnerPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -116,9 +115,9 @@
               .setLimit(limit)
               .query(
                   Predicate.and(
-                      new ProjectPredicate(projectName.get()),
+                      ChangePredicates.project(projectName),
                       ChangeStatusPredicate.open(),
-                      new OwnerPredicate(user)))) {
+                      ChangePredicates.owner(user)))) {
         PatchSet ps = cd.currentPatchSet();
         if (ps != null) {
           // Ensure we actually observed a patch set ref pointing to this
diff --git a/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
index ce62d7a..a9ef70e 100644
--- a/java/com/google/gerrit/server/git/receive/ReplaceOp.java
+++ b/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -16,7 +16,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
-import static com.google.gerrit.server.change.ReviewerAdder.newAddReviewerInputFromCommitIdentity;
+import static com.google.gerrit.server.change.ReviewerModifier.newReviewerInputFromCommitIdentity;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
 import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -30,32 +30,31 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.PatchSetInfo;
 import com.google.gerrit.entities.SubmissionId;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.registration.DynamicItem;
 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.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddReviewersOp;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.InternalAddReviewerInput;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAdditionList;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.InternalReviewerInput;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModificationList;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
@@ -74,6 +73,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gerrit.server.validators.ValidationException;
@@ -130,7 +130,7 @@
   private final PatchSetUtil psUtil;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
   private final ProjectCache projectCache;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final Change change;
   private final MessageIdGenerator messageIdGenerator;
   private final DynamicItem<UrlFormatter> urlFormatter;
@@ -154,11 +154,11 @@
   private ChangeNotes notes;
   private PatchSet newPatchSet;
   private ChangeKind changeKind;
-  private ChangeMessage msg;
+  private String mailMessage;
   private String rejectMessage;
   private MergedByPushOp mergedByPushOp;
   private RequestScopePropagator requestScopePropagator;
-  private ReviewerAdditionList reviewerAdditions;
+  private ReviewerModificationList reviewerAdditions;
   private MailRecipients oldRecipients;
 
   @Inject
@@ -175,7 +175,7 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       ProjectCache projectCache,
       @SendEmailExecutor ExecutorService sendEmailExecutor,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       Change change,
       MessageIdGenerator messageIdGenerator,
       DynamicItem<UrlFormatter> urlFormatter,
@@ -203,7 +203,7 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.projectCache = projectCache;
     this.sendEmailExecutor = sendEmailExecutor;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.change = change;
     this.messageIdGenerator = messageIdGenerator;
     this.urlFormatter = urlFormatter;
@@ -305,6 +305,9 @@
         change.setWorkInProgress(true);
         update.setWorkInProgress(true);
       }
+      if (magicBranch.ignoreAttentionSet) {
+        update.ignoreFurtherAttentionSetUpdates();
+      }
     }
 
     newPatchSet =
@@ -323,12 +326,13 @@
         update, projectState.getLabelTypes(), newPatchSet, ctx.getUser(), approvals);
 
     reviewerAdditions =
-        reviewerAdder.prepare(
+        reviewerModifier.prepare(
             ctx.getNotes(),
             ctx.getUser(),
             getReviewerInputs(magicBranch, fromFooters, ctx.getChange(), info),
             true);
-    Optional<ReviewerAddition> reviewerError = reviewerAdditions.getFailures().stream().findFirst();
+    Optional<ReviewerModification> reviewerError =
+        reviewerAdditions.getFailures().stream().findFirst();
     if (reviewerError.isPresent()) {
       throw new UnprocessableEntityException(reviewerError.get().result.error);
     }
@@ -341,9 +345,11 @@
       update.putReviewer(ctx.getAccountId(), REVIEWER);
     }
 
-    msg = createChangeMessage(ctx, reviewMessage);
-    cmUtil.addChangeMessage(update, msg);
+    // Approvals that are being set in the new patch-set during this operation are not available yet
+    // outside of the scope of this method. Only copied approvals are set here.
+    approvalsUtil.byPatchSet(ctx.getNotes(), newPatchSet).forEach(a -> update.putCopiedApproval(a));
 
+    mailMessage = insertChangeMessage(update, ctx, reviewMessage);
     if (mergedByPushOp == null) {
       resetChange(ctx);
     } else {
@@ -353,24 +359,24 @@
     return true;
   }
 
-  private ImmutableList<AddReviewerInput> getReviewerInputs(
+  private ImmutableList<ReviewerInput> getReviewerInputs(
       @Nullable MagicBranchInput magicBranch,
       MailRecipients fromFooters,
       Change change,
       PatchSetInfo psInfo) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new change email.
-    Stream<AddReviewerInput> inputs =
+    Stream<ReviewerInput> inputs =
         Streams.concat(
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getAuthor().getAccount(),
                     NotifyHandling.NONE,
                     newPatchSet.uploader())),
             Streams.stream(
-                newAddReviewerInputFromCommitIdentity(
+                newReviewerInputFromCommitIdentity(
                     change,
                     psInfo.getCommitId(),
                     psInfo.getCommitter().getAccount(),
@@ -381,28 +387,27 @@
           Streams.concat(
               inputs,
               magicBranch.getCombinedReviewers(fromFooters).stream()
-                  .map(r -> newAddReviewerInput(r, ReviewerState.REVIEWER)),
+                  .map(r -> newReviewerInput(r, ReviewerState.REVIEWER)),
               magicBranch.getCombinedCcs(fromFooters).stream()
-                  .map(r -> newAddReviewerInput(r, ReviewerState.CC)));
+                  .map(r -> newReviewerInput(r, ReviewerState.CC)));
     }
     return inputs.collect(toImmutableList());
   }
 
-  private static InternalAddReviewerInput newAddReviewerInput(
-      String reviewer, ReviewerState state) {
+  private static InternalReviewerInput newReviewerInput(String reviewer, ReviewerState state) {
     // Disable individual emails when adding reviewers, as all reviewers will receive the single
     // bulk new patch set email.
-    InternalAddReviewerInput input =
-        ReviewerAdder.newAddReviewerInput(reviewer, state, NotifyHandling.NONE);
+    InternalReviewerInput input =
+        ReviewerModifier.newReviewerInput(reviewer, state, NotifyHandling.NONE);
 
     // Ignore failures for reasons like the reviewer being inactive or being unable to see the
     // change. See discussion in ChangeInserter.
-    input.otherFailureBehavior = ReviewerAdder.FailureBehavior.IGNORE;
+    input.otherFailureBehavior = ReviewerModifier.FailureBehavior.IGNORE;
 
     return input;
   }
 
-  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
+  private String insertChangeMessage(ChangeUpdate update, ChangeContext ctx, String reviewMessage)
       throws IOException {
     String approvalMessage =
         ApprovalsUtil.renderMessageWithApprovals(
@@ -421,12 +426,8 @@
     if (magicBranch != null && magicBranch.workInProgress) {
       workInProgress = true;
     }
-    return ChangeMessagesUtil.newMessage(
-        patchSetId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        message.toString(),
-        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+    return cmUtil.setChangeMessage(
+        update, message.toString(), ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
   }
 
   private String changeKindMessage(ChangeKind changeKind) {
@@ -467,10 +468,10 @@
           continue;
         }
 
-        LabelType lt = projectState.getLabelTypes().byLabel(a.labelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
-        }
+        projectState
+            .getLabelTypes()
+            .byLabel(a.labelId())
+            .ifPresent(l -> current.put(l.getName(), a));
       }
     }
     return current;
@@ -493,7 +494,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws Exception {
+  public void postUpdate(PostUpdateContext ctx) throws Exception {
     reviewerAdditions.postUpdate(ctx);
     if (changeKind != ChangeKind.TRIVIAL_REBASE) {
       // TODO(dborowitz): Merge email templates so we only have to send one.
@@ -506,7 +507,8 @@
       }
     }
     NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
-    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
+    revisionCreated.fire(
+        ctx.getChangeData(notes), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
     try {
       fireApprovalsEvent(ctx);
     } catch (Exception e) {
@@ -531,7 +533,7 @@
             replacePatchSetFactory.create(projectState.getNameKey(), notes.getChangeId());
         emailSender.setFrom(ctx.getAccount().account().id());
         emailSender.setPatchSet(newPatchSet, info);
-        emailSender.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.setNotify(ctx.getNotify(notes.getChangeId()));
         emailSender.addReviewers(
             Streams.concat(
@@ -560,7 +562,7 @@
     }
   }
 
-  private void fireApprovalsEvent(Context ctx) {
+  private void fireApprovalsEvent(PostUpdateContext ctx) {
     if (approvals.isEmpty()) {
       return;
     }
@@ -588,7 +590,7 @@
       }
     }
     commentAdded.fire(
-        notes.getChange(),
+        ctx.getChangeData(notes),
         newPatchSet,
         ctx.getAccount(),
         null,
diff --git a/java/com/google/gerrit/server/git/validators/CommitValidators.java b/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 870667b..596efb5 100644
--- a/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -45,7 +45,6 @@
 import com.google.gerrit.server.events.CommitReceivedEvent;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.git.validators.ValidationMessage.Type;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -326,7 +325,7 @@
                       + "Hint: run\n"
                       + "  git commit --amend\n"
                       + "and move 'Change-Id: Ixxx..' to the bottom on a separate line\n",
-                  Type.ERROR));
+                  ValidationMessage.Type.ERROR));
           throw new CommitValidationException(CHANGE_ID_ABOVE_FOOTER_MSG, messages);
         }
         if (projectState.is(BooleanProjectConfig.REQUIRE_CHANGE_ID)) {
@@ -366,7 +365,7 @@
               + "and then amend the commit:\n"
               + "  git commit --amend --no-edit\n"
               + "Finally, push your changes again\n",
-          Type.ERROR);
+          ValidationMessage.Type.ERROR);
     }
 
     private String getCommitMessageHookInstallationHint() {
@@ -881,7 +880,7 @@
           throw new CommitValidationException(
               "invalid account configuration",
               errorMessages.stream()
-                  .map(m -> new CommitValidationMessage(m, Type.ERROR))
+                  .map(m -> new CommitValidationMessage(m, ValidationMessage.Type.ERROR))
                   .collect(toList()));
         }
       } catch (IOException e) {
@@ -964,7 +963,7 @@
           .append(urlFormatter.getSettingsUrl("EmailAddresses").get())
           .append("\n\n");
     }
-    return new CommitValidationMessage(sb.toString(), Type.ERROR);
+    return new CommitValidationMessage(sb.toString(), ValidationMessage.Type.ERROR);
   }
 
   /**
@@ -985,6 +984,6 @@
   }
 
   private static void addError(String error, List<CommitValidationMessage> messages) {
-    messages.add(new CommitValidationMessage(error, Type.ERROR));
+    messages.add(new CommitValidationMessage(error, ValidationMessage.Type.ERROR));
   }
 }
diff --git a/java/com/google/gerrit/server/git/validators/MergeValidators.java b/java/com/google/gerrit/server/git/validators/MergeValidators.java
index cbaa121..6b145ca 100644
--- a/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -51,6 +51,7 @@
 import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
 /**
@@ -104,7 +105,8 @@
             new PluginMergeValidationListener(mergeValidationListeners),
             projectConfigValidatorFactory.create(),
             accountValidatorFactory.create(),
-            groupValidatorFactory.create());
+            groupValidatorFactory.create(),
+            new DestBranchRefValidator());
 
     for (MergeValidationListener validator : validators) {
       validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
@@ -198,7 +200,7 @@
                   throw new MergeValidationException(SET_BY_ADMIN, e);
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
-                  throw new MergeValidationException("validation unavailable");
+                  throw new MergeValidationException("validation unavailable", e);
                 }
               } else {
                 try {
@@ -210,7 +212,7 @@
                   throw new MergeValidationException(SET_BY_OWNER, e);
                 } catch (PermissionBackendException e) {
                   logger.atWarning().withCause(e).log("Cannot check WRITE_CONFIG");
-                  throw new MergeValidationException("validation unavailable");
+                  throw new MergeValidationException("validation unavailable", e);
                 }
               }
               if (allUsersName.equals(destProject.getNameKey())
@@ -317,7 +319,7 @@
         }
       } catch (StorageException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
-        throw new MergeValidationException("account validation unavailable");
+        throw new MergeValidationException("account validation unavailable", e);
       }
 
       try {
@@ -329,7 +331,7 @@
         }
       } catch (IOException e) {
         logger.atSevere().withCause(e).log("Cannot validate account update");
-        throw new MergeValidationException("account validation unavailable");
+        throw new MergeValidationException("account validation unavailable", e);
       }
     }
   }
@@ -366,4 +368,34 @@
       throw new MergeValidationException("group update not allowed");
     }
   }
+
+  /**
+   * Validator to ensure that destBranch is not a symbolic reference (an attempt to merge into a
+   * symbolic ref branch leads to LOCK_FAILURE exception).
+   */
+  private static class DestBranchRefValidator implements MergeValidationListener {
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewRevWalk revWalk,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        BranchNameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      try {
+        Ref ref = repo.exactRef(destBranch.branch());
+        // Usually the target branch exists, but there is an exception for some branches (see
+        // {@link com.google.gerrit.server.git.receive.ReceiveCommits} for details).
+        // Such non-existing branches should be ignored.
+        if (ref != null && ref.isSymbolic()) {
+          throw new MergeValidationException("the target branch is a symbolic ref");
+        }
+      } catch (IOException e) {
+        logger.atSevere().withCause(e).log("Cannot validate destination branch");
+        throw new MergeValidationException("symref validation unavailable", e);
+      }
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
index 432dda3..98f2aa2 100644
--- a/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
+++ b/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -76,8 +76,8 @@
     }
 
     /**
-     * @return a map from ref to commands covering all ref operations to be performed on this
-     *     repository as part of the ongoing submit operation.
+     * Returns a map from ref to commands covering all ref operations to be performed on this
+     * repository as part of the ongoing submit operation.
      */
     public ImmutableMap<String, ReceiveCommand> getCommands() {
       return commands;
diff --git a/java/com/google/gerrit/server/git/validators/ValidationMessage.java b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
index b5d7eb1..c743bbc 100644
--- a/java/com/google/gerrit/server/git/validators/ValidationMessage.java
+++ b/java/com/google/gerrit/server/git/validators/ValidationMessage.java
@@ -68,7 +68,8 @@
   }
 
   /**
-   * Returns {@true} if this message is an error. Used to decide if the operation should be aborted.
+   * Returns {@code true} if this message is an error. Used to decide if the operation should be
+   * aborted.
    */
   public boolean isError() {
     return type == Type.FATAL || type == Type.ERROR;
diff --git a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
index cae213f..72e15ee 100644
--- a/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
+++ b/java/com/google/gerrit/server/group/PeriodicGroupIndexer.java
@@ -59,7 +59,7 @@
 public class PeriodicGroupIndexer implements Runnable {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends LifecycleModule {
+  public static class PeriodicGroupIndexerModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(Lifecycle.class);
@@ -145,7 +145,7 @@
       }
       groupUuids = newGroupUuids;
       logger.atInfo().log("Run group indexer, %s groups reindexed", reindexCounter);
-    } catch (Throwable t) {
+    } catch (Exception t) {
       logger.atSevere().withCause(t).log("Failed to reindex groups");
     }
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupConfig.java b/java/com/google/gerrit/server/group/db/GroupConfig.java
index b2d1849b..c187186 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfig.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfig.java
@@ -95,19 +95,18 @@
   private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
 
   /**
-   * Creates a {@code GroupConfig} for a new group from the {@code InternalGroupCreation} blueprint.
-   * Further, optional properties can be specified by setting an {@code InternalGroupUpdate} via
-   * {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)} on the returned {@code
-   * GroupConfig}.
+   * Creates a {@link GroupConfig} for a new group from the {@link InternalGroupCreation} blueprint.
+   * Further, optional properties can be specified by setting a {@link GroupDelta} via {@link
+   * #setGroupDelta(GroupDelta, AuditLogFormatter)} on the returned {@link GroupConfig}.
    *
-   * <p><strong>Note:</strong> The returned {@code GroupConfig} has to be committed via {@link
+   * <p><strong>Note:</strong> The returned {@link GroupConfig} has to be committed via {@link
    * #commit(MetaDataUpdate)} in order to create the group for real.
    *
    * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
-   * @param groupCreation an {@code InternalGroupCreation} specifying all properties which are
+   * @param groupCreation an {@link InternalGroupCreation} specifying all properties which are
    *     required for a new group
-   * @return a {@code GroupConfig} for a group creation
+   * @return a {@link GroupConfig} for a group creation
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if a group with the same UUID already exists but can't be read
    *     due to an invalid format
@@ -123,7 +122,7 @@
   }
 
   /**
-   * Creates a {@code GroupConfig} for an existing group.
+   * Creates a {@link GroupConfig} for an existing group.
    *
    * <p>The group is automatically loaded within this method and can be accessed via {@link
    * #getLoadedGroup()}.
@@ -131,14 +130,14 @@
    * <p>It's safe to call this method for non-existing groups. In that case, {@link
    * #getLoadedGroup()} won't return any group. Thus, the existence of a group can be easily tested.
    *
-   * <p>The group represented by the returned {@code GroupConfig} can be updated by setting an
-   * {@code InternalGroupUpdate} via {@link #setGroupUpdate(InternalGroupUpdate, AuditLogFormatter)}
-   * and committing the {@code GroupConfig} via {@link #commit(MetaDataUpdate)}.
+   * <p>The group represented by the returned {@link GroupConfig} can be updated by setting an
+   * {@link GroupDelta} via {@link #setGroupDelta(GroupDelta, AuditLogFormatter)} and committing the
+   * {@link GroupConfig} via {@link #commit(MetaDataUpdate)}.
    *
    * @param projectName the name of the project which holds the NoteDb commits for groups
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
-   * @return a {@code GroupConfig} for the group with the specified UUID
+   * @return a {@link GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
@@ -169,7 +168,7 @@
   }
 
   /**
-   * Creates a {@code GroupConfig} for an existing group at a specific revision of the repository.
+   * Creates a {@link GroupConfig} for an existing group at a specific revision of the repository.
    *
    * <p>This method behaves nearly the same as {@link #loadForGroup(Project.NameKey, Repository,
    * AccountGroup.UUID)}. The only difference is that {@link #loadForGroup(Project.NameKey,
@@ -180,7 +179,7 @@
    * @param repository the repository which holds the NoteDb commits for groups
    * @param groupUuid the UUID of the group
    * @param commitId the revision of the repository at which the group should be loaded
-   * @return a {@code GroupConfig} for the group with the specified UUID
+   * @return a {@link GroupConfig} for the group with the specified UUID
    * @throws IOException if the repository can't be accessed for some reason
    * @throws ConfigInvalidException if the group exists but can't be read due to an invalid format
    */
@@ -200,7 +199,7 @@
 
   private Optional<InternalGroup> loadedGroup = Optional.empty();
   private Optional<InternalGroupCreation> groupCreation = Optional.empty();
-  private Optional<InternalGroupUpdate> groupUpdate = Optional.empty();
+  private Optional<GroupDelta> groupDelta = Optional.empty();
   private AuditLogFormatter auditLogFormatter = AuditLogFormatter.createPartiallyWorkingFallBack();
   private boolean isLoaded = false;
   private boolean allowSaveEmptyName;
@@ -213,16 +212,16 @@
   /**
    * Returns the group loaded from NoteDb.
    *
-   * <p>If not any NoteDb commits exist for the group represented by this {@code GroupConfig}, no
+   * <p>If not any NoteDb commits exist for the group represented by this {@link GroupConfig}, no
    * group is returned.
    *
-   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@code GroupConfig}, this method
+   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@link GroupConfig}, this method
    * returns a group which is in line with the latest NoteDb commit for this group. So, after
-   * creating a {@code GroupConfig} for a new group and committing it, this method can be used to
+   * creating a {@link GroupConfig} for a new group and committing it, this method can be used to
    * retrieve a representation of the created group. The same holds for the representation of an
    * updated group.
    *
-   * @return the loaded group, or an empty {@code Optional} if the group doesn't exist
+   * @return the loaded group, or an empty {@link Optional} if the group doesn't exist
    */
   public Optional<InternalGroup> getLoadedGroup() {
     checkLoaded();
@@ -232,20 +231,19 @@
   /**
    * Specifies how the current group should be updated.
    *
-   * <p>If the group is newly created, the {@code InternalGroupUpdate} can be used to specify
-   * optional properties.
+   * <p>If the group is newly created, the {@link GroupDelta} can be used to specify optional
+   * properties.
    *
    * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
    * instructions for the update. To apply the update for real and write the result back to NoteDb,
-   * call {@link #commit(MetaDataUpdate)} on this {@code GroupConfig}.
+   * call {@link #commit(MetaDataUpdate)} on this {@link GroupConfig}.
    *
-   * @param groupUpdate an {@code InternalGroupUpdate} outlining the modifications which should be
-   *     applied
-   * @param auditLogFormatter an {@code AuditLogFormatter} for formatting the commit message in a
+   * @param groupDelta a {@link GroupDelta} with the modifications to be applied
+   * @param auditLogFormatter an {@link AuditLogFormatter} for formatting the commit message in a
    *     parsable way
    */
-  public void setGroupUpdate(InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter) {
-    this.groupUpdate = Optional.of(groupUpdate);
+  public void setGroupDelta(GroupDelta groupDelta, AuditLogFormatter auditLogFormatter) {
+    this.groupDelta = Optional.of(groupDelta);
     this.auditLogFormatter = auditLogFormatter;
   }
 
@@ -304,7 +302,7 @@
   @Override
   protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
     checkLoaded();
-    if (!groupCreation.isPresent() && !groupUpdate.isPresent()) {
+    if (!groupCreation.isPresent() && !groupDelta.isPresent()) {
       // Group was neither created nor changed. -> A new commit isn't necessary.
       return false;
     }
@@ -318,7 +316,7 @@
     // for new groups, we explicitly need to truncate the timestamp here.
     Timestamp commitTimestamp =
         TimeUtil.truncateToSecond(
-            groupUpdate.flatMap(InternalGroupUpdate::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+            groupDelta.flatMap(GroupDelta::getUpdatedOn).orElseGet(TimeUtil::nowTs));
     commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
     commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
 
@@ -329,7 +327,7 @@
 
     loadedGroup = Optional.of(updatedGroup);
     groupCreation = Optional.empty();
-    groupUpdate = Optional.empty();
+    groupDelta = Optional.empty();
 
     return true;
   }
@@ -339,8 +337,8 @@
   }
 
   private Optional<String> getNewName() {
-    if (groupUpdate.isPresent()) {
-      return groupUpdate.get().getName().map(n -> Strings.nullToEmpty(n.get()));
+    if (groupDelta.isPresent()) {
+      return groupDelta.get().getName().map(n -> Strings.nullToEmpty(n.get()));
     }
     if (groupCreation.isPresent()) {
       return Optional.of(Strings.nullToEmpty(groupCreation.get().getNameKey().get()));
@@ -377,11 +375,10 @@
         internalGroupCreation ->
             Arrays.stream(GroupConfigEntry.values())
                 .forEach(configEntry -> configEntry.initNewConfig(config, internalGroupCreation)));
-    groupUpdate.ifPresent(
-        internalGroupUpdate ->
+    groupDelta.ifPresent(
+        delta ->
             Arrays.stream(GroupConfigEntry.values())
-                .forEach(
-                    configEntry -> configEntry.updateConfigValue(config, internalGroupUpdate)));
+                .forEach(configEntry -> configEntry.updateConfigValue(config, delta)));
     saveConfig(GROUP_CONFIG_FILE, config);
     return config;
   }
@@ -389,8 +386,8 @@
   private Optional<ImmutableSet<Account.Id>> updateMembers(ImmutableSet<Account.Id> originalMembers)
       throws IOException {
     Optional<ImmutableSet<Account.Id>> updatedMembers =
-        groupUpdate
-            .map(InternalGroupUpdate::getMemberModification)
+        groupDelta
+            .map(GroupDelta::getMemberModification)
             .map(memberModification -> memberModification.apply(originalMembers))
             .map(ImmutableSet::copyOf)
             .filter(members -> !originalMembers.equals(members));
@@ -403,8 +400,8 @@
   private Optional<ImmutableSet<AccountGroup.UUID>> updateSubgroups(
       ImmutableSet<AccountGroup.UUID> originalSubgroups) throws IOException {
     Optional<ImmutableSet<AccountGroup.UUID>> updatedSubgroups =
-        groupUpdate
-            .map(InternalGroupUpdate::getSubgroupModification)
+        groupDelta
+            .map(GroupDelta::getSubgroupModification)
             .map(subgroupModification -> subgroupModification.apply(originalSubgroups))
             .map(ImmutableSet::copyOf)
             .filter(subgroups -> !originalSubgroups.equals(subgroups));
diff --git a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
index be56344..687e3fb 100644
--- a/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
+++ b/java/com/google/gerrit/server/group/db/GroupConfigEntry.java
@@ -26,8 +26,8 @@
  * <p>Each property knows how to read and write its value from/to a JGit {@link Config} file.
  *
  * <p><strong>Warning:</strong> This class is a low-level API for properties of groups in NoteDb. It
- * may only be used by {@link GroupConfig}. Other classes should use {@link InternalGroupUpdate} to
- * modify the properties of a group.
+ * may only be used by {@link GroupConfig}. Other classes should use {@link GroupDelta} to modify
+ * the properties of a group.
  */
 enum GroupConfigEntry {
   /**
@@ -59,7 +59,7 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
       // Updating the ID is not supported.
     }
   },
@@ -87,8 +87,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getName()
           .ifPresent(name -> config.setString(SECTION_NAME, null, super.keyName, name.get()));
     }
@@ -112,8 +112,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getDescription()
           .ifPresent(
               description ->
@@ -144,8 +144,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getOwnerGroupUUID()
           .ifPresent(
               ownerGroupUuid ->
@@ -171,8 +171,8 @@
     }
 
     @Override
-    void updateConfigValue(Config config, InternalGroupUpdate groupUpdate) {
-      groupUpdate
+    void updateConfigValue(Config config, GroupDelta groupDelta) {
+      groupDelta
           .getVisibleToAll()
           .ifPresent(
               visibleToAll -> config.setBoolean(SECTION_NAME, null, super.keyName, visibleToAll));
@@ -217,13 +217,13 @@
 
   /**
    * Updates the corresponding property of this {@code GroupConfigEntry} in the given {@code Config}
-   * if the {@code InternalGroupUpdate} mentions a modification.
+   * if the {@link GroupDelta} mentions a modification.
    *
-   * <p>This call is a no-op if the {@code InternalGroupUpdate} doesn't contain a modification for
-   * the property.
+   * <p>This call is a no-op if the {@link GroupDelta} doesn't contain a modification for the
+   * property.
    *
    * @param config a {@code Config} for which the property should be updated
-   * @param groupUpdate an {@code InternalGroupUpdate} detailing the modifications on a group
+   * @param groupDelta a {@link GroupDelta} detailing the modifications on a group
    */
-  abstract void updateConfigValue(Config config, InternalGroupUpdate groupUpdate);
+  abstract void updateConfigValue(Config config, GroupDelta groupDelta);
 }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java b/java/com/google/gerrit/server/group/db/GroupDelta.java
similarity index 70%
rename from java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
rename to java/com/google/gerrit/server/group/db/GroupDelta.java
index 5c7408c..69cb936 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupDelta.java
@@ -23,14 +23,13 @@
 import java.util.Set;
 
 /**
- * Definition of an update to a group.
+ * Data holder for updates to be applied to a group.
  *
- * <p>An {@code InternalGroupUpdate} only specifies the modifications which should be applied to a
- * group. Each of the modifications and hence each call on {@link InternalGroupUpdate.Builder} is
- * optional.
+ * <p>A {@link GroupDelta} specifies the modifications to be applied to a group. Only fields set via
+ * {@link GroupDelta.Builder} will be updated.
  */
 @AutoValue
-public abstract class InternalGroupUpdate {
+public abstract class GroupDelta {
 
   /** Representation of a member modification as defined by {@link #apply(ImmutableSet)}. */
   @FunctionalInterface
@@ -99,10 +98,10 @@
    * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
    * specified, the current {@code Timestamp} when creating the commit will be used.
    *
-   * <p>If this {@code InternalGroupUpdate} is passed next to an {@link InternalGroupCreation}
-   * during a group creation, this {@code Timestamp} is used for the NoteDb commits of the new
-   * group. Hence, the {@link com.google.gerrit.entities.InternalGroup#getCreatedOn()
-   * InternalGroup#getCreatedOn()} field will match this {@code Timestamp}.
+   * <p>If this {@link GroupDelta} is passed next to an {@link InternalGroupCreation} during a group
+   * creation, this {@code Timestamp} is used for the NoteDb commits of the new group. Hence, the
+   * {@link com.google.gerrit.entities.InternalGroup#getCreatedOn() InternalGroup#getCreatedOn()}
+   * field will match this {@code Timestamp}.
    *
    * <p><strong>Note: </strong>{@code Timestamp}s of NoteDb commits for groups are used for events
    * in the audit log. For this reason, specifying this field will have an effect on the resulting
@@ -113,56 +112,85 @@
   public abstract Builder toBuilder();
 
   public static Builder builder() {
-    return new AutoValue_InternalGroupUpdate.Builder()
+    return new AutoValue_GroupDelta.Builder()
         .setMemberModification(in -> in)
         .setSubgroupModification(in -> in);
   }
 
-  /** A builder for an {@link InternalGroupUpdate}. */
+  /** A builder for a {@link GroupDelta}. */
   @AutoValue.Builder
   public abstract static class Builder {
 
-    /** @see #getName() */
+    /**
+     * Defines the new name of the group
+     *
+     * <p>See {@link #getName}.
+     */
     public abstract Builder setName(AccountGroup.NameKey name);
 
-    /** @see #getDescription() */
+    /**
+     * Defines the new description of the group
+     *
+     * <p>See {@link #getDescription()}}
+     */
     public abstract Builder setDescription(String description);
 
-    /** @see #getOwnerGroupUUID() */
+    /**
+     * Defines the new owner of the group
+     *
+     * <p>See {@link #getOwnerGroupUUID()}
+     */
     public abstract Builder setOwnerGroupUUID(AccountGroup.UUID ownerGroupUUID);
 
-    /** @see #getVisibleToAll() */
+    /**
+     * Defines the new state of the 'visibleToAll' flag of the group
+     *
+     * <p>See {@link #getVisibleToAll()}
+     */
     public abstract Builder setVisibleToAll(boolean visibleToAll);
 
-    /** @see #getMemberModification() */
+    /**
+     * Set {@link MemberModification} for the prospective {@link GroupDelta}
+     *
+     * <p>See {@link #getMemberModification()}
+     */
     public abstract Builder setMemberModification(MemberModification memberModification);
 
     /**
      * Returns the currently defined {@link MemberModification} for the prospective {@link
-     * InternalGroupUpdate}.
+     * GroupDelta}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setMemberModification(InternalGroupUpdate.MemberModification)} in order to combine multiple
-     * member additions, deletions, or other modifications into one update.
+     * #setMemberModification(GroupDelta.MemberModification)} in order to combine multiple member
+     * additions, deletions, or other modifications into one update.
      */
     public abstract MemberModification getMemberModification();
 
-    /** @see #getSubgroupModification() */
+    /**
+     * Set {@link SubgroupModification} for the prospective {@link GroupDelta}
+     *
+     * <p>See {@link #getSubgroupModification()}
+     */
     public abstract Builder setSubgroupModification(SubgroupModification subgroupModification);
 
     /**
      * Returns the currently defined {@link SubgroupModification} for the prospective {@link
-     * InternalGroupUpdate}.
+     * GroupDelta}.
      *
      * <p>This modification can be tweaked further and passed to {@link
-     * #setSubgroupModification(InternalGroupUpdate.SubgroupModification)} in order to combine
-     * multiple subgroup additions, deletions, or other modifications into one update.
+     * #setSubgroupModification(GroupDelta.SubgroupModification)} in order to combine multiple
+     * subgroup additions, deletions, or other modifications into one update.
      */
     public abstract SubgroupModification getSubgroupModification();
 
-    /** @see #getUpdatedOn() */
+    /**
+     * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
+     * specified, the current {@code Timestamp} when creating the commit will be used.
+     *
+     * <p>See {@link #getUpdatedOn()}
+     */
     public abstract Builder setUpdatedOn(Timestamp timestamp);
 
-    public abstract InternalGroupUpdate build();
+    public abstract GroupDelta build();
   }
 }
diff --git a/java/com/google/gerrit/server/group/db/GroupNameNotes.java b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
index cdba81f..7c4fb16f9 100644
--- a/java/com/google/gerrit/server/group/db/GroupNameNotes.java
+++ b/java/com/google/gerrit/server/group/db/GroupNameNotes.java
@@ -35,6 +35,9 @@
 import com.google.gerrit.exceptions.DuplicateKeyException;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.logging.Metadata;
+import com.google.gerrit.server.logging.TraceContext;
+import com.google.gerrit.server.logging.TraceContext.TraceTimer;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Map;
@@ -210,7 +213,11 @@
     if (ref == null) {
       return ImmutableList.of();
     }
-    try (RevWalk revWalk = new RevWalk(repository);
+    try (TraceTimer ignored =
+            TraceContext.newTimer(
+                "Loading all groups",
+                Metadata.builder().noteDbRefName(RefNames.REFS_GROUPNAMES).build());
+        RevWalk revWalk = new RevWalk(repository);
         ObjectReader reader = revWalk.getObjectReader()) {
       RevCommit notesCommit = revWalk.parseCommit(ref.getObjectId());
       NoteMap noteMap = NoteMap.read(reader, notesCommit);
diff --git a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
index 01ee811..24bcaf0 100644
--- a/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
+++ b/java/com/google/gerrit/server/group/db/GroupsNoteDbConsistencyChecker.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.HashBiMap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.FormatMethod;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.GroupReference;
@@ -138,8 +139,7 @@
     Optional<Ref> maybeRef =
         refs.stream().filter(r -> r.getName().equals(RefNames.REFS_GROUPNAMES)).findFirst();
     if (!maybeRef.isPresent()) {
-      String msg = String.format("ref %s does not exist", RefNames.REFS_GROUPNAMES);
-      result.problems.add(error(msg));
+      result.problems.add(error("ref %s does not exist", RefNames.REFS_GROUPNAMES));
       return;
     }
     Ref ref = maybeRef.get();
@@ -280,6 +280,7 @@
     }
   }
 
+  @FormatMethod
   public static void logConsistencyProblemAsWarning(String fmt, Object... args) {
     logConsistencyProblem(warning(fmt, args));
   }
diff --git a/java/com/google/gerrit/server/group/db/GroupsUpdate.java b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
index 02d55eb..9aa5cfd 100644
--- a/java/com/google/gerrit/server/group/db/GroupsUpdate.java
+++ b/java/com/google/gerrit/server/group/db/GroupsUpdate.java
@@ -67,19 +67,19 @@
  * <p>All calls which write group related details to the database are gathered here. Other classes
  * should always use this class instead of accessing the database directly. There are a few
  * exceptions though: schema classes, wrapper classes, and classes executed during init. The latter
- * ones should use {@code GroupsOnInit} instead.
+ * ones should use {@link com.google.gerrit.pgm.init.GroupsOnInit} instead.
  *
  * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
  */
 public class GroupsUpdate {
   public interface Factory {
     /**
-     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
+     * Creates a {@link GroupsUpdate} which uses the identity of the specified user to mark database
      * modifications executed by it. For NoteDb, this identity is used as author and committer for
      * all related commits.
      *
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
-     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@code GroupsUpdate}
+     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@link GroupsUpdate}
      * instead.
      *
      * @param currentUser the user to which modifications should be attributed
@@ -87,12 +87,12 @@
     GroupsUpdate create(IdentifiedUser currentUser);
 
     /**
-     * Creates a {@code GroupsUpdate} which uses the server identity to mark database modifications
+     * Creates a {@link GroupsUpdate} which uses the server identity to mark database modifications
      * executed by it. For NoteDb, this identity is used as author and committer for all related
      * commits.
      *
      * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
-     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@code
+     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@link
      * GroupsUpdate} instead.
      */
     GroupsUpdate createWithServerIdent();
@@ -115,6 +115,7 @@
   private final RetryHelper retryHelper;
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -149,6 +150,7 @@
   }
 
   @AssistedInject
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -183,6 +185,7 @@
         Optional.of(currentUser));
   }
 
+  @SuppressWarnings("BindingAnnotationWithoutInject")
   private GroupsUpdate(
       GitRepositoryManager repoManager,
       AllUsersName allUsersName,
@@ -251,25 +254,24 @@
   /**
    * Creates the specified group for the specified members (accounts).
    *
-   * @param groupCreation an {@code InternalGroupCreation} which specifies all mandatory properties
+   * @param groupCreation an {@link InternalGroupCreation} which specifies all mandatory properties
    *     of the group
-   * @param groupUpdate an {@code InternalGroupUpdate} which specifies optional properties of the
-   *     group. If this {@code InternalGroupUpdate} updates a property which was already specified
-   *     by the {@code InternalGroupCreation}, the value of this {@code InternalGroupUpdate} wins.
+   * @param groupDelta a {@link GroupDelta} which specifies optional properties of the group. If
+   *     this {@link GroupDelta} updates a property which was already specified by the {@link
+   *     InternalGroupCreation}, the value of this {@link GroupDelta} wins.
    * @throws DuplicateKeyException if a group with the chosen name already exists
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
-   * @return the created {@code InternalGroup}
+   * @return the created {@link InternalGroup}
    */
-  public InternalGroup createGroup(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+  public InternalGroup createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, ConfigInvalidException {
-    try (TraceTimer timer =
+    try (TraceTimer ignored =
         TraceContext.newTimer(
             "Creating group",
             Metadata.builder()
-                .groupName(groupUpdate.getName().orElseGet(groupCreation::getNameKey).get())
+                .groupName(groupDelta.getName().orElseGet(groupCreation::getNameKey).get())
                 .build())) {
-      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupUpdate);
+      InternalGroup createdGroup = createGroupInNoteDbWithRetry(groupCreation, groupDelta);
       evictCachesOnGroupCreation(createdGroup);
       dispatchAuditEventsOnGroupCreation(createdGroup);
       return createdGroup;
@@ -280,24 +282,23 @@
    * Updates the specified group.
    *
    * @param groupUuid the UUID of the group to update
-   * @param groupUpdate an {@code InternalGroupUpdate} which indicates the desired updates on the
-   *     group
+   * @param groupDelta a {@link GroupDelta} which indicates the desired updates on the group
    * @throws DuplicateKeyException if the new name of the group is used by another group
    * @throws IOException if indexing fails, or an error occurs while reading/writing from/to NoteDb
    * @throws NoSuchGroupException if the specified group doesn't exist
    */
-  public void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  public void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws DuplicateKeyException, IOException, NoSuchGroupException, ConfigInvalidException {
-    try (TraceTimer timer =
+    try (TraceTimer ignored =
         TraceContext.newTimer(
             "Updating group", Metadata.builder().groupUuid(groupUuid.get()).build())) {
-      Optional<Timestamp> updatedOn = groupUpdate.getUpdatedOn();
+      Optional<Timestamp> updatedOn = groupDelta.getUpdatedOn();
       if (!updatedOn.isPresent()) {
         updatedOn = Optional.of(TimeUtil.nowTs());
-        groupUpdate = groupUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
+        groupDelta = groupDelta.toBuilder().setUpdatedOn(updatedOn.get()).build();
       }
 
-      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupUpdate);
+      UpdateResult result = updateGroupInNoteDbWithRetry(groupUuid, groupDelta);
       updateNameInProjectConfigsIfNecessary(result);
       evictCachesOnGroupUpdate(result);
       dispatchAuditEventsOnGroupUpdate(result, updatedOn.get());
@@ -305,11 +306,11 @@
   }
 
   private InternalGroup createGroupInNoteDbWithRetry(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try {
       return retryHelper
-          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupUpdate))
+          .groupUpdate("createGroup", () -> createGroupInNoteDb(groupCreation, groupDelta))
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -322,17 +323,17 @@
 
   @VisibleForTesting
   public InternalGroup createGroupInNoteDb(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+      AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
       GroupNameNotes groupNameNotes =
           GroupNameNotes.forNewGroup(
               allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
 
       GroupConfig groupConfig =
           GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
       commit(allUsersRepo, groupConfig, groupNameNotes);
 
@@ -344,11 +345,11 @@
   }
 
   private UpdateResult updateGroupInNoteDbWithRetry(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+      AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try {
       return retryHelper
-          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupUpdate))
+          .groupUpdate("updateGroup", () -> updateGroupInNoteDb(groupUuid, groupDelta))
           .call();
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
@@ -361,21 +362,20 @@
   }
 
   @VisibleForTesting
-  public UpdateResult updateGroupInNoteDb(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  public UpdateResult updateGroupInNoteDb(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException, DuplicateKeyException, NoSuchGroupException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
       GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, groupUuid);
-      groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+      groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
       if (!groupConfig.getLoadedGroup().isPresent()) {
         throw new NoSuchGroupException(groupUuid);
       }
 
       InternalGroup originalGroup = groupConfig.getLoadedGroup().get();
       GroupNameNotes groupNameNotes = null;
-      if (groupUpdate.getName().isPresent()) {
+      if (groupDelta.getName().isPresent()) {
         AccountGroup.NameKey oldName = originalGroup.getNameKey();
-        AccountGroup.NameKey newName = groupUpdate.getName().get();
+        AccountGroup.NameKey newName = groupDelta.getName().get();
         groupNameNotes =
             GroupNameNotes.forRename(allUsersName, allUsersRepo, groupUuid, oldName, newName);
       }
diff --git a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
index 8988547..291c354 100644
--- a/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
+++ b/java/com/google/gerrit/server/group/db/InternalGroupCreation.java
@@ -20,19 +20,19 @@
 /**
  * Definition of all properties necessary for a group creation.
  *
- * <p>An instance of {@code InternalGroupCreation} is a blueprint for a group which should be
+ * <p>An instance of {@link InternalGroupCreation} is a blueprint for a group which should be
  * created.
  */
 @AutoValue
 public abstract class InternalGroupCreation {
 
-  /** Defines the numeric ID the group should have. */
+  /** Defines the numeric ID the group should have */
   public abstract AccountGroup.Id getId();
 
-  /** Defines the name the group should have. */
+  /** Defines the name the group should have */
   public abstract AccountGroup.NameKey getNameKey();
 
-  /** Defines the UUID the group should have. */
+  /** Defines the UUID the group should have */
   public abstract AccountGroup.UUID getGroupUUID();
 
   public static Builder builder() {
@@ -41,13 +41,13 @@
 
   @AutoValue.Builder
   public abstract static class Builder {
-    /** @see #getId() */
+    /** Defines the name the group should have */
     public abstract InternalGroupCreation.Builder setId(AccountGroup.Id id);
 
-    /** @see #getNameKey() */
+    /** Defines the name the group should have */
     public abstract InternalGroupCreation.Builder setNameKey(AccountGroup.NameKey name);
 
-    /** @see #getGroupUUID() */
+    /** Defines the UUID the group should have */
     public abstract InternalGroupCreation.Builder setGroupUUID(AccountGroup.UUID groupUuid);
 
     public abstract InternalGroupCreation build();
diff --git a/java/com/google/gerrit/server/index/AbstractIndexModule.java b/java/com/google/gerrit/server/index/AbstractIndexModule.java
index 352971f..81c517f 100644
--- a/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -33,6 +33,7 @@
  * index implementations, such as {@link com.google.gerrit.lucene.LuceneIndexModule}.
  */
 public abstract class AbstractIndexModule extends AbstractModule {
+  public static final String INDEX_MODULE = "index-module";
 
   private final int threads;
   private final Map<String, Integer> singleVersions;
diff --git a/java/com/google/gerrit/server/index/IndexModule.java b/java/com/google/gerrit/server/index/IndexModule.java
index 8b04c8d..a729863 100644
--- a/java/com/google/gerrit/server/index/IndexModule.java
+++ b/java/com/google/gerrit/server/index/IndexModule.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.index.project.ProjectSchemaDefinitions;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexDefinition;
@@ -112,6 +113,7 @@
 
   @Override
   protected void configure() {
+    factory(MultiProgressMonitor.Factory.class);
 
     bind(AccountIndexRewriter.class);
     bind(AccountIndexCollection.class);
diff --git a/java/com/google/gerrit/server/index/OnlineUpgrader.java b/java/com/google/gerrit/server/index/OnlineUpgrader.java
index bfcf55f..a7e3dbc 100644
--- a/java/com/google/gerrit/server/index/OnlineUpgrader.java
+++ b/java/com/google/gerrit/server/index/OnlineUpgrader.java
@@ -20,7 +20,7 @@
 
 /** Listener to handle upgrading index schema versions at startup. */
 public class OnlineUpgrader implements LifecycleListener {
-  public static class Module extends LifecycleModule {
+  public static class OnlineUpgraderModule extends LifecycleModule {
     @Override
     protected void configure() {
       listener().to(OnlineUpgrader.class);
diff --git a/java/com/google/gerrit/server/index/VersionManager.java b/java/com/google/gerrit/server/index/VersionManager.java
index 56ce604..cdb69c6 100644
--- a/java/com/google/gerrit/server/index/VersionManager.java
+++ b/java/com/google/gerrit/server/index/VersionManager.java
@@ -107,7 +107,6 @@
    * @param name index name
    * @param force start re-index
    * @return true if started, otherwise false.
-   * @throws ReindexerAlreadyRunningException
    */
   public synchronized boolean startReindexer(String name, boolean force)
       throws ReindexerAlreadyRunningException {
@@ -125,7 +124,6 @@
    *
    * @param name index name
    * @return true if index was activated, otherwise false.
-   * @throws ReindexerAlreadyRunningException
    */
   public synchronized boolean activateLatestIndex(String name)
       throws ReindexerAlreadyRunningException {
diff --git a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index ec27db0..1f48e35 100644
--- a/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -96,7 +96,7 @@
                 try {
                   Optional<AccountState> a = accountCache.get(id);
                   if (a.isPresent()) {
-                    if (isFirstInsertForEntry.equals(isFirstInsertForEntry.YES)) {
+                    if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
                       index.insert(a.get());
                     } else {
                       index.replace(a.get());
diff --git a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index f176c38..b1ff504 100644
--- a/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.git.MultiProgressMonitor.TaskKind;
 import com.google.gerrit.server.git.MultiProgressMonitor.VolatileTask;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.OnlineReindexMode;
@@ -63,6 +64,8 @@
   private Task failedTask;
   private static final int PROJECT_SLICE_MAX_REFS = 1000;
 
+  private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
+
   private static class ProjectsCollectionFailure extends Exception {
     private static final long serialVersionUID = 1L;
 
@@ -80,12 +83,14 @@
 
   @Inject
   AllChangesIndexer(
+      MultiProgressMonitor.Factory multiProgressMonitorFactory,
       ChangeData.Factory changeDataFactory,
       GitRepositoryManager repoManager,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
       ChangeIndexer.Factory indexerFactory,
       ChangeNotes.Factory notesFactory,
       ProjectCache projectCache) {
+    this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.changeDataFactory = changeDataFactory;
     this.repoManager = repoManager;
     this.executor = executor;
@@ -128,7 +133,7 @@
 
     Stopwatch sw = Stopwatch.createStarted();
     AtomicBoolean ok = new AtomicBoolean(true);
-    mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
+    mpm = multiProgressMonitorFactory.create(progressOut, TaskKind.INDEXING, "Reindexing changes");
     doneTask = mpm.beginVolatileSubTask("changes");
     failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
     List<ListenableFuture<?>> futures;
diff --git a/java/com/google/gerrit/server/index/change/ChangeField.java b/java/com/google/gerrit/server/index/change/ChangeField.java
index ce748d1..4a2419b 100644
--- a/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.index.FieldDef.exact;
 import static com.google.gerrit.index.FieldDef.fullText;
@@ -42,11 +43,13 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.common.io.Files;
 import com.google.common.primitives.Longs;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
@@ -57,6 +60,7 @@
 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,11 +70,14 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.notedb.SubmitRequirementProtoConverter;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeStatusPredicate;
+import com.google.gerrit.server.query.change.MagicLabelValue;
 import com.google.gson.Gson;
+import com.google.protobuf.MessageLite;
 import java.sql.Timestamp;
 import java.time.Instant;
 import java.util.ArrayList;
@@ -84,6 +91,7 @@
 import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Stream;
+import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.PersonIdent;
 
 /**
@@ -103,6 +111,7 @@
 
   private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
 
+  // TODO: Rename LEGACY_ID to NUMERIC_ID
   /** Legacy change ID. */
   public static final FieldDef<ChangeData, Integer> LEGACY_ID =
       integer("legacy_id").stored().build(cd -> cd.getId().get());
@@ -153,7 +162,7 @@
   public static final FieldDef<ChangeData, Timestamp> MERGED_ON =
       timestamp(ChangeQueryBuilder.FIELD_MERGED_ON)
           .stored()
-          .build(cd -> cd.getMergedOn().orElse(null));
+          .build(cd -> cd.getMergedOn().orElse(null), (cd, field) -> cd.setMergedOn(field));
 
   /** List of full file paths modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> PATH =
@@ -179,11 +188,21 @@
       exact(ChangeQueryBuilder.FIELD_HASHTAG)
           .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
 
+  /** Hashtags as fulltext field for in-string search. */
+  public static final FieldDef<ChangeData, Iterable<String>> FUZZY_HASHTAG =
+      fullText("hashtag2")
+          .buildRepeatable(cd -> cd.hashtags().stream().map(String::toLowerCase).collect(toSet()));
+
   /** Hashtags with original case. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> HASHTAG_CASE_AWARE =
       storedOnly("_hashtag")
           .buildRepeatable(
-              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()));
+              cd -> cd.hashtags().stream().map(t -> t.getBytes(UTF_8)).collect(toSet()),
+              (cd, field) ->
+                  cd.setHashtags(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toImmutableSet())));
 
   /** Components of each file path modified in the current patch set. */
   public static final FieldDef<ChangeData, Iterable<String>> FILE_PART =
@@ -248,7 +267,7 @@
       StringBuilder directory = new StringBuilder();
       r.add(directory.toString());
       String nextPart = null;
-      for (String part : s.split(path)) {
+      for (String part : s.split(path.toLowerCase(Locale.US))) {
         if (nextPart != null) {
           r.add(nextPart);
 
@@ -275,6 +294,10 @@
   public static final FieldDef<ChangeData, Integer> OWNER =
       integer(ChangeQueryBuilder.FIELD_OWNER).build(changeGetter(c -> c.getOwner().get()));
 
+  /** Uploader of the latest patch set. */
+  public static final FieldDef<ChangeData, Integer> UPLOADER =
+      integer(ChangeQueryBuilder.FIELD_UPLOADER).build(cd -> cd.currentPatchSet().uploader().get());
+
   /** References the source change number that this change was cherry-picked from. */
   public static final FieldDef<ChangeData, Integer> CHERRY_PICK_OF_CHANGE =
       integer(ChangeQueryBuilder.FIELD_CHERRY_PICK_OF_CHANGE)
@@ -323,6 +346,11 @@
       integer(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS)
           .buildRepeatable(ChangeField::getAttentionSetUserIds);
 
+  /** Number of changes that contain attention set. */
+  public static final FieldDef<ChangeData, Integer> ATTENTION_SET_USERS_COUNT =
+      intRange(ChangeQueryBuilder.FIELD_ATTENTION_SET_USERS_COUNT)
+          .build(cd -> additionsOnly(cd.attentionSet()).size());
+
   /**
    * The full attention set data including timestamp, reason and possible future fields.
    *
@@ -330,7 +358,14 @@
    */
   public static final FieldDef<ChangeData, Iterable<byte[]>> ATTENTION_SET_FULL =
       storedOnly(ChangeQueryBuilder.FIELD_ATTENTION_SET_FULL)
-          .buildRepeatable(ChangeField::storedAttentionSet);
+          .buildRepeatable(
+              ChangeField::storedAttentionSet,
+              (cd, value) ->
+                  parseAttentionSet(
+                      StreamSupport.stream(value.spliterator(), false)
+                          .map(v -> new String(v, UTF_8))
+                          .collect(toImmutableSet()),
+                      cd));
 
   /** The user assigned to the change. */
   public static final FieldDef<ChangeData, Integer> ASSIGNEE =
@@ -339,25 +374,38 @@
 
   /** Reviewer(s) associated with the change. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
-      exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
+      exact("reviewer2")
+          .stored()
+          .buildRepeatable(
+              cd -> getReviewerFieldValues(cd.reviewers()),
+              (cd, field) -> cd.setReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) associated with the change that do not have a gerrit account. */
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
       exact("reviewer_by_email")
           .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+          .buildRepeatable(
+              cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()),
+              (cd, field) ->
+                  cd.setReviewersByEmail(parseReviewerByEmailFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) modified during change's current WIP phase. */
   public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
       exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
           .stored()
-          .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers()));
+          .buildRepeatable(
+              cd -> getReviewerFieldValues(cd.pendingReviewers()),
+              (cd, field) -> cd.setPendingReviewers(parseReviewerFieldValues(cd.getId(), field)));
 
   /** Reviewer(s) by email modified during change's current WIP phase. */
   public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
       exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
           .stored()
-          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()));
+          .buildRepeatable(
+              cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()),
+              (cd, field) ->
+                  cd.setPendingReviewersByEmail(
+                      parseReviewerByEmailFieldValues(cd.getId(), field)));
 
   /** References a change that this change reverts. */
   public static final FieldDef<ChangeData, Integer> REVERT_OF =
@@ -559,24 +607,52 @@
 
   /** List of labels on the current patch set including change owner votes. */
   public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact("label2").buildRepeatable(cd -> getLabels(cd, true));
+      exact("label2").buildRepeatable(cd -> getLabels(cd));
 
-  private static Iterable<String> getLabels(ChangeData cd, boolean owners) {
+  private static Iterable<String> getLabels(ChangeData cd) {
     Set<String> allApprovals = new HashSet<>();
     Set<String> distinctApprovals = new HashSet<>();
     for (PatchSetApproval a : cd.currentApprovals()) {
       if (a.value() != 0 && !a.isLegacySubmit()) {
         allApprovals.add(formatLabel(a.label(), a.value(), a.accountId()));
-        if (owners && cd.change().getOwner().equals(a.accountId())) {
+        Optional<LabelType> labelType = cd.getLabelTypes().byLabel(a.labelId());
+        allApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, a.accountId()));
+        if (cd.change().getOwner().equals(a.accountId())) {
           allApprovals.add(formatLabel(a.label(), a.value(), ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+          allApprovals.addAll(
+              getMaxMinAnyLabels(
+                  a.label(), a.value(), labelType, ChangeQueryBuilder.OWNER_ACCOUNT_ID));
+        }
+        if (!cd.currentPatchSet().uploader().equals(a.accountId())) {
+          allApprovals.add(
+              formatLabel(a.label(), a.value(), ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
+          allApprovals.addAll(
+              getMaxMinAnyLabels(
+                  a.label(), a.value(), labelType, ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID));
         }
         distinctApprovals.add(formatLabel(a.label(), a.value()));
+        distinctApprovals.addAll(getMaxMinAnyLabels(a.label(), a.value(), labelType, null));
       }
     }
     allApprovals.addAll(distinctApprovals);
     return allApprovals;
   }
 
+  private static List<String> getMaxMinAnyLabels(
+      String label, short labelVal, Optional<LabelType> labelType, @Nullable Account.Id accountId) {
+    List<String> labels = new ArrayList<>();
+    if (labelType.isPresent()) {
+      if (labelVal == labelType.get().getMaxPositive()) {
+        labels.add(formatLabel(label, MagicLabelValue.MAX.name(), accountId));
+      }
+      if (labelVal == labelType.get().getMaxNegative()) {
+        labels.add(formatLabel(label, MagicLabelValue.MIN.name(), accountId));
+      }
+    }
+    labels.add(formatLabel(label, MagicLabelValue.ANY.name(), accountId));
+    return labels;
+  }
+
   public static Set<String> getAuthorParts(ChangeData cd) {
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
@@ -637,13 +713,18 @@
   /** Serialized change object, used for pre-populating results. */
   public static final FieldDef<ChangeData, byte[]> CHANGE =
       storedOnly("_change")
-          .build(changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)));
+          .build(
+              changeGetter(change -> toProto(ChangeProtoConverter.INSTANCE, change)),
+              (cd, field) -> cd.setChange(parseProtoFrom(field, ChangeProtoConverter.INSTANCE)));
 
   /** Serialized approvals for the current patch set, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> APPROVAL =
       storedOnly("_approval")
           .buildRepeatable(
-              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()));
+              cd -> toProtos(PatchSetApprovalProtoConverter.INSTANCE, cd.currentApprovals()),
+              (cd, field) ->
+                  cd.setCurrentApprovals(
+                      decodeProtos(field, PatchSetApprovalProtoConverter.INSTANCE)));
 
   public static String formatLabel(String label, int value) {
     return formatLabel(label, value, null);
@@ -656,9 +737,22 @@
         + (accountId != null ? "," + formatAccount(accountId) : "");
   }
 
+  public static String formatLabel(String label, String value) {
+    return formatLabel(label, value, null);
+  }
+
+  public static String formatLabel(String label, String value, @Nullable Account.Id accountId) {
+    return label.toLowerCase()
+        + "="
+        + value
+        + (accountId != null ? "," + formatAccount(accountId) : "");
+  }
+
   private static String formatAccount(Account.Id accountId) {
     if (ChangeQueryBuilder.OWNER_ACCOUNT_ID.equals(accountId)) {
       return ChangeQueryBuilder.ARG_ID_OWNER;
+    } else if (ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID.equals(accountId)) {
+      return ChangeQueryBuilder.ARG_ID_NON_UPLOADER;
     }
     return Integer.toString(accountId.get());
   }
@@ -674,17 +768,24 @@
               cd ->
                   Stream.concat(
                           cd.publishedComments().stream().map(c -> c.message),
+                          // Some endpoint allow passing user message in input, and we still want to
+                          // search by that. Index on message template with placeholders for user
+                          // data, so we don't
+                          // persist user identifiable information data in index.
                           cd.messages().stream().map(ChangeMessage::getMessage))
                       .collect(toSet()));
 
   /** Number of unresolved comment threads of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
       intRange(ChangeQueryBuilder.FIELD_UNRESOLVED_COMMENT_COUNT)
-          .build(ChangeData::unresolvedCommentCount);
+          .build(
+              ChangeData::unresolvedCommentCount,
+              (cd, field) -> cd.setUnresolvedCommentCount(field));
 
   /** Total number of published inline comments of the change, including robot comments. */
   public static final FieldDef<ChangeData, Integer> TOTAL_COMMENT_COUNT =
-      intRange("total_comments").build(ChangeData::totalCommentCount);
+      intRange("total_comments")
+          .build(ChangeData::totalCommentCount, (cd, field) -> cd.setTotalCommentCount(field));
 
   /** Whether the change is mergeable. */
   public static final FieldDef<ChangeData, String> MERGEABLE =
@@ -697,7 +798,8 @@
                   return null;
                 }
                 return m ? "1" : "0";
-              });
+              },
+              (cd, field) -> cd.setMergeable(field == null ? false : field.equals("1")));
 
   /** Whether the change is a merge commit. */
   public static final FieldDef<ChangeData, String> MERGE =
@@ -712,15 +814,33 @@
                 return m ? "1" : "0";
               });
 
+  /** Whether the change is a cherry pick of another change. */
+  public static final FieldDef<ChangeData, String> CHERRY_PICK =
+      exact(ChangeQueryBuilder.FIELD_CHERRYPICK)
+          .stored()
+          .build(cd -> cd.change().getCherryPickOf() != null ? "1" : "0");
+
   /** The number of inserted lines in this change. */
   public static final FieldDef<ChangeData, Integer> ADDED =
       intRange(ChangeQueryBuilder.FIELD_ADDED)
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null);
+          .build(
+              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().insertions : null,
+              (cd, field) -> {
+                if (field != null) {
+                  cd.setLinesInserted(field);
+                }
+              });
 
   /** The number of deleted lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELETED =
       intRange(ChangeQueryBuilder.FIELD_DELETED)
-          .build(cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null);
+          .build(
+              cd -> cd.changedLines().isPresent() ? cd.changedLines().get().deletions : null,
+              (cd, field) -> {
+                if (field != null) {
+                  cd.setLinesDeleted(field);
+                }
+              });
 
   /** The total number of modified lines in this change. */
   public static final FieldDef<ChangeData, Integer> DELTA =
@@ -761,8 +881,12 @@
                   Iterables.transform(
                       cd.stars().entries(),
                       e ->
-                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue())
-                              .toString()));
+                          StarredChangesUtil.StarField.create(e.getKey(), e.getValue()).toString()),
+              (cd, field) ->
+                  cd.setStars(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> StarredChangesUtil.StarField.parse(f))
+                          .collect(toImmutableListMultimap(e -> e.accountId(), e -> e.label()))));
 
   /** Users that have starred the change with any label. */
   public static final FieldDef<ChangeData, Iterable<Integer>> STARBY =
@@ -778,7 +902,9 @@
   /** Serialized patch set object, used for pre-populating results. */
   public static final FieldDef<ChangeData, Iterable<byte[]>> PATCH_SET =
       storedOnly("_patch_set")
-          .buildRepeatable(cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()));
+          .buildRepeatable(
+              cd -> toProtos(PatchSetProtoConverter.INSTANCE, cd.patchSets()),
+              (cd, field) -> cd.setPatchSets(decodeProtos(field, PatchSetProtoConverter.INSTANCE)));
 
   /** Users who have edits on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> EDITBY =
@@ -812,7 +938,12 @@
                   return ImmutableSet.of(NOT_REVIEWED);
                 }
                 return reviewedBy.stream().map(Account.Id::get).collect(toList());
-              });
+              },
+              (cd, field) ->
+                  cd.setReviewedBy(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(Account::id)
+                          .collect(toImmutableSet())));
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
       SubmitRuleOptions.builder().recomputeOnClosedChanges(true).build();
@@ -820,6 +951,19 @@
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       SubmitRuleOptions.builder().build();
 
+  /** All submit rules results in the form of "$ruleName,$status". */
+  public static final FieldDef<ChangeData, Iterable<String>> SUBMIT_RULE_RESULT =
+      exact("submit_rule_result")
+          .buildRepeatable(
+              cd -> {
+                List<String> result = new ArrayList<>();
+                List<SubmitRecord> submitRecords = cd.submitRecords(SUBMIT_RULE_OPTIONS_STRICT);
+                for (SubmitRecord record : submitRecords) {
+                  result.add(record.ruleName + "=" + record.status.name());
+                }
+                return result;
+              });
+
   /**
    * JSON type for storing SubmitRecords.
    *
@@ -839,12 +983,14 @@
       @Deprecated Map<String, String> data;
     }
 
+    String ruleName;
     SubmitRecord.Status status;
     List<StoredLabel> labels;
     List<StoredRequirement> requirements;
     String errorMessage;
 
     public StoredSubmitRecord(SubmitRecord rec) {
+      this.ruleName = rec.ruleName;
       this.status = rec.status;
       this.errorMessage = rec.errorMessage;
       if (rec.labels != null) {
@@ -876,6 +1022,7 @@
 
     public SubmitRecord toSubmitRecord() {
       SubmitRecord rec = new SubmitRecord();
+      rec.ruleName = ruleName;
       rec.status = status;
       rec.errorMessage = errorMessage;
       if (labels != null) {
@@ -908,11 +1055,27 @@
 
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_STRICT =
       storedOnly("full_submit_record_strict")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT));
+          .buildRepeatable(
+              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_STRICT),
+              (cd, field) ->
+                  parseSubmitRecords(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toSet()),
+                      SUBMIT_RULE_OPTIONS_STRICT,
+                      cd));
 
   public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_RECORD_LENIENT =
       storedOnly("full_submit_record_lenient")
-          .buildRepeatable(cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT));
+          .buildRepeatable(
+              cd -> storedSubmitRecords(cd, SUBMIT_RULE_OPTIONS_LENIENT),
+              (cd, field) ->
+                  parseSubmitRecords(
+                      StreamSupport.stream(field.spliterator(), false)
+                          .map(f -> new String(f, UTF_8))
+                          .collect(toSet()),
+                      SUBMIT_RULE_OPTIONS_LENIENT,
+                      cd));
 
   public static void parseSubmitRecords(
       Collection<String> values, SubmitRuleOptions opts, ChangeData out) {
@@ -964,6 +1127,27 @@
     return result;
   }
 
+  /** Serialized submit requirements, used for pre-populating results. */
+  public static final FieldDef<ChangeData, Iterable<byte[]>> STORED_SUBMIT_REQUIREMENTS =
+      storedOnly("full_submit_requirements")
+          .buildRepeatable(
+              cd ->
+                  toProtos(
+                      SubmitRequirementProtoConverter.INSTANCE, cd.submitRequirements().values()),
+              (cd, field) -> parseSubmitRequirements(field, cd));
+
+  private static void parseSubmitRequirements(Iterable<byte[]> values, ChangeData out) {
+    out.setSubmitRequirements(
+        StreamSupport.stream(values.spliterator(), false)
+            .map(
+                f ->
+                    SubmitRequirementProtoConverter.INSTANCE.fromProto(
+                        Protos.parseUnchecked(
+                            SubmitRequirementProtoConverter.INSTANCE.getParser(), f)))
+            .collect(
+                ImmutableMap.toImmutableMap(sr -> sr.submitRequirement(), Function.identity())));
+  }
+
   /**
    * All values of all refs that were used in the course of indexing this document.
    *
@@ -978,7 +1162,8 @@
                     .entries()
                     .forEach(e -> result.add(e.getValue().toByteArray(e.getKey())));
                 return result;
-              });
+              },
+              (cd, field) -> cd.setRefStates(RefState.parseStates(field)));
 
   /**
    * All ref wildcard patterns that were used in the course of indexing this document.
@@ -1004,7 +1189,8 @@
                     RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
                         .toByteArray(allUsers(cd)));
                 return result;
-              });
+              },
+              (cd, field) -> cd.setRefStatePatterns(field));
 
   private static String getTopic(ChangeData cd) {
     Change c = cd.change();
@@ -1022,6 +1208,18 @@
     return Protos.toByteArray(converter.toProto(object));
   }
 
+  private static <T> List<T> decodeProtos(Iterable<byte[]> raw, ProtoConverter<?, T> converter) {
+    return StreamSupport.stream(raw.spliterator(), false)
+        .map(bytes -> parseProtoFrom(bytes, converter))
+        .collect(toImmutableList());
+  }
+
+  private static <P extends MessageLite, T> T parseProtoFrom(
+      byte[] bytes, ProtoConverter<P, T> converter) {
+    P message = Protos.parseUnchecked(converter.getParser(), bytes, 0, bytes.length);
+    return converter.fromProto(message);
+  }
+
   private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndex.java b/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 49d0d4e..05c5c77 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -19,8 +19,7 @@
 import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
-import com.google.gerrit.server.query.change.LegacyChangeIdStrPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 
 /**
  * Index for Gerrit changes. This class is mainly used for typing the generic parent class that
@@ -32,7 +31,7 @@
   @Override
   default Predicate<ChangeData> keyPredicate(Change.Id id) {
     return getSchema().useLegacyNumericFields()
-        ? new LegacyChangeIdPredicate(id)
-        : new LegacyChangeIdStrPredicate(id);
+        ? ChangePredicates.id(id)
+        : ChangePredicates.idStr(id);
   }
 }
diff --git a/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index a088af0..8f68904 100644
--- a/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -47,6 +47,7 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ConcurrentHashMap;
@@ -81,8 +82,7 @@
   private final boolean autoReindexIfStale;
   private final IsFirstInsertForEntry isFirstInsertForEntry;
 
-  private final Set<IndexTask> queuedIndexTasks =
-      Collections.newSetFromMap(new ConcurrentHashMap<>());
+  private final Map<Change.Id, IndexTask> queuedIndexTasks = new ConcurrentHashMap<>();
   private final Set<ReindexIfStaleTask> queuedReindexIfStaleTasks =
       Collections.newSetFromMap(new ConcurrentHashMap<>());
 
@@ -143,16 +143,44 @@
   /**
    * Start indexing a change.
    *
-   * @param id change to index.
+   * @param changeId change to index.
    * @return future for the indexing task.
    */
-  public ListenableFuture<?> indexAsync(Project.NameKey project, Change.Id id) {
-    IndexTask task = new IndexTask(project, id);
-    if (queuedIndexTasks.add(task)) {
-      fireChangeScheduledForIndexingEvent(project.get(), id.get());
-      return submit(task);
-    }
-    return Futures.immediateFuture(null);
+  public ListenableFuture<ChangeData> indexAsync(Project.NameKey project, Change.Id changeId) {
+    // If the change was already scheduled for indexing, we do not need to schedule it again. Change
+    // updates that happened after the change was scheduled for indexing will automatically be taken
+    // into account when the index task is executed (as it reads the current change state).
+    // To skip duplicate index requests, queuedIndexTasks keeps track of the scheduled index tasks.
+    // Here we check if the change has already been scheduled for indexing, and only if not we
+    // create a new index task for the change.
+    // By using computeIfAbsent we ensure that the lookup and the insertion of a new task happens
+    // atomically. Some attempted update operations on this map by other threads may be blocked
+    // while the computation is in progress (but not all as ConcurrentHashMap doesn't lock the
+    // entire table on write, but only segments of the table).
+    IndexTask task =
+        queuedIndexTasks.computeIfAbsent(
+            changeId,
+            id -> {
+              fireChangeScheduledForIndexingEvent(project.get(), id.get());
+              return new IndexTask(project, id);
+            });
+    // Submitting the task to the executor must not happen from within the computeIfAbsent callback,
+    // as this could result in the task being executed before the computeIfAbsent method has
+    // finished (e.g. if a direct executor is used, but also if starting the task asynchronously is
+    // faster than finishing the computeIfAbsent method). This could lead to failures and unexpected
+    // behavior:
+    // * The first thing that IndexTask does is to remove itself from queuedIndexTasks.
+    //   This is done so that index requests which are received while an index task for the same
+    //   change is in progress, are not dropped but added to the queue. This is important since
+    //   the change state that is written to the index is read at the beginning of the index task
+    //   and change updates that happen after this read will not be considered when updating the
+    //   index.
+    // * Trying to remove the IndexTask from queuedIndexTasks at the beginning of the task doesn't
+    //   work if the computeIfAbsent method hasn't finished yet. Either the queuedIndexTasks doesn't
+    //   contain the new entry yet and the removal has no effect as it is done before the entry is
+    //   added to the map, or the removal fails with {@link IllegalStateException} as recursive
+    //   updates from within the computeIfAbsent callback are not allowed.
+    return task.submitIfNeeded();
   }
 
   /**
@@ -161,8 +189,9 @@
    * @param ids changes to index.
    * @return future for completing indexing of all changes.
    */
-  public ListenableFuture<?> indexAsync(Project.NameKey project, Collection<Change.Id> ids) {
-    List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
+  public ListenableFuture<List<ChangeData>> indexAsync(
+      Project.NameKey project, Collection<Change.Id> ids) {
+    List<ListenableFuture<ChangeData>> futures = new ArrayList<>(ids.size());
     for (Change.Id id : ids) {
       futures.add(indexAsync(project, id));
     }
@@ -214,7 +243,7 @@
                   .patchSetId(cd.currentPatchSet().number())
                   .indexVersion(i.getSchema().getVersion())
                   .build())) {
-        if (isFirstInsertForEntry.equals(isFirstInsertForEntry.YES)) {
+        if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
           i.insert(cd);
         } else {
           i.replace(cd);
@@ -269,9 +298,9 @@
    * Start deleting a change.
    *
    * @param id change to delete.
-   * @return future for the deleting task.
+   * @return future for the deleting task, the result of the future is always {@code null}
    */
-  public ListenableFuture<?> deleteAsync(Change.Id id) {
+  public ListenableFuture<ChangeData> deleteAsync(Change.Id id) {
     fireChangeScheduledForDeletionFromIndexEvent(id.get());
     return submit(new DeleteTask(id));
   }
@@ -369,17 +398,46 @@
     }
   }
 
-  private class IndexTask extends AbstractIndexTask<Void> {
+  private class IndexTask extends AbstractIndexTask<ChangeData> {
+    ListenableFuture<ChangeData> future;
+
     private IndexTask(Project.NameKey project, Change.Id id) {
       super(project, id);
     }
 
+    /**
+     * Submits this task to be executed, if it wasn't submitted yet.
+     *
+     * <p>Submits this task to the executor if it hasn't been submitted yet. The future is cached so
+     * that it can be returned if this method is called again.
+     *
+     * <p>This method must be synchronized so that concurrent calls do not submit this task to the
+     * executor multiple times.
+     *
+     * @return future from which the result of the index task (the {@link ChangeData} instance) can
+     *     be retrieved.
+     */
+    private synchronized ListenableFuture<ChangeData> submitIfNeeded() {
+      if (future == null) {
+        future = submit(this);
+      }
+      return future;
+    }
+
     @Override
-    public Void callImpl() throws Exception {
+    public ChangeData callImpl() throws Exception {
+      // Remove this task from queuedIndexTasks. This is done right at the beginning of this task so
+      // that index requests which are received for the same change while this index task is in
+      // progress, are not dropped but added to the queue. This is important since change updates
+      // that happen after reading the change notes below will not be considered when updating the
+      // index.
       remove();
+
       try {
         ChangeNotes changeNotes = notesFactory.createChecked(project, id);
-        doIndex(changeDataFactory.create(changeNotes));
+        ChangeData changeData = changeDataFactory.create(changeNotes);
+        doIndex(changeData);
+        return changeData;
       } catch (NoSuchChangeException e) {
         doDelete(id);
       }
@@ -407,12 +465,12 @@
 
     @Override
     protected void remove() {
-      queuedIndexTasks.remove(this);
+      queuedIndexTasks.remove(id);
     }
   }
 
   // Not AbstractIndexTask as it doesn't need a request context.
-  private class DeleteTask implements Callable<Void> {
+  private class DeleteTask implements Callable<ChangeData> {
     private final Change.Id id;
 
     private DeleteTask(Change.Id id) {
@@ -420,7 +478,7 @@
     }
 
     @Override
-    public Void call() {
+    public ChangeData call() {
       logger.atFine().log("Delete change %d from index.", id.get());
       // Don't bother setting a RequestContext to provide the DB.
       // Implementations should not need to access the DB in order to delete a
diff --git a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index 969b071..9339d62 100644
--- a/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -135,9 +135,57 @@
       new Schema.Builder<ChangeData>().add(V59).add(ChangeField.MERGE).build();
 
   /** Added new field {@link ChangeField#MERGED_ON} */
+  @Deprecated
   static final Schema<ChangeData> V61 =
       new Schema.Builder<ChangeData>().add(V60).add(ChangeField.MERGED_ON).build();
 
+  /** Added new field {@link ChangeField#FUZZY_HASHTAG} */
+  @Deprecated
+  static final Schema<ChangeData> V62 =
+      new Schema.Builder<ChangeData>().add(V61).add(ChangeField.FUZZY_HASHTAG).build();
+
+  /**
+   * The computation of the {@link ChangeField#DIRECTORY} field is changed, hence reindexing is
+   * required.
+   */
+  @Deprecated static final Schema<ChangeData> V63 = schema(V62, false);
+
+  /** Added support for MIN/MAX/ANY for {@link ChangeField#LABEL} */
+  @Deprecated static final Schema<ChangeData> V64 = schema(V63, false);
+
+  /** Added new field for submit requirements. */
+  @Deprecated
+  static final Schema<ChangeData> V65 =
+      new Schema.Builder<ChangeData>().add(V64).add(ChangeField.STORED_SUBMIT_REQUIREMENTS).build();
+
+  /**
+   * The computation of {@link ChangeField#LABEL} has changed: We added the non_uploader arg to the
+   * label field.
+   */
+  @Deprecated static final Schema<ChangeData> V66 = schema(V65, false);
+
+  /** Updated submit records: store the rule name that created the submit record. */
+  @Deprecated static final Schema<ChangeData> V67 = schema(V66, false);
+
+  /** Added new field {@link ChangeField#SUBMIT_RULE_RESULT}. */
+  @Deprecated
+  static final Schema<ChangeData> V68 =
+      new Schema.Builder<ChangeData>().add(V67).add(ChangeField.SUBMIT_RULE_RESULT).build();
+
+  /** Added new field {@link ChangeField#CHERRY_PICK}. */
+  @Deprecated
+  static final Schema<ChangeData> V69 =
+      new Schema.Builder<ChangeData>().add(V68).add(ChangeField.CHERRY_PICK).build();
+
+  /** Added new field {@link ChangeField#ATTENTION_SET_USERS_COUNT}. */
+  @Deprecated
+  static final Schema<ChangeData> V70 =
+      new Schema.Builder<ChangeData>().add(V69).add(ChangeField.ATTENTION_SET_USERS_COUNT).build();
+
+  /** Added new field {@link ChangeField#UPLOADER}. */
+  static final Schema<ChangeData> V71 =
+      new Schema.Builder<ChangeData>().add(V70).add(ChangeField.UPLOADER).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/ReindexAfterRefUpdate.java b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
index e39873e..49f6ff9 100644
--- a/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
+++ b/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -87,11 +87,14 @@
 
   @Override
   public void onGitReferenceUpdated(Event event) {
-    if (allUsersName.get().equals(event.getProjectName())) {
+    if (allUsersName.get().equals(event.getProjectName())
+        && !RefNames.REFS_CONFIG.equals(event.getRefName())) {
       Account.Id accountId = Account.Id.fromRef(event.getRefName());
       if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
         indexer.get().index(accountId);
       }
+      // The update is in All-Users and not on refs/meta/config. So it's not a change. Return early.
+      return;
     }
 
     if (!enabled
diff --git a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 075a4ce..3773d435 100644
--- a/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -103,7 +103,7 @@
                   groupCache.evict(uuid);
                   InternalGroup internalGroup = reindexedGroups.get(uuid);
                   if (internalGroup != null) {
-                    if (isFirstInsertForEntry.equals(isFirstInsertForEntry.YES)) {
+                    if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
                       index.insert(internalGroup);
                     } else {
                       index.replace(internalGroup);
diff --git a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
index 86c7e94..1c977d1 100644
--- a/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
+++ b/java/com/google/gerrit/server/index/project/AllProjectsIndexer.java
@@ -86,7 +86,7 @@
                   projectCache.evict(name);
                   ProjectData projectData =
                       projectCache.get(name).orElseThrow(illegalState(name)).toProjectData();
-                  if (isFirstInsertForEntry.equals(isFirstInsertForEntry.YES)) {
+                  if (isFirstInsertForEntry.equals(IsFirstInsertForEntry.YES)) {
                     index.insert(projectData);
                   } else {
                     index.replace(projectData);
diff --git a/java/com/google/gerrit/server/ioutil/HostPlatform.java b/java/com/google/gerrit/server/ioutil/HostPlatform.java
index 39e9c07..e27d17c 100644
--- a/java/com/google/gerrit/server/ioutil/HostPlatform.java
+++ b/java/com/google/gerrit/server/ioutil/HostPlatform.java
@@ -21,7 +21,7 @@
   private static final boolean win32 = compute("windows");
   private static final boolean mac = compute("mac");
 
-  /** @return true if this JVM is running on a Windows platform. */
+  /** Returns true if this JVM is running on a Windows platform. */
   public static boolean isWin32() {
     return win32;
   }
diff --git a/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java b/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java
index 015887b..a58d9ae 100644
--- a/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java
+++ b/java/com/google/gerrit/server/ioutil/LimitedByteArrayOutputStream.java
@@ -57,7 +57,7 @@
     buffer.write(b, off, len);
   }
 
-  /** @return a newly allocated byte array with contents of the buffer. */
+  /** Returns a newly allocated byte array with contents of the buffer. */
   public byte[] toByteArray() {
     return buffer.toByteArray();
   }
diff --git a/java/com/google/gerrit/server/logging/BUILD b/java/com/google/gerrit/server/logging/BUILD
index c60af0d..ee0168c 100644
--- a/java/com/google/gerrit/server/logging/BUILD
+++ b/java/com/google/gerrit/server/logging/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/util/time",
         "//lib:gson",
         "//lib:guava",
diff --git a/java/com/google/gerrit/server/logging/CallerFinder.java b/java/com/google/gerrit/server/logging/CallerFinder.java
index bd7e608..4cb4b7f 100644
--- a/java/com/google/gerrit/server/logging/CallerFinder.java
+++ b/java/com/google/gerrit/server/logging/CallerFinder.java
@@ -41,7 +41,7 @@
  *
  * <p>E.g. the stacktrace could look like this:
  *
- * <pre>
+ * <pre>{@code
  * GroupQueryProcessor(QueryProcessor<T>).query(List<String>, List<Predicate<T>>) line: 216
  * GroupQueryProcessor(QueryProcessor<T>).query(List<Predicate<T>>) line: 188
  * GroupQueryProcessor(QueryProcessor<T>).query(Predicate<T>) line: 171
@@ -52,7 +52,7 @@
  * GroupCacheImpl$ByNameLoader.load(Object) line: 1
  * LocalCache$LoadingValueReference<K,V>.loadFuture(K, CacheLoader<? super K,V>) line: 3527
  * ...
- * </pre>
+ * }</pre>
  *
  * <p>The first interesting caller is {@code GroupCacheImpl$ByNameLoader.load(String) line: 166}. To
  * find this caller from the stacktrace we could specify {@link
diff --git a/java/com/google/gerrit/server/logging/LoggingContext.java b/java/com/google/gerrit/server/logging/LoggingContext.java
index 5611d08..3907da5 100644
--- a/java/com/google/gerrit/server/logging/LoggingContext.java
+++ b/java/com/google/gerrit/server/logging/LoggingContext.java
@@ -19,7 +19,7 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSetMultimap;
-import com.google.common.flogger.backend.Tags;
+import com.google.common.flogger.context.Tags;
 import com.google.inject.Provider;
 import java.util.List;
 import java.util.concurrent.Callable;
diff --git a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
index 3c4c563..1bba018 100644
--- a/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
+++ b/java/com/google/gerrit/server/logging/LoggingContextAwareRunnable.java
@@ -28,24 +28,24 @@
  *
  * <p>Example:
  *
- * <pre>
- *   try (TraceContext traceContext = TraceContext.newTrace(true, ...)) {
- *     executor
- *         .submit(new LoggingContextAwareRunnable(
- *             () -> {
- *               // Tracing is enabled since the runnable is created within the TraceContext.
- *               // Tracing is even enabled if the executor runs the runnable only after the
- *               // TraceContext was closed.
+ * <pre>{@code
+ * try (TraceContext traceContext = TraceContext.newTrace(true, ...)) {
+ *   executor
+ *       .submit(new LoggingContextAwareRunnable(
+ *           () -> {
+ *             // Tracing is enabled since the runnable is created within the TraceContext.
+ *             // Tracing is even enabled if the executor runs the runnable only after the
+ *             // TraceContext was closed.
  *
- *               // The tag "foo=bar" is not set, since it was added to the logging context only
- *               // after this runnable was created.
+ *             // The tag "foo=bar" is not set, since it was added to the logging context only
+ *             // after this runnable was created.
  *
- *               // do stuff
- *             }))
- *         .get();
- *     traceContext.addTag("foo", "bar");
- *   }
- * </pre>
+ *             // do stuff
+ *           }))
+ *       .get();
+ *   traceContext.addTag("foo", "bar");
+ * }
+ * }</pre>
  *
  * @see LoggingContextAwareCallable
  */
diff --git a/java/com/google/gerrit/server/logging/Metadata.java b/java/com/google/gerrit/server/logging/Metadata.java
index 71ee01f..89b5b46 100644
--- a/java/com/google/gerrit/server/logging/Metadata.java
+++ b/java/com/google/gerrit/server/logging/Metadata.java
@@ -55,6 +55,12 @@
   /** The name of the implementation class. */
   public abstract Optional<String> className();
 
+  /**
+   * The reason of a request cancellation (CLIENT_CLOSED_REQUEST, CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+   * SERVER_DEADLINE_EXCEEDED).
+   */
+  public abstract Optional<String> cancellationReason();
+
   /** The numeric ID of a change. */
   public abstract Optional<Integer> changeId();
 
@@ -66,9 +72,15 @@
   /** The cause of an error. */
   public abstract Optional<String> cause();
 
+  /** Side where the comment is written: <= 0 for parent, 1 for revision. */
+  public abstract Optional<Integer> commentSide();
+
   /** The SHA1 of a commit. */
   public abstract Optional<String> commit();
 
+  /** Diff algorithm used in diff computation. */
+  public abstract Optional<String> diffAlgorithm();
+
   /** The type of an event. */
   public abstract Optional<String> eventType();
 
@@ -144,6 +156,9 @@
   /** The type of a Git push to Gerrit (CREATE_REPLACE, NORMAL, AUTOCLOSE). */
   public abstract Optional<String> pushType();
 
+  /** The type of a Git push to Gerrit (GIT_RECEIVE, GIT_UPLOAD, REST, SSH). */
+  public abstract Optional<String> requestType();
+
   /** The number of resources that is processed. */
   public abstract Optional<Integer> resourceCount();
 
@@ -167,17 +182,18 @@
    * <pre>
    * Metadata{accountId=Optional.empty, actionType=Optional.empty, authDomainName=Optional.empty,
    * branchName=Optional.empty, cacheKey=Optional.empty, cacheName=Optional.empty,
-   * className=Optional.empty, changeId=Optional[9212550], changeIdType=Optional.empty,
-   * cause=Optional.empty, eventType=Optional.empty, exportValue=Optional.empty,
-   * filePath=Optional.empty, garbageCollectorName=Optional.empty, gitOperation=Optional.empty,
-   * groupId=Optional.empty, groupName=Optional.empty, groupUuid=Optional.empty,
-   * httpStatus=Optional.empty, indexName=Optional.empty, indexVersion=Optional[0],
-   * methodName=Optional.empty, multiple=Optional.empty, operationName=Optional.empty,
-   * partial=Optional.empty, noteDbFilePath=Optional.empty, noteDbRefName=Optional.empty,
+   * className=Optional.empty, cancellationReason=Optional.empty changeId=Optional[9212550],
+   * changeIdType=Optional.empty, cause=Optional.empty, diffAlgorithm=Optional.empty,
+   * eventType=Optional.empty, exportValue=Optional.empty, filePath=Optional.empty,
+   * garbageCollectorName=Optional.empty, gitOperation=Optional.empty, groupId=Optional.empty,
+   * groupName=Optional.empty, groupUuid=Optional.empty, httpStatus=Optional.empty,
+   * indexName=Optional.empty, indexVersion=Optional[0], methodName=Optional.empty,
+   * multiple=Optional.empty, operationName=Optional.empty, partial=Optional.empty,
+   * noteDbFilePath=Optional.empty, noteDbRefName=Optional.empty,
    * noteDbSequenceType=Optional.empty, patchSetId=Optional.empty, pluginMetadata=[],
    * pluginName=Optional.empty, projectName=Optional.empty, pushType=Optional.empty,
-   * resourceCount=Optional.empty, restViewName=Optional.empty, revision=Optional.empty,
-   * username=Optional.empty}
+   * requestType=Optional.empty, resourceCount=Optional.empty, restViewName=Optional.empty,
+   * revision=Optional.empty, username=Optional.empty}
    * </pre>
    *
    * <p>That's hard to read in logs. This is why this method
@@ -282,14 +298,20 @@
 
     public abstract Builder className(@Nullable String className);
 
+    public abstract Builder cancellationReason(@Nullable String cancellationReason);
+
     public abstract Builder changeId(int changeId);
 
     public abstract Builder changeIdType(@Nullable String changeIdType);
 
     public abstract Builder cause(@Nullable String cause);
 
+    public abstract Builder commentSide(int side);
+
     public abstract Builder commit(@Nullable String commit);
 
+    public abstract Builder diffAlgorithm(@Nullable String diffAlgorithm);
+
     public abstract Builder eventType(@Nullable String eventType);
 
     public abstract Builder exportValue(@Nullable String exportValue);
@@ -345,6 +367,8 @@
 
     public abstract Builder pushType(@Nullable String pushType);
 
+    public abstract Builder requestType(@Nullable String requestType);
+
     public abstract Builder resourceCount(int resourceCount);
 
     public abstract Builder restViewName(@Nullable String restViewName);
diff --git a/java/com/google/gerrit/server/logging/MutableTags.java b/java/com/google/gerrit/server/logging/MutableTags.java
index 83009a6..3f48b59 100644
--- a/java/com/google/gerrit/server/logging/MutableTags.java
+++ b/java/com/google/gerrit/server/logging/MutableTags.java
@@ -20,7 +20,7 @@
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
-import com.google.common.flogger.backend.Tags;
+import com.google.common.flogger.context.Tags;
 
 public class MutableTags {
   private final SetMultimap<String, String> tagMap =
diff --git a/java/com/google/gerrit/server/logging/PerformanceLogContext.java b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
index b6dafdc..65e033b15 100644
--- a/java/com/google/gerrit/server/logging/PerformanceLogContext.java
+++ b/java/com/google/gerrit/server/logging/PerformanceLogContext.java
@@ -92,7 +92,7 @@
             p -> {
               try (TraceContext traceContext = newPluginTrace(p)) {
                 performanceLogRecords.forEach(r -> r.writeTo(p.get()));
-              } catch (Throwable e) {
+              } catch (RuntimeException e) {
                 logger.atWarning().withCause(e).log(
                     "Failure in %s of plugin %s", p.get().getClass(), p.getPluginName());
               }
diff --git a/java/com/google/gerrit/server/logging/TraceContext.java b/java/com/google/gerrit/server/logging/TraceContext.java
index 2fc19b5..487e0af 100644
--- a/java/com/google/gerrit/server/logging/TraceContext.java
+++ b/java/com/google/gerrit/server/logging/TraceContext.java
@@ -24,6 +24,7 @@
 import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Consumer;
@@ -208,6 +209,7 @@
     }
 
     private TraceTimer(Runnable startLogFn, Consumer<Long> doneLogFn) {
+      RequestStateContext.abortIfCancelled();
       startLogFn.run();
       this.doneLogFn = doneLogFn;
       this.stopwatch = Stopwatch.createStarted();
@@ -217,6 +219,7 @@
     public void close() {
       stopwatch.stop();
       doneLogFn.accept(stopwatch.elapsed(TimeUnit.MILLISECONDS));
+      RequestStateContext.abortIfCancelled();
     }
   }
 
@@ -265,15 +268,23 @@
     return this;
   }
 
-  public boolean isTracing() {
+  public static boolean isTracing() {
     return LoggingContext.getInstance().isLoggingForced();
   }
 
-  public Optional<String> getTraceId() {
+  public static Optional<String> getTraceId() {
     return LoggingContext.getInstance().getTagsAsMap().get(RequestId.Type.TRACE_ID.name()).stream()
         .findFirst();
   }
 
+  public static Optional<String> getPluginTag() {
+    return getTag(PLUGIN_TAG);
+  }
+
+  public static Optional<String> getTag(String tagName) {
+    return LoggingContext.getInstance().getTagsAsMap().get(tagName).stream().findFirst();
+  }
+
   public TraceContext enableAclLogging() {
     if (stopAclLoggingOnClose) {
       return this;
@@ -283,11 +294,7 @@
     return this;
   }
 
-  public boolean isAclLoggingEnabled() {
-    return LoggingContext.getInstance().isAclLogging();
-  }
-
-  public ImmutableList<String> getAclLogRecords() {
+  public static ImmutableList<String> getAclLogRecords() {
     return LoggingContext.getInstance().getAclLogRecords();
   }
 
diff --git a/java/com/google/gerrit/server/mail/EmailModule.java b/java/com/google/gerrit/server/mail/EmailModule.java
index ff166b1..c659b5f 100644
--- a/java/com/google/gerrit/server/mail/EmailModule.java
+++ b/java/com/google/gerrit/server/mail/EmailModule.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.server.mail.send.AbandonedSender;
 import com.google.gerrit.server.mail.send.AddKeySender;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
 import com.google.gerrit.server.mail.send.AddToAttentionSetSender;
 import com.google.gerrit.server.mail.send.CommentSender;
 import com.google.gerrit.server.mail.send.CreateChangeSender;
@@ -26,6 +25,7 @@
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.mail.send.MergedSender;
+import com.google.gerrit.server.mail.send.ModifyReviewerSender;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.gerrit.server.mail.send.RemoveFromAttentionSetSender;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
@@ -38,7 +38,7 @@
   protected void configure() {
     factory(AbandonedSender.Factory.class);
     factory(AddKeySender.Factory.class);
-    factory(AddReviewerSender.Factory.class);
+    factory(ModifyReviewerSender.Factory.class);
     factory(CommentSender.Factory.class);
     factory(CreateChangeSender.Factory.class);
     factory(DeleteKeySender.Factory.class);
diff --git a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
index 2ff5fc3..ead4c06 100644
--- a/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/EmailTokenVerifier.java
@@ -56,10 +56,13 @@
   class ParsedToken {
     private final Account.Id accountId;
     private final String emailAddress;
+    private final AuthRequest.Factory authRequestFactory;
 
-    public ParsedToken(Account.Id accountId, String emailAddress) {
+    public ParsedToken(
+        Account.Id accountId, String emailAddress, AuthRequest.Factory authRequestFactory) {
       this.accountId = accountId;
       this.emailAddress = emailAddress;
+      this.authRequestFactory = authRequestFactory;
     }
 
     public Account.Id getAccountId() {
@@ -71,7 +74,7 @@
     }
 
     public AuthRequest toAuthRequest() {
-      return AuthRequest.forEmail(getEmailAddress());
+      return authRequestFactory.createForEmail(getEmailAddress());
     }
 
     @Override
diff --git a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
index 77be665..36e801b 100644
--- a/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
+++ b/java/com/google/gerrit/server/mail/SignedTokenEmailTokenVerifier.java
@@ -19,6 +19,7 @@
 
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
 import com.google.inject.AbstractModule;
@@ -31,8 +32,9 @@
 @Singleton
 public class SignedTokenEmailTokenVerifier implements EmailTokenVerifier {
   private final SignedToken emailRegistrationToken;
+  private final AuthRequest.Factory authRequestFactory;
 
-  public static class Module extends AbstractModule {
+  public static class SignedTokenEmailTokenVerifierModule extends AbstractModule {
     @Override
     protected void configure() {
       bind(EmailTokenVerifier.class).to(SignedTokenEmailTokenVerifier.class);
@@ -40,8 +42,9 @@
   }
 
   @Inject
-  SignedTokenEmailTokenVerifier(AuthConfig config) {
+  SignedTokenEmailTokenVerifier(AuthConfig config, AuthRequest.Factory authRequestFactory) {
     emailRegistrationToken = config.getEmailRegistrationToken();
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -77,7 +80,7 @@
     }
     Account.Id id = Account.Id.tryParse(matcher.group(1)).orElseThrow(InvalidTokenException::new);
     String newEmail = matcher.group(2);
-    return new ParsedToken(id, newEmail);
+    return new ParsedToken(id, newEmail, authRequestFactory);
   }
 
   private void checkEmailRegistrationToken() {
diff --git a/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index df38118..0710784 100644
--- a/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.receive;
 
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
@@ -23,7 +24,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -44,7 +44,6 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailMetadata;
 import com.google.gerrit.mail.TextParser;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
@@ -52,11 +51,13 @@
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.UrlFormatter;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
 import com.google.gerrit.server.mail.send.InboundEmailRejectionSender;
+import com.google.gerrit.server.mail.send.InboundEmailRejectionSender.InboundEmailError;
 import com.google.gerrit.server.mail.send.MessageIdGenerator;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -65,7 +66,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
@@ -92,7 +93,7 @@
   private static final ImmutableMap<MailComment.CommentType, CommentForValidation.CommentType>
       MAIL_COMMENT_TYPE_TO_VALIDATION_TYPE =
           ImmutableMap.of(
-              MailComment.CommentType.CHANGE_MESSAGE,
+              MailComment.CommentType.PATCHSET_LEVEL,
                   CommentForValidation.CommentType.CHANGE_MESSAGE,
               MailComment.CommentType.FILE_COMMENT, CommentForValidation.CommentType.FILE_COMMENT,
               MailComment.CommentType.INLINE_COMMENT,
@@ -184,7 +185,7 @@
       logger.atSevere().log(
           "Message %s is missing required metadata, have %s. Will delete message.",
           message.id(), metadata);
-      sendRejectionEmail(message, InboundEmailRejectionSender.Error.PARSING_ERROR);
+      sendRejectionEmail(message, InboundEmailError.PARSING_ERROR);
       return;
     }
 
@@ -198,7 +199,7 @@
 
       // We don't want to send an email if no accounts are linked to it.
       if (accountIds.size() > 1) {
-        sendRejectionEmail(message, InboundEmailRejectionSender.Error.UNKNOWN_ACCOUNT);
+        sendRejectionEmail(message, InboundEmailError.UNKNOWN_ACCOUNT);
       }
       return;
     }
@@ -210,14 +211,14 @@
     }
     if (!accountState.get().account().isActive()) {
       logger.atWarning().log("Mail: Account %s is inactive. Will delete message.", accountId);
-      sendRejectionEmail(message, InboundEmailRejectionSender.Error.INACTIVE_ACCOUNT);
+      sendRejectionEmail(message, InboundEmailError.INACTIVE_ACCOUNT);
       return;
     }
 
     persistComments(buf, message, metadata, accountId);
   }
 
-  private void sendRejectionEmail(MailMessage message, InboundEmailRejectionSender.Error reason) {
+  private void sendRejectionEmail(MailMessage message, InboundEmailError reason) {
     try {
       InboundEmailRejectionSender emailSender =
           emailRejectionSender.create(message.from(), message.id(), reason);
@@ -233,7 +234,14 @@
       throws UpdateException, RestApiException {
     try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
-          queryProvider.get().byLegacyChangeId(Change.id(metadata.changeNumber));
+          queryProvider
+              .get()
+              .enforceVisibility(true)
+              .byLegacyChangeId(Change.id(metadata.changeNumber));
+      if (changeDataList.isEmpty()) {
+        sendRejectionEmail(message, InboundEmailError.CHANGE_NOT_FOUND);
+        return;
+      }
       if (changeDataList.size() != 1) {
         logger.atSevere().log(
             "Message %s references unique change %s,"
@@ -241,7 +249,7 @@
                 + " Will delete message.",
             message.id(), metadata.changeNumber, changeDataList.size());
 
-        sendRejectionEmail(message, InboundEmailRejectionSender.Error.INTERNAL_EXCEPTION);
+        sendRejectionEmail(message, InboundEmailError.INTERNAL_EXCEPTION);
         return;
       }
       ChangeData cd = Iterables.getOnlyElement(changeDataList);
@@ -277,7 +285,7 @@
       if (parsedComments.isEmpty()) {
         logger.atWarning().log(
             "Could not parse any comments from %s. Will delete message.", message.id());
-        sendRejectionEmail(message, InboundEmailRejectionSender.Error.PARSING_ERROR);
+        sendRejectionEmail(message, InboundEmailError.PARSING_ERROR);
         return;
       }
 
@@ -298,7 +306,7 @@
           PublishCommentUtil.findInvalidComments(
               commentValidationCtx, commentValidators, parsedCommentsForValidation);
       if (!commentValidationFailures.isEmpty()) {
-        sendRejectionEmail(message, InboundEmailRejectionSender.Error.COMMENT_REJECTED);
+        sendRejectionEmail(message, InboundEmailError.COMMENT_REJECTED);
         return;
       }
 
@@ -313,7 +321,7 @@
     private final PatchSet.Id psId;
     private final List<MailComment> parsedComments;
     private final String tag;
-    private ChangeMessage changeMessage;
+    private String mailMessage;
     private List<HumanComment> comments;
     private PatchSet patchSet;
     private ChangeNotes notes;
@@ -332,14 +340,10 @@
         throw new StorageException("patch set not found: " + psId);
       }
 
-      changeMessage = generateChangeMessage(ctx);
-      changeMessagesUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
-
+      mailMessage =
+          changeMessagesUtil.setChangeMessage(ctx.getUpdate(psId), generateChangeMessage(), tag);
       comments = new ArrayList<>();
       for (MailComment c : parsedComments) {
-        if (c.getType() == MailComment.CommentType.CHANGE_MESSAGE) {
-          continue;
-        }
         comments.add(
             persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
@@ -352,9 +356,9 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
       String patchSetComment = null;
-      if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
+      if (parsedComments.get(0).getType() == MailComment.CommentType.PATCHSET_LEVEL) {
         patchSetComment = parsedComments.get(0).getMessage();
       }
       // Send email notifications
@@ -364,7 +368,8 @@
               notes,
               patchSet,
               ctx.getUser().asIdentifiedUser(),
-              changeMessage,
+              mailMessage,
+              ctx.getWhen(),
               comments,
               patchSetComment,
               ImmutableList.of(),
@@ -379,27 +384,19 @@
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
-          notes.getChange(),
+          ctx.getChangeData(notes),
           patchSet,
           ctx.getAccount(),
-          changeMessage.getMessage(),
+          mailMessage,
           approvals,
           approvals,
           ctx.getWhen());
     }
 
-    private ChangeMessage generateChangeMessage(ChangeContext ctx) {
+    private String generateChangeMessage() {
       String changeMsg = "Patch Set " + psId.get() + ":";
-      if (parsedComments.get(0).getType() == MailComment.CommentType.CHANGE_MESSAGE) {
-        // Add a blank line after Patch Set to follow the default format
-        if (parsedComments.size() > 1) {
-          changeMsg += "\n\n" + numComments(parsedComments.size() - 1);
-        }
-        changeMsg += "\n\n" + parsedComments.get(0).getMessage();
-      } else {
-        changeMsg += "\n\n" + numComments(parsedComments.size());
-      }
-      return ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+      changeMsg += "\n\n" + numComments(parsedComments.size());
+      return changeMsg;
     }
 
     private PatchSet targetPatchSetForComment(
@@ -418,7 +415,11 @@
       // The patch set that this comment is based on is different if this
       // comment was sent in reply to a comment on a previous patch set.
       Side side;
-      if (mailComment.getInReplyTo() != null) {
+      if (mailComment.getType() == MailComment.CommentType.PATCHSET_LEVEL) {
+        fileName = PATCHSET_LEVEL;
+        // Patchset comments do not have side.
+        side = Side.REVISION;
+      } else if (mailComment.getInReplyTo() != null) {
         fileName = mailComment.getInReplyTo().key.filename;
         side = Side.fromShort(mailComment.getInReplyTo().side);
       } else {
diff --git a/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index dc99b46..23e1cc3 100644
--- a/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -43,11 +43,11 @@
   private WorkQueue workQueue;
   private Timer timer;
 
-  public static class Module extends LifecycleModule {
+  public static class MailReceiverModule extends LifecycleModule {
     private final EmailSettings mailSettings;
 
     @Inject
-    Module(EmailSettings mailSettings) {
+    MailReceiverModule(EmailSettings mailSettings) {
       this.mailSettings = mailSettings;
     }
 
@@ -110,8 +110,6 @@
    * requestDeletion will enqueue an email for deletion and delete it the next time we connect to
    * the email server. This does not guarantee deletion as the Gerrit instance might fail before we
    * connect to the email server.
-   *
-   * @param messageId
    */
   public void requestDeletion(String messageId) {
     pendingDeletion.add(messageId);
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 81ce101..5c0132c 100644
--- a/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -41,11 +41,10 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -66,6 +65,7 @@
 import org.apache.james.mime4j.dom.field.FieldName;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.eclipse.jgit.util.TemporaryBuffer;
@@ -269,17 +269,23 @@
 
       if (patchSet != null) {
         detail.append("---\n");
-        PatchList patchList = getPatchList();
-        for (PatchListEntry p : patchList.getPatches()) {
-          if (Patch.isMagic(p.getNewName())) {
+        Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles();
+        for (FileDiffOutput fileDiff : modifiedFiles.values()) {
+          if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
             continue;
           }
           detail
-              .append(p.getChangeType().getCode())
+              .append(fileDiff.changeType().getCode())
               .append(" ")
-              .append(p.getNewName())
+              .append(
+                  FilePathAdapter.getNewPath(
+                      fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
               .append("\n");
         }
+        Integer insertions =
+            modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
+        Integer deletions =
+            modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
         detail.append(
             MessageFormat.format(
                 "" //
@@ -287,9 +293,9 @@
                     + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
                     + "\n",
-                patchList.getPatches().size() - 1, //
-                patchList.getInsertions(), //
-                patchList.getDeletions()));
+                modifiedFiles.size() - 1, //
+                insertions, //
+                deletions));
         detail.append("\n");
       }
       return detail.toString();
@@ -300,7 +306,8 @@
   }
 
   /** Get the patch list corresponding to patch set patchSetId of this change. */
-  protected PatchList getPatchList(int patchSetId) throws PatchListNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId)
+      throws DiffNotAvailableException {
     PatchSet ps;
     if (patchSetId == patchSet.number()) {
       ps = patchSet;
@@ -308,18 +315,20 @@
       try {
         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
       } catch (StorageException e) {
-        throw new PatchListNotAvailableException("Failed to get patchSet", e);
+        throw new DiffNotAvailableException("Failed to get patchSet", e);
       }
     }
-    return args.patchListCache.get(change, ps);
+    return args.diffOperations.listModifiedFilesAgainstParent(
+        change.getProject(), ps.commitId(), /* parentNum= */ 0);
   }
 
   /** Get the patch list corresponding to this patch set. */
-  protected PatchList getPatchList() throws PatchListNotAvailableException {
+  protected Map<String, FileDiffOutput> listModifiedFiles() throws DiffNotAvailableException {
     if (patchSet != null) {
-      return args.patchListCache.get(change, patchSet);
+      return args.diffOperations.listModifiedFilesAgainstParent(
+          change.getProject(), patchSet.commitId(), /* parentNum= */ 0);
     }
-    throw new PatchListNotAvailableException("no patchSet specified");
+    throw new DiffNotAvailableException("no patchSet specified");
   }
 
   /** Get the project entity the change is in; null if its been deleted. */
@@ -476,7 +485,7 @@
     soyContext.put("coverLetter", getCoverLetter());
     soyContext.put("fromName", getNameFor(fromId));
     soyContext.put("fromEmail", getNameEmailFor(fromId));
-    soyContext.put("diffLines", getDiffTemplateData());
+    soyContext.put("diffLines", getDiffTemplateData(getUnifiedDiff()));
 
     soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
     soyContextEmailData.put("changeDetail", getChangeDetail());
@@ -576,18 +585,15 @@
 
   /** Show patch set as unified difference. */
   public String getUnifiedDiff() {
-    PatchList patchList;
+    Map<String, FileDiffOutput> modifiedFiles;
     try {
-      patchList = getPatchList();
-      if (patchList.getOldId() == null) {
+      modifiedFiles = listModifiedFiles();
+      if (modifiedFiles.isEmpty()) {
         // Octopus merges are not well supported for diff output by Gerrit.
         // Currently these always have a null oldId in the PatchList.
         return "[Octopus merge; cannot be formatted as a diff.]\n";
       }
-    } catch (PatchListObjectTooLargeException e) {
-      logger.atWarning().log("Cannot format patch %s", e.getMessage());
-      return "";
-    } catch (PatchListNotAvailableException e) {
+    } catch (DiffNotAvailableException e) {
       logger.atSevere().withCause(e).log("Cannot format patch");
       return "";
     }
@@ -597,9 +603,11 @@
     try (DiffFormatter fmt = new DiffFormatter(buf)) {
       try (Repository git = args.server.openRepository(change.getProject())) {
         try {
+          ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
+          ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
           fmt.setRepository(git);
           fmt.setDetectRenames(true);
-          fmt.format(patchList.getOldId(), patchList.getNewId());
+          fmt.format(oldId, newId);
           return RawParseUtils.decode(buf.toByteArray());
         } catch (IOException e) {
           if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
@@ -619,11 +627,14 @@
    * Generate a list of maps representing each line of the unified diff. The line maps will have a
    * 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to the
    * line's content.
+   *
+   * @param sourceDiff the unified diff that we're converting to the map.
+   * @return map of 'type' to a line's content.
    */
-  private ImmutableList<ImmutableMap<String, String>> getDiffTemplateData() {
+  protected ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(String sourceDiff) {
     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
-    for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
+    for (String diffLine : lineSplitter.split(sourceDiff)) {
       ImmutableMap.Builder<String, String> lineData = ImmutableMap.builder();
       lineData.put("text", diffLine);
 
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java
index ac6c2f3..5a7352a 100644
--- a/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.mail.send;
 
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
@@ -36,10 +37,9 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.mail.receive.Protocol;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.gerrit.server.patch.PatchFile;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -72,23 +72,23 @@
     public PatchFile fileData;
     public List<Comment> comments = new ArrayList<>();
 
-    /** @return a web link to a comment for a change. */
+    /** Returns a web link to a comment for a change. */
     public String getCommentLink(String uuid) {
       return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
     }
 
-    /** @return a web link to the comment tab view of a change. */
+    /** Returns a web link to the comment tab view of a change. */
     public String getCommentsTabLink() {
       return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
     }
 
-    /** @return a web link to the findings tab view of a change. */
+    /** Returns a web link to the findings tab view of a change. */
     public String getFindingsTabLink() {
       return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
     }
 
     /**
-     * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
+     * Returns a title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
      */
     public String getTitle() {
       if (Patch.COMMIT_MSG.equals(filename)) {
@@ -181,8 +181,8 @@
   }
 
   /**
-   * @return a list of FileCommentGroup objects representing the inline comments grouped by the
-   *     file.
+   * Returns a list of FileCommentGroup objects representing the inline comments grouped by the
+   * file.
    */
   private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
     List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
@@ -198,30 +198,30 @@
         currentGroup = new FileCommentGroup();
         currentGroup.filename = c.key.filename;
         currentGroup.patchSetId = c.key.patchSetId;
-        // Get the patch list:
-        PatchList patchList = null;
+        // Get the modified files:
+        Map<String, FileDiffOutput> modifiedFiles = null;
         try {
-          patchList = getPatchList(c.key.patchSetId);
-        } catch (PatchListObjectTooLargeException e) {
-          logger.atWarning().log("Failed to get patch list: %s", e.getMessage());
-        } catch (PatchListNotAvailableException e) {
-          logger.atSevere().withCause(e).log("Failed to get patch list");
+          modifiedFiles = listModifiedFiles(c.key.patchSetId);
+        } catch (DiffNotAvailableException e) {
+          logger.atSevere().withCause(e).log("Failed to get modified files");
         }
 
         groups.add(currentGroup);
-        if (patchList != null) {
+        if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
           try {
-            currentGroup.fileData = new PatchFile(repo, patchList, c.key.filename);
+            currentGroup.fileData = new PatchFile(repo, modifiedFiles, c.key.filename);
           } catch (IOException e) {
             logger.atWarning().withCause(e).log(
                 "Cannot load %s from %s in %s",
-                c.key.filename, patchList.getNewId().name(), projectState.getName());
+                c.key.filename,
+                modifiedFiles.values().iterator().next().newCommitId().name(),
+                projectState.getName());
             currentGroup.fileData = null;
           }
         }
       }
 
-      if (currentGroup.fileData != null) {
+      if (currentGroup.filename.equals(PATCHSET_LEVEL) || currentGroup.fileData != null) {
         currentGroup.comments.add(c);
       }
     }
@@ -268,7 +268,7 @@
   }
 
   /**
-   * @return the lines of file content in fileData that are encompassed by range on the given side.
+   * Returns the lines of file content in fileData that are encompassed by range on the given side.
    */
   private List<String> getLinesByRange(Comment.Range range, PatchFile fileData, short side) {
     List<String> lines = new ArrayList<>();
@@ -331,9 +331,9 @@
   }
 
   /**
-   * @return a shortened version of the given comment's message. Will be shortened to 100 characters
-   *     or the first line, or following the last period within the first 100 characters, whichever
-   *     is shorter. If the message is shortened, an ellipsis is appended.
+   * Returns a shortened version of the given comment's message. Will be shortened to 100 characters
+   * or the first line, or following the last period within the first 100 characters, whichever is
+   * shorter. If the message is shortened, an ellipsis is appended.
    */
   protected static String getShortenedCommentMessage(String message) {
     int threshold = 100;
@@ -369,8 +369,8 @@
   }
 
   /**
-   * @return grouped inline comment data mapped to data structures that are suitable for passing
-   *     into Soy.
+   * Returns grouped inline comment data mapped to data structures that are suitable for passing
+   * into Soy.
    */
   private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
     List<Map<String, Object>> commentGroups = new ArrayList<>();
@@ -383,7 +383,9 @@
       List<Map<String, Object>> commentsList = new ArrayList<>();
       for (Comment comment : group.comments) {
         Map<String, Object> commentData = new HashMap<>();
-        commentData.put("lines", getLinesOfComment(comment, group.fileData));
+        if (group.fileData != null) {
+          commentData.put("lines", getLinesOfComment(comment, group.fileData));
+        }
         commentData.put("message", comment.message.trim());
         List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
         commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
diff --git a/java/com/google/gerrit/server/mail/send/EmailArguments.java b/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 808d6a4..96effc1 100644
--- a/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -18,13 +18,14 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AnonymousUser;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritInstanceName;
@@ -34,7 +35,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
@@ -42,6 +43,7 @@
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.ssh.SshAdvertisedAddresses;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -70,7 +72,7 @@
   final PermissionBackend permissionBackend;
   final GroupBackend groupBackend;
   final AccountCache accountCache;
-  final PatchListCache patchListCache;
+  final DiffOperations diffOperations;
   final PatchSetUtil patchSetUtil;
   final ApprovalsUtil approvalsUtil;
   final Provider<FromAddressGenerator> fromAddressGenerator;
@@ -85,7 +87,6 @@
   final AllProjectsName allProjectsName;
   final List<String> sshAddresses;
   final SitePaths site;
-
   final Provider<ChangeQueryBuilder> queryBuilder;
   final ChangeData.Factory changeDataFactory;
   final Provider<SoySauce> soySauce;
@@ -95,6 +96,8 @@
   final OutgoingEmailValidator validator;
   final boolean addInstanceNameInSubject;
   final Provider<String> instanceNameProvider;
+  final Provider<CurrentUser> currentUserProvider;
+  final RetryHelper retryHelper;
 
   @Inject
   EmailArguments(
@@ -103,7 +106,7 @@
       PermissionBackend permissionBackend,
       GroupBackend groupBackend,
       AccountCache accountCache,
-      PatchListCache patchListCache,
+      DiffOperations diffOperations,
       PatchSetUtil patchSetUtil,
       ApprovalsUtil approvalsUtil,
       Provider<FromAddressGenerator> fromAddressGenerator,
@@ -126,13 +129,15 @@
       Provider<InternalAccountQuery> accountQueryProvider,
       OutgoingEmailValidator validator,
       @GerritInstanceName Provider<String> instanceNameProvider,
-      @GerritServerConfig Config cfg) {
+      @GerritServerConfig Config cfg,
+      Provider<CurrentUser> currentUserProvider,
+      RetryHelper retryHelper) {
     this.server = server;
     this.projectCache = projectCache;
     this.permissionBackend = permissionBackend;
     this.groupBackend = groupBackend;
     this.accountCache = accountCache;
-    this.patchListCache = patchListCache;
+    this.diffOperations = diffOperations;
     this.patchSetUtil = patchSetUtil;
     this.approvalsUtil = approvalsUtil;
     this.fromAddressGenerator = fromAddressGenerator;
@@ -155,7 +160,8 @@
     this.accountQueryProvider = accountQueryProvider;
     this.validator = validator;
     this.instanceNameProvider = instanceNameProvider;
-
     this.addInstanceNameInSubject = cfg.getBoolean("sendemail", "addInstanceNameInSubject", false);
+    this.currentUserProvider = currentUserProvider;
+    this.retryHelper = retryHelper;
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
index 709bf61..2e0eeb3 100644
--- a/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
+++ b/java/com/google/gerrit/server/mail/send/InboundEmailRejectionSender.java
@@ -28,20 +28,21 @@
 public class InboundEmailRejectionSender extends OutgoingEmail {
 
   /** Used by the templating system to determine what error message should be sent */
-  public enum Error {
+  public enum InboundEmailError {
     PARSING_ERROR,
     INACTIVE_ACCOUNT,
     UNKNOWN_ACCOUNT,
     INTERNAL_EXCEPTION,
-    COMMENT_REJECTED
+    COMMENT_REJECTED,
+    CHANGE_NOT_FOUND
   }
 
   public interface Factory {
-    InboundEmailRejectionSender create(Address to, String threadId, Error reason);
+    InboundEmailRejectionSender create(Address to, String threadId, InboundEmailError reason);
   }
 
   private final Address to;
-  private final Error reason;
+  private final InboundEmailError reason;
   private final String threadId;
 
   @Inject
@@ -49,7 +50,7 @@
       EmailArguments args,
       @Assisted Address to,
       @Assisted String threadId,
-      @Assisted Error reason) {
+      @Assisted InboundEmailError reason) {
     super(args, "error");
     this.to = requireNonNull(to);
     this.threadId = requireNonNull(threadId);
diff --git a/java/com/google/gerrit/server/mail/send/MergedSender.java b/java/com/google/gerrit/server/mail/send/MergedSender.java
index ea76ab8..cec857d 100644
--- a/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -78,16 +78,15 @@
     try {
       Table<Account.Id, String, PatchSetApproval> pos = HashBasedTable.create();
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
-      for (PatchSetApproval ca :
-          args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id(), null, null)) {
-        LabelType lt = labelTypes.byLabel(ca.labelId());
-        if (lt == null) {
+      for (PatchSetApproval ca : args.approvalsUtil.byPatchSet(changeData.notes(), patchSet.id())) {
+        Optional<LabelType> lt = labelTypes.byLabel(ca.labelId());
+        if (!lt.isPresent()) {
           continue;
         }
         if (ca.value() > 0) {
-          pos.put(ca.accountId(), lt.getName(), ca);
+          pos.put(ca.accountId(), lt.get().getName(), ca);
         } else if (ca.value() < 0) {
-          neg.put(ca.accountId(), lt.getName(), ca);
+          neg.put(ca.accountId(), lt.get().getName(), ca);
         }
       }
 
@@ -142,6 +141,8 @@
     soyContextEmailData.put("approvals", getApprovals());
     if (stickyApprovalDiff.isPresent()) {
       soyContextEmailData.put("stickyApprovalDiff", stickyApprovalDiff.get());
+      soyContextEmailData.put(
+          "stickyApprovalDiffHtml", getDiffTemplateData(stickyApprovalDiff.get()));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
index aa683f6..b32c43a 100644
--- a/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
+++ b/java/com/google/gerrit/server/mail/send/MessageIdGenerator.java
@@ -58,8 +58,6 @@
   /**
    * Create a {@link MessageId} as a result of a change update.
    *
-   * @param repoView
-   * @param patchsetId
    * @return MessageId that depends on the patchset.
    */
   public MessageId fromChangeUpdate(RepoView repoView, PatchSet.Id patchsetId) {
@@ -89,8 +87,9 @@
   }
 
   /**
-   * @param accountId Create a {@link MessageId} as a result of an account update.
-   * @return MessageId that depends on the account id.
+   * Create a {@link MessageId} as a result of an account update
+   *
+   * @return {@link MessageId} that depends on the account id.
    */
   public MessageId fromAccountUpdate(Account.Id accountId) {
     String userRef = RefNames.refsUsers(accountId);
@@ -113,8 +112,6 @@
    * Create a {@link MessageId} from a reason, Account.Id, and timestamp.
    *
    * @param reason for performing this account update
-   * @param accountId
-   * @param timestamp
    * @return MessageId that depends on the reason, accountId, and timestamp.
    */
   public MessageId fromReasonAccountIdAndTimestamp(
diff --git a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
similarity index 85%
rename from java/com/google/gerrit/server/mail/send/AddReviewerSender.java
rename to java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
index 96d9483..b187f9c 100644
--- a/java/com/google/gerrit/server/mail/send/AddReviewerSender.java
+++ b/java/com/google/gerrit/server/mail/send/ModifyReviewerSender.java
@@ -21,13 +21,13 @@
 import com.google.inject.assistedinject.Assisted;
 
 /** Asks a user to review a change. */
-public class AddReviewerSender extends NewChangeSender {
+public class ModifyReviewerSender extends NewChangeSender {
   public interface Factory {
-    AddReviewerSender create(Project.NameKey project, Change.Id changeId);
+    ModifyReviewerSender create(Project.NameKey project, Change.Id changeId);
   }
 
   @Inject
-  public AddReviewerSender(
+  public ModifyReviewerSender(
       EmailArguments args, @Assisted Project.NameKey project, @Assisted Change.Id changeId) {
     super(args, newChangeData(args, project, changeId));
   }
@@ -37,5 +37,6 @@
     super.init();
 
     ccExistingReviewers();
+    removeUsersThatIgnoredTheChange();
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index ee9a328..001de52 100644
--- a/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -31,6 +31,8 @@
   private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
   private final Set<Address> extraCCByEmail = new HashSet<>();
+  private final Set<Account.Id> removedReviewers = new HashSet<>();
+  private final Set<Address> removedByEmailReviewers = new HashSet<>();
 
   protected NewChangeSender(EmailArguments args, ChangeData changeData) {
     super(args, "newchange", changeData);
@@ -52,10 +54,17 @@
     extraCCByEmail.addAll(cc);
   }
 
+  public void addRemovedReviewers(Collection<Account.Id> removed) {
+    removedReviewers.addAll(removed);
+  }
+
+  public void addRemovedByEmailReviewers(Collection<Address> removed) {
+    removedByEmailReviewers.addAll(removed);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
-
     String threadId = getChangeMessageThreadId();
     setHeader("References", threadId);
 
@@ -71,6 +80,8 @@
       case OWNER_REVIEWERS:
         reviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
         addByEmail(RecipientType.TO, reviewersByEmail, true);
+        removedReviewers.stream().forEach(r -> add(RecipientType.TO, r, true));
+        addByEmail(RecipientType.TO, removedByEmailReviewers, true);
         break;
     }
 
@@ -96,10 +107,25 @@
     return names;
   }
 
+  public List<String> getRemovedReviewerNames() {
+    if (removedReviewers.isEmpty() && removedByEmailReviewers.isEmpty()) {
+      return null;
+    }
+    List<String> names = new ArrayList<>();
+    for (Account.Id id : removedReviewers) {
+      names.add(getNameFor(id));
+    }
+    for (Address address : removedByEmailReviewers) {
+      names.add(address.name());
+    }
+    return names;
+  }
+
   @Override
   protected void setupSoyContext() {
     super.setupSoyContext();
     soyContext.put("ownerName", getNameFor(change.getOwner()));
     soyContextEmailData.put("reviewerNames", getReviewerNames());
+    soyContextEmailData.put("removedReviewerNames", getRemovedReviewerNames());
   }
 }
diff --git a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 746a07a..286f0c7 100644
--- a/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy.DISABLED;
 import static java.util.Objects.requireNonNull;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.Sets;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -25,14 +26,17 @@
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
 import com.google.gerrit.entities.EmailHeader.AddressList;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailFormat;
 import com.google.gerrit.mail.MailHeader;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.update.RetryableAction.ActionType;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.template.soy.jbcsrc.api.SoySauce;
@@ -92,12 +96,27 @@
     this.messageId = messageId;
   }
 
-  /**
-   * Format and enqueue the message for delivery.
-   *
-   * @throws EmailException
-   */
+  /** Format and enqueue the message for delivery. */
   public void send() throws EmailException {
+    try {
+      args.retryHelper
+          .action(
+              ActionType.SEND_EMAIL,
+              "sendEmail",
+              () -> {
+                sendImpl();
+                return null;
+              })
+          .retryWithTrace(Exception.class::isInstance)
+          .call();
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, EmailException.class);
+      throw new EmailException("sending email failed", e);
+    }
+  }
+
+  private void sendImpl() throws EmailException {
     if (!args.emailSender.isEnabled()) {
       // Server has explicitly disabled email sending.
       //
@@ -127,18 +146,40 @@
         Optional<AccountState> fromUser = args.accountCache.get(fromId);
         if (fromUser.isPresent()) {
           GeneralPreferencesInfo senderPrefs = fromUser.get().generalPreferences();
+          CurrentUser user = args.currentUserProvider.get();
+          boolean isImpersonating = user.isIdentifiedUser() && user.isImpersonating();
+          if (isImpersonating && user.getAccountId() != fromId) {
+            // This should not be possible, if this is the case it means the RequestContext is not
+            // set up correctly.
+            throw new EmailException(
+                String.format(
+                    "User %s is sending email from %s, while acting on behalf of %s",
+                    user.asIdentifiedUser().getRealUser().getAccountId(),
+                    fromId,
+                    user.getAccountId()));
+          }
           if (senderPrefs != null && senderPrefs.getEmailStrategy() == CC_ON_OWN_COMMENTS) {
-            // If we are impersonating a user, make sure they receive a CC of
-            // this message so they can always review and audit what we sent
-            // on their behalf to others.
+            // Include the sender in email if they enabled email notifications on their own
+            // comments.
             //
             logger.atFine().log(
                 "CC email sender %s because the email strategy of this user is %s",
                 fromUser.get().account().id(), CC_ON_OWN_COMMENTS);
             add(RecipientType.CC, fromId);
+          } else if (isImpersonating) {
+            // If we are impersonating a user, make sure they receive a CC of
+            // this message regardless of email strategy, unless email notifications are explicitly
+            // disabled for this user. This way they can always review and audit what we sent
+            // on their behalf to others.
+            logger.atFine().log(
+                "CC email sender %s because the email is sent on behalf of and email notifications"
+                    + " are enabled for this user.",
+                fromUser.get().account().id());
+            add(RecipientType.CC, fromId);
+
           } else if (!notify.accounts().containsValue(fromId) && rcptTo.remove(fromId)) {
             // If they don't want a copy, but we queued one up anyway,
-            // drop them from the recipient lists.
+            // drop them from the recipient lists, but only if the user is not being impersonated.
             //
             logger.atFine().log(
                 "Not CCing email sender %s because the email strategy of this user is not %s but"
@@ -261,7 +302,7 @@
     if (messageId != null) {
       String message = "<" + messageId.id() + suffix + "@" + getGerritHost() + ">";
       message = message.replaceAll("\\s", "");
-      va.headers.put(FieldName.MESSAGE_ID, new EmailHeader.String(message));
+      va.headers.put(FieldName.MESSAGE_ID, new StringEmailHeader(message));
     }
   }
 
@@ -343,7 +384,7 @@
 
   /** Set a header in the outgoing message. */
   protected void setHeader(String name, String value) {
-    headers.put(name, new EmailHeader.String(value));
+    headers.put(name, new StringEmailHeader(value));
   }
 
   /** Remove a header from the outgoing message. */
@@ -504,9 +545,9 @@
   }
 
   /**
+   * Returns whether this email is visible to the given account
+   *
    * @param to account.
-   * @throws PermissionBackendException
-   * @return whether this email is visible to the given account.
    */
   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     return true;
diff --git a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 1ad94be..d32e6fb 100644
--- a/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Version;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.exceptions.EmailException;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -59,7 +60,7 @@
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends AbstractModule {
+  public static class SmtpEmailSenderModule extends AbstractModule {
     @Override
     protected void configure() {
       bind(EmailSender.class).to(SmtpEmailSender.class);
@@ -386,7 +387,7 @@
 
   private static void setMissingHeader(Map<String, EmailHeader> hdrs, String name, String value) {
     if (!hdrs.containsKey(name) || hdrs.get(name).isEmpty()) {
-      hdrs.put(name, new EmailHeader.String(value));
+      hdrs.put(name, new StringEmailHeader(value));
     }
   }
 
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 74ecd68..158972f 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -132,7 +132,7 @@
     return changeId;
   }
 
-  /** @return revision of the metadata that was loaded. */
+  /** Returns revision of the metadata that was loaded. */
   public ObjectId getRevision() {
     return revision;
   }
@@ -215,12 +215,12 @@
   protected abstract void loadDefaults();
 
   /**
-   * @return the NameKey for the project where the notes should be stored, which is not necessarily
-   *     the same as the change's project.
+   * Returns the NameKey for the project where the notes should be stored, which is not necessarily
+   * the same as the change's project.
    */
   public abstract Project.NameKey getProjectName();
 
-  /** @return name of the reference storing this configuration. */
+  /** Returns name of the reference storing this configuration. */
   protected abstract String getRefName();
 
   /** Set up the metadata, parsing any state from the loaded revision. */
diff --git a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 8e6606e..6677490 100644
--- a/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -122,12 +122,11 @@
   }
 
   /**
-   * @return notes for the state of this change prior to this update. If this update is part of a
-   *     series managed by a {@link NoteDbUpdateManager}, then this reflects the state prior to the
-   *     first update in the series. A null return value can only happen when the change is being
-   *     rebuilt from NoteDb. A change that is in the process of being created will result in a
-   *     non-null return value from this method, but a null return value from {@link
-   *     ChangeNotes#getRevision()}.
+   * Returns notes for the state of this change prior to this update. If this update is part of a
+   * series managed by a {@link NoteDbUpdateManager}, then this reflects the state prior to the
+   * first update in the series. A null return value can only happen when the change is being
+   * rebuilt from NoteDb. A change that is in the process of being created will result in a non-null
+   * return value from this method, but a null return value from {@link ChangeNotes#getRevision()}.
    */
   @Nullable
   public ChangeNotes getNotes() {
@@ -173,8 +172,8 @@
   }
 
   /**
-   * @return the NameKey for the project where the update will be stored, which is not necessarily
-   *     the same as the change's project.
+   * Returns the NameKey for the project where the update will be stored, which is not necessarily
+   * the same as the change's project.
    */
   protected abstract Project.NameKey getProjectName();
 
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
index 483b2e9..4c41a12 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteJson.java
@@ -14,9 +14,17 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.EntitiesAdapterFactory;
+import com.google.gerrit.json.EnumTypeAdapterFactory;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
+import com.google.gson.TypeAdapter;
+import com.google.gson.stream.JsonReader;
+import com.google.gson.stream.JsonWriter;
 import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import java.io.IOException;
 import java.sql.Timestamp;
 
 @Singleton
@@ -26,6 +34,11 @@
   static Gson newGson() {
     return new GsonBuilder()
         .registerTypeAdapter(Timestamp.class, new CommentTimestampAdapter().nullSafe())
+        .registerTypeAdapterFactory(new EnumTypeAdapterFactory())
+        .registerTypeAdapterFactory(EntitiesAdapterFactory.create())
+        .registerTypeAdapter(
+            new TypeLiteral<ImmutableList<String>>() {}.getType(),
+            new ImmutableListAdapter().nullSafe())
         .setPrettyPrinting()
         .create();
   }
@@ -33,4 +46,27 @@
   public Gson getGson() {
     return gson;
   }
+
+  static class ImmutableListAdapter extends TypeAdapter<ImmutableList<String>> {
+
+    @Override
+    public void write(JsonWriter out, ImmutableList<String> value) throws IOException {
+      out.beginArray();
+      for (String v : value) {
+        out.value(v);
+      }
+      out.endArray();
+    }
+
+    @Override
+    public ImmutableList<String> read(JsonReader in) throws IOException {
+      ImmutableList.Builder<String> builder = ImmutableList.builder();
+      in.beginArray();
+      while (in.hasNext()) {
+        builder.add(in.nextString());
+      }
+      in.endArray();
+      return builder.build();
+    }
+  }
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 15f187a..28ab711 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -40,6 +40,7 @@
   static final FooterKey FOOTER_GROUPS = new FooterKey("Groups");
   static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
   static final FooterKey FOOTER_LABEL = new FooterKey("Label");
+  static final FooterKey FOOTER_COPIED_LABEL = new FooterKey("Copied-Label");
   static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
   static final FooterKey FOOTER_PATCH_SET_DESCRIPTION = new FooterKey("Patch-set-description");
   static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
@@ -54,6 +55,8 @@
   static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
   static final FooterKey FOOTER_CHERRY_PICK_OF = new FooterKey("Cherry-pick-of");
 
+  static final String GERRIT_USER_TEMPLATE = "Gerrit User %d";
+
   private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
 
   private final ChangeNoteJson changeNoteJson;
@@ -83,6 +86,11 @@
         .append('>');
   }
 
+  public static String formatAccountIdentString(Account.Id account, String accountIdAsEmail) {
+    return String.format(
+        "%s <%s>", ChangeNoteUtil.getAccountIdAsUsername(account), accountIdAsEmail);
+  }
+
   /**
    * Returns a {@link PersonIdent} that contains the account ID, but not the user's name or email
    * address.
@@ -97,10 +105,10 @@
 
   /** Returns the string {@code "Gerrit User " + accountId}, to pseudonymize user names. */
   public static String getAccountIdAsUsername(Account.Id accountId) {
-    return "Gerrit User " + accountId.toString();
+    return String.format(GERRIT_USER_TEMPLATE, accountId.get());
   }
 
-  private String getAccountIdAsEmailAddress(Account.Id accountId) {
+  public String getAccountIdAsEmailAddress(Account.Id accountId) {
     return accountId.get() + "@" + serverId;
   }
 
@@ -145,7 +153,14 @@
     }
 
     if (ptr <= changeMessageStart) {
-      return Optional.empty();
+      // Return with subject, ChangeMessage is empty
+      return Optional.of(
+          CommitMessageRange.builder()
+              .subjectStart(subjectStart)
+              .subjectEnd(subjectEnd)
+              .changeMessageStart(changeMessageStart)
+              .changeMessageEnd(changeMessageStart)
+              .build());
     }
 
     CommitMessageRange range =
@@ -170,6 +185,10 @@
 
     public abstract int changeMessageEnd();
 
+    public boolean hasChangeMessage() {
+      return changeMessageStart() < changeMessageEnd();
+    }
+
     public static Builder builder() {
       return new AutoValue_ChangeNoteUtil_CommitMessageRange.Builder();
     }
@@ -190,7 +209,7 @@
   }
 
   /** Helper class for JSON serialization. Timestamp is taken from the commit. */
-  private static class AttentionStatusInNoteDb {
+  public static class AttentionStatusInNoteDb {
 
     final String personIdent;
     final AttentionSetUpdate.Operation operation;
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotes.java b/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 619d499..3ecedfa 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -16,10 +16,10 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableListMultimap.toImmutableListMultimap;
 import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.RefNames.changeMetaRef;
 import static java.util.Comparator.comparing;
-import static java.util.Objects.requireNonNull;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
@@ -37,6 +37,7 @@
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
 import com.google.common.flogger.FluentLogger;
+import com.google.errorprone.annotations.FormatMethod;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AttentionSetUpdate;
@@ -51,6 +52,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
@@ -93,6 +95,7 @@
   public static final Ordering<ChangeMessage> MESSAGE_BY_TIME =
       Ordering.from(comparing(ChangeMessage::getWrittenOn));
 
+  @FormatMethod
   public static ConfigInvalidException parseException(
       Change.Id changeId, String fmt, Object... args) {
     return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
@@ -390,6 +393,7 @@
   // ChangeNotesCache from handlers.
   private ImmutableSortedMap<PatchSet.Id, PatchSet> patchSets;
   private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvalsWithCopied;
   private ImmutableSet<Comment.Key> commentKeys;
 
   public ChangeNotes(
@@ -427,28 +431,49 @@
     return patchSets;
   }
 
+  /**
+   * Gets the approvals, not including the copied approvals. To get copied approvals as well, use
+   * {@link #getApprovalsWithCopied}, or use {@code ApprovalInference}.
+   */
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
     if (approvals == null) {
-      approvals = ImmutableListMultimap.copyOf(state.approvals());
+      approvals =
+          state.approvals().stream()
+              .filter(e -> !e.getValue().copied())
+              .collect(toImmutableListMultimap(e -> e.getKey(), e -> e.getValue()));
     }
     return approvals;
   }
 
+  /**
+   * This method is currently used only in tests. TODO(paiking): Use this method to fetch approvals
+   * (including copied approvals) instead of computing copied approvals on demand. This will be used
+   * by {@code ApprovalCache}.
+   *
+   * @return all approvals, including copied approvals.
+   */
+  public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovalsWithCopied() {
+    if (approvalsWithCopied == null) {
+      approvalsWithCopied = ImmutableListMultimap.copyOf(state.approvals());
+    }
+    return approvalsWithCopied;
+  }
+
   public ReviewerSet getReviewers() {
     return state.reviewers();
   }
 
-  /** @return reviewers that do not currently have a Gerrit account and were added by email. */
+  /** Returns reviewers that do not currently have a Gerrit account and were added by email. */
   public ReviewerByEmailSet getReviewersByEmail() {
     return state.reviewersByEmail();
   }
 
-  /** @return reviewers that were modified during this change's current WIP phase. */
+  /** Returns reviewers that were modified during this change's current WIP phase. */
   public ReviewerSet getPendingReviewers() {
     return state.pendingReviewers();
   }
 
-  /** @return reviewers by email that were modified during this change's current WIP phase. */
+  /** Returns reviewers by email that were modified during this change's current WIP phase. */
   public ReviewerByEmailSet getPendingReviewersByEmail() {
     return state.pendingReviewersByEmail();
   }
@@ -468,8 +493,18 @@
   }
 
   /**
-   * @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.
+   * Returns the evaluated submit requirements for the change. We only intend to store submit
+   * requirements in NoteDb for closed changes, hence the result will be an empty list for active
+   * changes, or a list of submit requirements results otherwise. For closed changes, the results
+   * represent the state of evaluating submit requirements for this change when it was merged.
+   */
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    return state.submitRequirementsResult();
+  }
+
+  /**
+   * Returns 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.
    */
   public ImmutableSet<Account.Id> getPastAssignees() {
     return Lists.reverse(state.assigneeUpdates()).stream()
@@ -480,37 +515,37 @@
   }
 
   /**
-   * @return an ImmutableList of AssigneeStatusUpdate of all the updates to the assignee field to
-   *     this change. The order of the list is from most recent updates to least recent.
+   * Returns an ImmutableList of AssigneeStatusUpdate of all the updates to the assignee field to
+   * this change. The order of the list is from most recent updates to least recent.
    */
   public ImmutableList<AssigneeStatusUpdate> getAssigneeUpdates() {
     return state.assigneeUpdates();
   }
 
-  /** @return a ImmutableSet of all hashtags for this change sorted in alphabetical order. */
+  /** Returns an ImmutableSet of all hashtags for this change sorted in alphabetical order. */
   public ImmutableSet<String> getHashtags() {
     return ImmutableSortedSet.copyOf(state.hashtags());
   }
 
-  /** @return a list of all users who have ever been a reviewer on this change. */
+  /** Returns a list of all users who have ever been a reviewer on this change. */
   public ImmutableList<Account.Id> getAllPastReviewers() {
     return state.allPastReviewers();
   }
 
   /**
-   * @return submit records stored during the most recent submit; only for changes that were
-   *     actually submitted.
+   * Returns submit records stored during the most recent submit; only for changes that were
+   * actually submitted.
    */
   public ImmutableList<SubmitRecord> getSubmitRecords() {
     return state.submitRecords();
   }
 
-  /** @return all change messages, in chronological order, oldest first. */
+  /** Returns all change messages, in chronological order, oldest first. */
   public ImmutableList<ChangeMessage> getChangeMessages() {
     return state.changeMessages();
   }
 
-  /** @return inline comments on each revision. */
+  /** Returns inline comments on each revision. */
   public ImmutableListMultimap<ObjectId, HumanComment> getHumanComments() {
     return state.publishedComments();
   }
@@ -530,7 +565,7 @@
     return state.updateCount();
   }
 
-  /** @return {@link Optional} value of time when the change was merged. */
+  /** Returns {@link Optional} value of time when the change was merged. */
   public Optional<Timestamp> getMergedOn() {
     return Optional.ofNullable(state.mergedOn());
   }
@@ -608,8 +643,20 @@
 
   public PatchSet getCurrentPatchSet() {
     PatchSet.Id psId = change.currentPatchSetId();
-    return requireNonNull(
-        getPatchSets().get(psId), () -> String.format("missing current patch set %s", psId.get()));
+    if (psId == null || getPatchSets().get(psId) == null) {
+      // In some cases, the current patch-set doesn't exist yet as it's being created during the
+      // operation (e.g rebase).
+      PatchSet currentPatchset =
+          getPatchSets().values().stream()
+              .max((p1, p2) -> p1.id().get() - p2.id().get())
+              .orElseThrow(
+                  () ->
+                      new IllegalStateException(
+                          String.format(
+                              "change %s can't load any patchset", getChangeId().toString())));
+      return currentPatchset;
+    }
+    return getPatchSets().get(psId);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index 2eb69e6f..5cf3a64 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
@@ -55,6 +56,7 @@
 import com.google.common.collect.Tables;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.primitives.Ints;
+import com.google.errorprone.annotations.FormatMethod;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
@@ -68,6 +70,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.AssigneeStatusUpdate;
 import com.google.gerrit.server.ReviewerByEmailSet;
@@ -125,6 +128,7 @@
   private final List<AssigneeStatusUpdate> assigneeUpdates;
   private final List<SubmitRecord> submitRecords;
   private final ListMultimap<ObjectId, HumanComment> humanComments;
+  private final List<SubmitRequirementResult> submitRequirementResults;
   private final Map<PatchSet.Id, PatchSet.Builder> patchSets;
   private final Set<PatchSet.Id> deletedPatchSets;
   private final Map<PatchSet.Id, PatchSetState> patchSetStates;
@@ -187,6 +191,7 @@
     submitRecords = Lists.newArrayListWithExpectedSize(1);
     allChangeMessages = new ArrayList<>();
     humanComments = MultimapBuilder.hashKeys().arrayListValues().build();
+    submitRequirementResults = new ArrayList<>();
     patchSets = new HashMap<>();
     deletedPatchSets = new HashSet<>();
     patchSetStates = new HashMap<>();
@@ -259,6 +264,7 @@
         submitRecords,
         buildAllMessages(),
         humanComments,
+        submitRequirementResults,
         firstNonNull(isPrivate, false),
         firstNonNull(workInProgress, false),
         firstNonNull(hasReviewStarted, true),
@@ -409,6 +415,9 @@
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
       parseApproval(psId, accountId, realAccountId, commitTimestamp, line);
     }
+    for (String line : commit.getFooterLineValues(FOOTER_COPIED_LABEL)) {
+      parseCopiedApproval(psId, commitTimestamp, line);
+    }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
@@ -735,10 +744,14 @@
     }
 
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(psId.changeId(), commit.name()), accountId, ts, psId);
-    changeMessage.setMessage(changeMsgString.get());
-    changeMessage.setTag(tag);
-    changeMessage.setRealAuthor(realAccountId);
+        ChangeMessage.create(
+            ChangeMessage.key(psId.changeId(), commit.name()),
+            accountId,
+            ts,
+            psId,
+            changeMsgString.get(),
+            realAccountId,
+            tag);
     return allChangeMessages.add(changeMessage);
   }
 
@@ -749,11 +762,13 @@
     Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
     return range.map(
         commitMessageRange ->
-            RawParseUtils.decode(
-                enc,
-                raw,
-                commitMessageRange.changeMessageStart(),
-                commitMessageRange.changeMessageEnd() + 1));
+            commitMessageRange.hasChangeMessage()
+                ? RawParseUtils.decode(
+                    enc,
+                    raw,
+                    commitMessageRange.changeMessageStart(),
+                    commitMessageRange.changeMessageEnd() + 1)
+                : null);
   }
 
   private void parseNotes() throws IOException, ConfigInvalidException {
@@ -768,6 +783,9 @@
       for (HumanComment c : e.getValue().getEntities()) {
         humanComments.put(e.getKey(), c);
       }
+      for (SubmitRequirementResult sr : e.getValue().getSubmitRequirementsResult()) {
+        submitRequirementResults.add(sr);
+      }
     }
 
     for (PatchSet.Builder b : patchSets.values()) {
@@ -783,6 +801,69 @@
     }
   }
 
+  // Footer example: Copied-Label: <LABEL>=VOTE <Gerrit Account>,<Gerrit Real Account> :"<TAG>"
+  // ":<"TAG>"" is optional. <Gerrit Real Account> is also optional, if it was not set.
+  // The label, vote, and the Gerrit account are mandatory (unlike FOOTER_LABEL where Gerrit
+  // Account is also optional since by default it's the committer).
+  private void parseCopiedApproval(PatchSet.Id psId, Timestamp ts, String line)
+      throws ConfigInvalidException {
+    // Copied approvals can't be explicitly removed. They are removed the same way as non-copied
+    // approvals.
+    checkFooter(!line.startsWith("-"), FOOTER_COPIED_LABEL, line);
+
+    Account.Id accountId, realAccountId = null;
+    String labelVoteStr;
+    String tag = null;
+    int s = line.indexOf(' ');
+    int tagStart = line.indexOf(":\"");
+
+    // The first account is the accountId, and second (if applicable) is the realAccountId.
+    try {
+      labelVoteStr = line.substring(0, s);
+    } catch (StringIndexOutOfBoundsException ex) {
+      throw new ConfigInvalidException(ex.getMessage(), ex);
+    }
+    String[] identities =
+        line.substring(s + 1, tagStart == -1 ? line.length() : tagStart).split(",");
+    PersonIdent ident = RawParseUtils.parsePersonIdent(identities[0]);
+    checkFooter(ident != null, FOOTER_COPIED_LABEL, line);
+    accountId = parseIdent(ident);
+
+    if (identities.length > 1) {
+      PersonIdent realIdent = RawParseUtils.parsePersonIdent(identities[1]);
+      checkFooter(realIdent != null, FOOTER_COPIED_LABEL, line);
+      realAccountId = parseIdent(realIdent);
+    }
+
+    LabelVote l;
+    try {
+      l = LabelVote.parseWithEquals(labelVoteStr);
+    } catch (IllegalArgumentException e) {
+      ConfigInvalidException pe = parseException("invalid %s: %s", FOOTER_COPIED_LABEL, line);
+      pe.initCause(e);
+      throw pe;
+    }
+
+    if (tagStart != -1) {
+      // tagStart+2 skips ":\"" to parse the actual tag. Tags are in brackets.
+      // line.length()-1 skips the last ".
+      tag = line.substring(tagStart + 2, line.length() - 1);
+    }
+
+    PatchSetApproval.Builder psa =
+        PatchSetApproval.builder()
+            .key(PatchSetApproval.key(psId, accountId, LabelId.create(l.label())))
+            .value(l.value())
+            .granted(ts)
+            .tag(Optional.ofNullable(tag))
+            .copied(true);
+    if (realAccountId != null) {
+      psa.realAccountId(realAccountId);
+    }
+    approvals.putIfAbsent(psa.key(), psa);
+    bufferedApprovals.add(psa);
+  }
+
   private void parseApproval(
       PatchSet.Id psId, Account.Id accountId, Account.Id realAccountId, Timestamp ts, String line)
       throws ConfigInvalidException {
@@ -904,8 +985,9 @@
         }
       } else {
         checkFooter(rec != null, FOOTER_SUBMITTED_WITH, line);
-        if (line.startsWith("Rule-Name")) {
-          // This is just added for forward compatibility. Ignore this field.
+        if (line.startsWith("Rule-Name: ")) {
+          String ruleName = line.split(": ")[1];
+          rec.ruleName = ruleName;
           continue;
         }
         SubmitRecord.Label label = new SubmitRecord.Label();
@@ -1158,6 +1240,7 @@
     }
     if (!missing.isEmpty()) {
       throw parseException(
+          "%s",
           "Missing footers: " + missing.stream().map(FooterKey::getName).collect(joining(", ")));
     }
   }
@@ -1191,6 +1274,7 @@
     return pending != null && pending.commitId().isPresent();
   }
 
+  @FormatMethod
   private ConfigInvalidException parseException(String fmt, Object... args) {
     return ChangeNotes.parseException(id, fmt, args);
   }
diff --git a/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 33bc039..4d6b9cf 100644
--- a/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -44,6 +44,7 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -128,6 +129,7 @@
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> changeMessages,
       ListMultimap<ObjectId, HumanComment> publishedComments,
+      List<SubmitRequirementResult> submitRequirementResults,
       boolean isPrivate,
       boolean workInProgress,
       boolean reviewStarted,
@@ -181,6 +183,7 @@
         .submitRecords(submitRecords)
         .changeMessages(changeMessages)
         .publishedComments(publishedComments)
+        .submitRequirementsResult(submitRequirementResults)
         .updateCount(updateCount)
         .mergedOn(mergedOn)
         .build();
@@ -326,6 +329,8 @@
 
   abstract ImmutableListMultimap<ObjectId, HumanComment> publishedComments();
 
+  abstract ImmutableList<SubmitRequirementResult> submitRequirementsResult();
+
   abstract int updateCount();
 
   @Nullable
@@ -404,6 +409,7 @@
           .submitRecords(ImmutableList.of())
           .changeMessages(ImmutableList.of())
           .publishedComments(ImmutableListMultimap.of())
+          .submitRequirementsResult(ImmutableList.of())
           .updateCount(0);
     }
 
@@ -445,6 +451,9 @@
 
     abstract Builder publishedComments(ListMultimap<ObjectId, HumanComment> publishedComments);
 
+    abstract Builder submitRequirementsResult(
+        List<SubmitRequirementResult> submitRequirementsResult);
+
     abstract Builder updateCount(int updateCount);
 
     abstract Builder mergedOn(Timestamp mergedOn);
@@ -519,6 +528,12 @@
           .changeMessages()
           .forEach(m -> b.addChangeMessage(ChangeMessageProtoConverter.INSTANCE.toProto(m)));
       object.publishedComments().values().forEach(c -> b.addPublishedComment(GSON.toJson(c)));
+      object
+          .submitRequirementsResult()
+          .forEach(
+              sr ->
+                  b.addSubmitRequirementResult(
+                      SubmitRequirementProtoConverter.INSTANCE.toProto(sr)));
       b.setUpdateCount(object.updateCount());
       if (object.mergedOn() != null) {
         b.setMergedOnMillis(object.mergedOn().getTime());
@@ -658,6 +673,10 @@
                   proto.getPublishedCommentList().stream()
                       .map(r -> GSON.fromJson(r, HumanComment.class))
                       .collect(toImmutableListMultimap(HumanComment::getCommitId, c -> c)))
+              .submitRequirementsResult(
+                  proto.getSubmitRequirementResultList().stream()
+                      .map(sr -> SubmitRequirementProtoConverter.INSTANCE.fromProto(sr))
+                      .collect(toImmutableList()))
               .updateCount(proto.getUpdateCount())
               .mergedOn(proto.getHasMergedOn() ? new Timestamp(proto.getMergedOnMillis()) : null);
       return b.build();
diff --git a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
index bf2cf07..44475db 100644
--- a/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -16,7 +16,9 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayInputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -34,6 +36,8 @@
   private final HumanComment.Status status;
   private String pushCert;
 
+  private ImmutableList<SubmitRequirementResult> submitRequirementsResult;
+
   ChangeRevisionNote(
       ChangeNoteJson noteJson, ObjectReader reader, ObjectId noteId, HumanComment.Status status) {
     super(reader, noteId);
@@ -41,6 +45,11 @@
     this.status = status;
   }
 
+  public ImmutableList<SubmitRequirementResult> getSubmitRequirementsResult() {
+    checkParsed();
+    return submitRequirementsResult;
+  }
+
   public String getPushCert() {
     checkParsed();
     return pushCert;
@@ -52,20 +61,24 @@
     MutableInteger p = new MutableInteger();
     p.value = offset;
 
-    HumanCommentsRevisionNoteData data = parseJson(noteJson, raw, p.value);
+    ChangeRevisionNoteData data = parseJson(noteJson, raw, p.value);
     if (status == HumanComment.Status.PUBLISHED) {
       pushCert = data.pushCert;
     } else {
       pushCert = null;
     }
+    this.submitRequirementsResult =
+        data.submitRequirementResults == null
+            ? ImmutableList.of()
+            : ImmutableList.copyOf(data.submitRequirementResults);
     return data.comments;
   }
 
-  private HumanCommentsRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
+  private ChangeRevisionNoteData parseJson(ChangeNoteJson noteUtil, byte[] raw, int offset)
       throws IOException {
     try (InputStream is = new ByteArrayInputStream(raw, offset, raw.length - offset);
         Reader r = new InputStreamReader(is, UTF_8)) {
-      return noteUtil.getGson().fromJson(r, HumanCommentsRevisionNoteData.class);
+      return noteUtil.getGson().fromJson(r, ChangeRevisionNoteData.class);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
similarity index 78%
rename from java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
rename to java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
index e570412..8e33023 100644
--- a/java/com/google/gerrit/server/notedb/HumanCommentsRevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/ChangeRevisionNoteData.java
@@ -15,14 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.HumanComment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
- * <p>It is intended for deserialization from JSON only. It is used for human comments only.
+ * <p>It is intended for deserialization from JSON only. It is used for human comments. Submit
+ * requirements are also stored but only for closed changes.
  */
-class HumanCommentsRevisionNoteData {
+class ChangeRevisionNoteData {
   String pushCert;
   List<HumanComment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 708212d..5acea1b 100644
--- a/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHANGE_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CHERRY_PICK_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COMMIT;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_COPIED_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_CURRENT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_GROUPS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
@@ -52,18 +53,22 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Table;
+import com.google.common.collect.Table.Cell;
 import com.google.common.collect.TreeBasedTable;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmissionId;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.server.CurrentUser;
@@ -77,6 +82,7 @@
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Comparator;
 import java.util.Date;
 import java.util.HashMap;
@@ -114,7 +120,6 @@
   public interface Factory {
     ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
 
-    @VisibleForTesting
     ChangeUpdate create(
         ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
   }
@@ -126,9 +131,11 @@
   private final ServiceUserClassifier serviceUserClassifier;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
+  private final List<PatchSetApproval> copiedApprovals = new ArrayList<>();
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
   private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<HumanComment> comments = new ArrayList<>();
+  private final List<SubmitRequirementResult> submitRequirementResults = new ArrayList<>();
 
   private String commitSubject;
   private String subject;
@@ -270,6 +277,15 @@
     approvals.put(label, reviewer, Optional.empty());
   }
 
+  /**
+   * We expect the {@code copied} flag of {@code copiedPatchSetApproval} to be set, since this
+   * method is only meant for copied approvals.
+   */
+  public void putCopiedApproval(PatchSetApproval copiedPatchSetApproval) {
+    checkArgument(copiedPatchSetApproval.copied(), "Approval that should be copied is not copied.");
+    copiedApprovals.add(copiedPatchSetApproval);
+  }
+
   public void merge(SubmissionId submissionId, Iterable<SubmitRecord> submitRecords) {
     this.status = Change.Status.MERGED;
     this.submissionId = submissionId.toString();
@@ -302,6 +318,10 @@
     this.psDescription = psDescription;
   }
 
+  public void putSubmitRequirementResults(Collection<SubmitRequirementResult> rs) {
+    submitRequirementResults.addAll(rs);
+  }
+
   public void putComment(HumanComment.Status status, HumanComment c) {
     verifyComment(c);
     createDraftUpdateIfNull();
@@ -485,10 +505,10 @@
     this.cherryPickOf = Optional.empty();
   }
 
-  /** @return the tree id for the updated tree */
+  /** Returns the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, IOException {
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return null;
     }
     RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
@@ -498,6 +518,9 @@
       c.tag = tag;
       cache.get(c.getCommitId()).putComment(c);
     }
+    for (SubmitRequirementResult sr : submitRequirementResults) {
+      cache.get(sr.patchSetCommitId()).putSubmitRequirementResult(sr);
+    }
     if (pushCert != null) {
       checkState(commit != null);
       cache.get(ObjectId.fromString(commit)).setPushCertificate(pushCert);
@@ -695,18 +718,10 @@
     }
 
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
-      addFooter(msg, FOOTER_LABEL);
-      // Label names/values are safe to append without sanitizing.
-      if (!c.getValue().isPresent()) {
-        msg.append('-').append(c.getRowKey());
-      } else {
-        msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
-      }
-      Account.Id id = c.getColumnKey();
-      if (!id.equals(getAccountId())) {
-        noteUtil.appendAccountIdIdentString(msg.append(' '), id);
-      }
-      msg.append('\n');
+      addLabelFooter(msg, c);
+    }
+    for (PatchSetApproval patchSetApproval : copiedApprovals) {
+      addCopiedLabelFooter(msg, patchSetApproval);
     }
 
     if (submissionId != null) {
@@ -720,7 +735,10 @@
           msg.append(' ').append(sanitizeFooter(rec.errorMessage));
         }
         msg.append('\n');
-
+        if (rec.ruleName != null) {
+          addFooter(msg, FOOTER_SUBMITTED_WITH).append("Rule-Name: ").append(rec.ruleName);
+          msg.append('\n');
+        }
         if (rec.labels != null) {
           for (SubmitRecord.Label label : rec.labels) {
             // Label names/values are safe to append without sanitizing.
@@ -735,7 +753,6 @@
             msg.append('\n');
           }
         }
-        // TODO(maximeg) We might want to list plugins that validated this submission.
       }
     }
 
@@ -770,9 +787,7 @@
       }
     }
 
-    if (plannedAttentionSetUpdates != null) {
-      updateAttentionSet(msg);
-    }
+    updateAttentionSet(msg);
 
     CommitBuilder cb = new CommitBuilder();
     cb.setMessage(msg.toString());
@@ -787,6 +802,47 @@
     return cb;
   }
 
+  private void addLabelFooter(StringBuilder msg, Cell<String, Account.Id, Optional<Short>> c) {
+    addFooter(msg, FOOTER_LABEL);
+    // Label names/values are safe to append without sanitizing.
+    if (!c.getValue().isPresent()) {
+      msg.append('-').append(c.getRowKey());
+    } else {
+      msg.append(LabelVote.create(c.getRowKey(), c.getValue().get()).formatWithEquals());
+    }
+    Account.Id id = c.getColumnKey();
+    if (!id.equals(getAccountId())) {
+      noteUtil.appendAccountIdIdentString(msg.append(' '), id);
+    }
+    msg.append('\n');
+  }
+
+  private void addCopiedLabelFooter(StringBuilder msg, PatchSetApproval patchSetApproval) {
+    if (patchSetApproval.value() == 0) {
+      // Can only happen if we removed a vote. There is no need to persist removed votes.
+      return;
+    }
+    addFooter(msg, FOOTER_COPIED_LABEL);
+    // Label names/values are safe to append without sanitizing.
+    msg.append(
+        LabelVote.create(patchSetApproval.label(), patchSetApproval.value()).formatWithEquals());
+    Account.Id id = patchSetApproval.accountId();
+    noteUtil.appendAccountIdIdentString(msg.append(' '), id);
+
+    // In the non-copied labels, we don't need to pass the real account id since it's already
+    // in FOOTER_REAL_USER. Here, we want to retain the original real account id.
+    if (patchSetApproval.realAccountId() != null) {
+      noteUtil.appendAccountIdIdentString(msg.append(","), patchSetApproval.realAccountId());
+    }
+
+    // In the non-copied labels, we don't need to pass the tag since it's already in
+    // FOOTER_TAG, but in this chase we want to retain the original tag, and not the current tag.
+    if (patchSetApproval.tag().isPresent()) {
+      msg.append(":\"" + sanitizeFooter(patchSetApproval.tag().get()) + "\"");
+    }
+    msg.append('\n');
+  }
+
   private void clearAttentionSet(String reason) {
     if (getNotes().getAttentionSet() == null) {
       return;
@@ -854,7 +910,7 @@
    */
   private void updateAttentionSet(StringBuilder msg) {
     if (plannedAttentionSetUpdates == null) {
-      return;
+      plannedAttentionSetUpdates = new HashMap<>();
     }
     Set<Account.Id> currentUsersInAttentionSet =
         AttentionSetUtil.additionsOnly(getNotes().getAttentionSet()).stream()
@@ -876,6 +932,8 @@
             .map(r -> r.getKey())
             .collect(ImmutableSet.toImmutableSet()));
 
+    removeInactiveUsersFromAttentionSet(currentReviewers);
+
     for (AttentionSetUpdate attentionSetUpdate : plannedAttentionSetUpdates.values()) {
       if (attentionSetUpdate.operation() == AttentionSetUpdate.Operation.ADD
           && currentUsersInAttentionSet.contains(attentionSetUpdate.account())) {
@@ -913,6 +971,38 @@
     }
   }
 
+  private void removeInactiveUsersFromAttentionSet(Set<Account.Id> currentReviewers) {
+    Set<Account.Id> inActiveUsersInTheAttentionSet =
+        // get the current attention set.
+        getNotes().getAttentionSet().stream()
+            .filter(a -> a.operation().equals(Operation.ADD))
+            .map(a -> a.account())
+            // remove users that are currently being removed from the attention set.
+            .filter(
+                a ->
+                    plannedAttentionSetUpdates.getOrDefault(a, /*defaultValue= */ null) == null
+                        || plannedAttentionSetUpdates.get(a).operation().equals(Operation.REMOVE))
+            // remove users that are still active on the change.
+            .filter(a -> !isActiveOnChange(currentReviewers, a))
+            .collect(ImmutableSet.toImmutableSet());
+
+    // We override the flag, as we never want such users in the attention set.
+    ignoreFurtherAttentionSetUpdates = false;
+
+    addToPlannedAttentionSetUpdates(
+        inActiveUsersInTheAttentionSet.stream()
+            .map(
+                a ->
+                    AttentionSetUpdate.createForWrite(
+                        a,
+                        Operation.REMOVE,
+                        /* reason= */ "Only change owner, uploader, reviewers, and cc can "
+                            + "be in the attention set"))
+            .collect(ImmutableSet.toImmutableSet()));
+
+    ignoreFurtherAttentionSetUpdates = true;
+  }
+
   /**
    * 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.
@@ -948,6 +1038,7 @@
   public boolean isEmpty() {
     return commitSubject == null
         && approvals.isEmpty()
+        && copiedApprovals.isEmpty()
         && changeMessage == null
         && comments.isEmpty()
         && reviewers.isEmpty()
diff --git a/java/com/google/gerrit/server/notedb/CommitRewriter.java b/java/com/google/gerrit/server/notedb/CommitRewriter.java
new file mode 100644
index 0000000..338b984
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/CommitRewriter.java
@@ -0,0 +1,1361 @@
+// 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.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ASSIGNEE;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_ATTENTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
+import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_PATTERN;
+import static com.google.gerrit.server.util.AccountTemplateUtil.ACCOUNT_TEMPLATE_REGEX;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+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.entities.Change;
+import com.google.gerrit.entities.HumanComment;
+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.git.RefUpdateUtil;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
+import com.google.gerrit.server.notedb.ChangeNoteUtil.CommitMessageRange;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import org.apache.commons.lang.StringUtils;
+import org.eclipse.jgit.diff.DiffAlgorithm;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.diff.EditList;
+import org.eclipse.jgit.diff.HistogramDiff;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.diff.RawTextComparator;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.internal.storage.file.PackInserter;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.RawParseUtils;
+
+/**
+ * Rewrites ('backfills') commit history of change in NoteDb to not contain user data. Only fixes
+ * known cases, rewriting commits case by case.
+ *
+ * <p>The cases where we used to put user data in NoteDb can be found by
+ * https://gerrit-review.googlesource.com/q/hashtag:user-data-cleanup
+ *
+ * <p>As opposed to {@link NoteDbRewriter} implementations, which target a specific change and are
+ * used by REST endpoints, this rewriter is used as standalone tool, that bulk backfills changes by
+ * project.
+ */
+@UsedAt(UsedAt.Project.GOOGLE)
+@Singleton
+public class CommitRewriter {
+  /** Options to run {@link #backfillProject}. */
+  public static class RunOptions implements Serializable {
+    /** Whether to rewrite the commit history or only find refs that need to be fixed. */
+    public boolean dryRun = true;
+    /**
+     * Whether to verify that resulting commits contain user data for the accounts that are linked
+     * to a change, see {@link #verifyCommit}, {@link #collectAccounts}.
+     */
+    public boolean verifyCommits = true;
+    /** Whether to compute and output the diff of the commit history for the backfilled refs. */
+    public boolean outputDiff = true;
+
+    /** Max number of refs to update in a single {@link BatchRefUpdate}. */
+    public int maxRefsInBatch = 10000;
+    /**
+     * Max number of refs to fix by a single {@link RefsUpdate#backfillProject} run. Since second
+     * run on the same set of refs is a no-op, running with this option in a loop will eventually
+     * fix all refs. Number of executed {@link BatchRefUpdate} depends on {@link #maxRefsInBatch}
+     * option.
+     */
+    public int maxRefsToUpdate = 50000;
+  }
+
+  /** Result of the backfill run for a project. */
+  public static class BackfillResult {
+
+    /** If the run for the project was successful. */
+    public boolean ok;
+
+    /**
+     * Refs that were fixed by the run/ would be fixed if in --dry-run, together with their commit
+     * history diff. Diff is empty if --output-diff is false.
+     */
+    public Map<String, List<CommitDiff>> fixedRefDiff = new HashMap<>();
+
+    /**
+     * Refs that still contain user data after the backfill run. Only filled if --verify-commits,
+     * see {@link #verifyCommit}
+     */
+    public List<String> refsStillInvalidAfterFix = new ArrayList<>();
+
+    /** Refs, failed to backfill by the run. */
+    public List<String> refsFailedToFix = new ArrayList<>();
+  }
+
+  /** Diff result of a single commit rewrite */
+  @AutoValue
+  public abstract static class CommitDiff {
+    public static CommitDiff create(ObjectId oldSha1, String commitDiff) {
+      return new AutoValue_CommitRewriter_CommitDiff(oldSha1, commitDiff);
+    }
+
+    /** SHA1 of the overwritten commit */
+    public abstract ObjectId oldSha1();
+
+    /** Diff applied to the commit with {@link #oldSha1} */
+    public abstract String diff();
+  }
+
+  public static final String DEFAULT_ACCOUNT_REPLACEMENT = "Gerrit Account";
+
+  private static final Pattern NON_REPLACE_ACCOUNT_PATTERN =
+      Pattern.compile(DEFAULT_ACCOUNT_REPLACEMENT + "|" + ACCOUNT_TEMPLATE_REGEX);
+
+  private static final Pattern OK_ACCOUNT_NAME_PATTERN =
+      Pattern.compile("(?i:someone|someone else|anonymous)|" + ACCOUNT_TEMPLATE_REGEX);
+
+  /** Patterns to match change messages that need to be fixed. */
+  private static final Pattern ASSIGNEE_DELETED_PATTERN = Pattern.compile("Assignee deleted: (.*)");
+
+  private static final Pattern ASSIGNEE_ADDED_PATTERN = Pattern.compile("Assignee added: (.*)");
+  private static final Pattern ASSIGNEE_CHANGED_PATTERN =
+      Pattern.compile("Assignee changed from: (.*) to: (.*)");
+
+  private static final Pattern REMOVED_REVIEWER_PATTERN =
+      Pattern.compile(
+          "Removed (cc|reviewer) (.*)(\\.| with the following votes:\n.*)", Pattern.DOTALL);
+
+  private static final Pattern REMOVED_VOTE_PATTERN = Pattern.compile("Removed (.*) by (.*)");
+
+  private static final String REMOVED_VOTES_CHANGE_MESSAGE_START = "Removed the following votes:";
+  private static final Pattern REMOVED_VOTES_CHANGE_MESSAGE_PATTERN =
+      Pattern.compile("\\* (.*) by (.*)");
+
+  private static final Pattern REMOVED_CHANGE_MESSAGE_PATTERN =
+      Pattern.compile("Change message removed by: (.*)(\nReason: .*)?");
+
+  private static final Pattern SUBMITTED_PATTERN =
+      Pattern.compile("Change has been successfully (.*) by (.*)");
+
+  private static final Pattern ON_CODE_OWNER_ADD_REVIEWER_PATTERN =
+      Pattern.compile("(.*) who was added as reviewer owns the following files");
+
+  private static final String CODE_OWNER_ADD_REVIEWER_TAG =
+      ChangeMessagesUtil.AUTOGENERATED_BY_GERRIT_TAG_PREFIX + "code-owners:addReviewer";
+
+  private static final String ON_CODE_OWNER_APPROVAL_REGEX = "code-owner approved by (.*):";
+  private static final String ON_CODE_OWNER_OVERRIDE_REGEX =
+      "code-owners submit requirement .* overridden by (.*)";
+
+  private static final Pattern ON_CODE_OWNER_REVIEW_PATTERN =
+      Pattern.compile(ON_CODE_OWNER_APPROVAL_REGEX + "|" + ON_CODE_OWNER_OVERRIDE_REGEX);
+  private static final Pattern ON_CODE_OWNER_POST_REVIEW_PATTERN =
+      Pattern.compile("Patch Set [0-9]+:[\\s\\S]*By (voting|removing)[\\s\\S]*");
+
+  private static final Pattern REPLY_BY_REASON_PATTERN =
+      Pattern.compile("(.*) replied on the change");
+  private static final Pattern ADDED_BY_REASON_PATTERN =
+      Pattern.compile("Added by (.*) using the hovercard menu");
+  private static final Pattern REMOVED_BY_REASON_PATTERN =
+      Pattern.compile("Removed by (.*) using the hovercard menu");
+  private static final Pattern REMOVED_BY_ICON_CLICK_REASON_PATTERN =
+      Pattern.compile("Removed by (.*) by clicking the attention icon");
+
+  /** Matches {@link Account#getNameEmail} */
+  private static final Pattern NAME_EMAIL_PATTERN = Pattern.compile("(.*) (\\<.*\\>|\\(.*\\))");
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final AccountCache accountCache;
+  private final DiffAlgorithm diffAlgorithm = new HistogramDiff();
+  private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
+
+  @Inject
+  CommitRewriter(ChangeNotes.Factory changeNotesFactory, AccountCache accountCache) {
+    this.changeNotesFactory = changeNotesFactory;
+    this.accountCache = accountCache;
+  }
+
+  /**
+   * Rewrites commit history of {@link RefNames#changeMetaRef}s in single {@code repo}. Only
+   * rewrites branch if necessary, i.e. if there were any commits that contained user data.
+   *
+   * <p>See {@link RunOptions} for the execution and output options.
+   *
+   * @param project project to backfill
+   * @param repo repo to backfill
+   * @param options {@link RunOptions} to control how the run is executed.
+   * @return BackfillResult
+   */
+  public BackfillResult backfillProject(
+      Project.NameKey project, Repository repo, RunOptions options) {
+
+    checkState(
+        options.maxRefsInBatch > 0 && options.maxRefsToUpdate > 0,
+        "Expected maxRefsInBatch>0 && <= maxRefsToUpdate>0");
+    checkState(
+        options.maxRefsInBatch <= options.maxRefsToUpdate,
+        "Expected maxRefsInBatch(%s) <= maxRefsToUpdate(%s)",
+        options.maxRefsInBatch,
+        options.maxRefsToUpdate);
+    BackfillResult result = new BackfillResult();
+    result.ok = true;
+    int refsInUpdate = 0;
+    RefsUpdate refsUpdate = null;
+    try {
+      for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+        if (result.fixedRefDiff.size() >= options.maxRefsToUpdate) {
+          return result;
+        }
+        Change.Id changeId = Change.Id.fromRef(ref.getName());
+        if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
+          continue;
+        }
+        try {
+          ImmutableSet<AccountState> accountsInChange = ImmutableSet.of();
+          if (options.verifyCommits) {
+            try {
+              ChangeNotes changeNotes = changeNotesFactory.create(project, changeId);
+              accountsInChange = collectAccounts(changeNotes);
+            } catch (Exception e) {
+              logger.atWarning().withCause(e).log("Failed to run verification on ref %s", ref);
+            }
+          }
+          if (refsUpdate == null) {
+            refsUpdate = RefsUpdate.create(repo);
+          }
+          ChangeFixProgress changeFixProgress =
+              backfillChange(refsUpdate, ref, accountsInChange, options);
+          if (changeFixProgress.anyFixesApplied) {
+            refsInUpdate++;
+            refsUpdate
+                .batchRefUpdate()
+                .addCommand(
+                    new ReceiveCommand(
+                        ref.getObjectId(), changeFixProgress.newTipId, ref.getName()));
+            result.fixedRefDiff.put(ref.getName(), changeFixProgress.commitDiffs);
+          }
+          if (refsInUpdate >= options.maxRefsInBatch
+              || result.fixedRefDiff.size() >= options.maxRefsToUpdate) {
+            processUpdate(options, refsUpdate);
+            refsUpdate = null;
+            refsInUpdate = 0;
+          }
+          if (!changeFixProgress.isValidAfterFix) {
+            result.refsStillInvalidAfterFix.add(ref.getName());
+          }
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log("Failed to fix ref %s", ref);
+          result.refsFailedToFix.add(ref.getName());
+        }
+      }
+      processUpdate(options, refsUpdate);
+    } catch (IOException e) {
+      logger.atWarning().log("Failed to fix project %s. Reason: %s", project.get(), e.getMessage());
+      result.ok = false;
+    } finally {
+      if (refsUpdate != null) {
+        refsUpdate.close();
+      }
+    }
+
+    return result;
+  }
+
+  /** Executes a single {@link RefsUpdate#batchRefUpdate}. */
+  private void processUpdate(RunOptions options, @Nullable RefsUpdate refsUpdate)
+      throws IOException {
+    if (refsUpdate == null) {
+      return;
+    }
+    if (!refsUpdate.batchRefUpdate().getCommands().isEmpty()) {
+      if (!options.dryRun) {
+        refsUpdate.inserter().flush();
+        RefUpdateUtil.executeChecked(refsUpdate.batchRefUpdate(), refsUpdate.revWalk());
+      }
+    }
+    refsUpdate.close();
+  }
+
+  /**
+   * Retrieves accounts, that are associated with a change (e.g. reviewers, commenters, etc.). These
+   * accounts are used to verify that commits do not contain user data. See {@link #verifyCommit}
+   *
+   * @param changeNotes {@link ChangeNotes} of the change to retrieve associated accounts from.
+   * @return {@link AccountState} of accounts, that are associated with the change.
+   */
+  private ImmutableSet<AccountState> collectAccounts(ChangeNotes changeNotes) {
+    Set<Account.Id> accounts = new HashSet<>();
+    accounts.add(changeNotes.getChange().getOwner());
+    for (PatchSetApproval patchSetApproval : changeNotes.getApprovals().values()) {
+      if (patchSetApproval.accountId() != null) {
+        accounts.add(patchSetApproval.accountId());
+      }
+      if (patchSetApproval.realAccountId() != null) {
+        accounts.add(patchSetApproval.realAccountId());
+      }
+    }
+    accounts.addAll(changeNotes.getAllPastReviewers());
+    accounts.addAll(changeNotes.getPastAssignees());
+    changeNotes
+        .getAttentionSetUpdates()
+        .forEach(attentionSetUpdate -> accounts.add(attentionSetUpdate.account()));
+    for (SubmitRecord submitRecord : changeNotes.getSubmitRecords()) {
+      if (submitRecord.labels != null) {
+        accounts.addAll(
+            submitRecord.labels.stream()
+                .map(label -> label.appliedBy)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+      }
+    }
+    for (HumanComment comment : changeNotes.getHumanComments().values()) {
+      if (comment.author != null) {
+        accounts.add(comment.author.getId());
+      }
+      if (comment.getRealAuthor() != null) {
+        accounts.add(comment.getRealAuthor().getId());
+      }
+    }
+    return ImmutableSet.copyOf(accountCache.get(accounts).values());
+  }
+
+  /** Verifies that the commit does not contain user data of accounts in {@code accounts}. */
+  private boolean verifyCommit(
+      String commitMessage, PersonIdent author, Collection<AccountState> accounts) {
+    for (AccountState accountState : accounts) {
+      Account account = accountState.account();
+      if (commitMessage.contains(account.getName())) {
+        return false;
+      }
+      if (account.fullName() != null && commitMessage.contains(account.fullName())) {
+        return false;
+      }
+      if (account.displayName() != null && commitMessage.contains(account.displayName())) {
+        return false;
+      }
+      if (account.preferredEmail() != null && commitMessage.contains(account.preferredEmail())) {
+        return false;
+      }
+      if (accountState.userName().isPresent()
+          && commitMessage.contains(accountState.userName().get())) {
+        return false;
+      }
+      Stream<String> allEmails =
+          accountState.externalIds().stream().map(ExternalId::email).filter(Objects::nonNull);
+      if (allEmails.anyMatch(email -> commitMessage.contains(email))) {
+        return false;
+      }
+      if (author.toString().contains(account.getName())) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  /**
+   * Walks the ref history from oldest update to the most recent update, fixing the commits that
+   * contain user data case by case. Commit history is rewritten from the first commit, that needs
+   * to be updated, for all subsequent updates. The new ref tip is returned in {@link
+   * ChangeFixProgress#newTipId}.
+   */
+  public ChangeFixProgress backfillChange(
+      RefsUpdate refsUpdate,
+      Ref ref,
+      ImmutableSet<AccountState> accountsInChange,
+      RunOptions options)
+      throws IOException, ConfigInvalidException {
+
+    ObjectId oldTip = ref.getObjectId();
+    // Walk from the first commit of the branch.
+    refsUpdate.revWalk().reset();
+    refsUpdate.revWalk().markStart(refsUpdate.revWalk().parseCommit(oldTip));
+    refsUpdate.revWalk().sort(RevSort.TOPO);
+
+    refsUpdate.revWalk().sort(RevSort.REVERSE);
+
+    RevCommit originalCommit;
+
+    boolean rewriteStarted = false;
+    ChangeFixProgress changeFixProgress = new ChangeFixProgress(ref.getName());
+    while ((originalCommit = refsUpdate.revWalk().next()) != null) {
+
+      changeFixProgress.updateAuthorId =
+          parseIdent(changeFixProgress, originalCommit.getAuthorIdent());
+      PersonIdent fixedAuthorIdent;
+      if (changeFixProgress.updateAuthorId.isPresent()) {
+        fixedAuthorIdent =
+            getFixedIdent(originalCommit.getAuthorIdent(), changeFixProgress.updateAuthorId.get());
+      } else {
+        // Field to parse id from ident. Update by gerrit server or an old/broken change.
+        // Leave as it is.
+        fixedAuthorIdent = originalCommit.getAuthorIdent();
+      }
+      Optional<String> fixedCommitMessage = fixedCommitMessage(originalCommit, changeFixProgress);
+      String commitMessage =
+          fixedCommitMessage.isPresent()
+              ? fixedCommitMessage.get()
+              : originalCommit.getFullMessage();
+      if (options.verifyCommits) {
+        boolean isCommitValid = verifyCommit(commitMessage, fixedAuthorIdent, accountsInChange);
+        changeFixProgress.isValidAfterFix &= isCommitValid;
+        if (!isCommitValid) {
+          StringBuilder detailedVerificationStatus =
+              new StringBuilder(
+                  String.format(
+                      "Commit %s of ref %s failed verification after fix",
+                      originalCommit.getId(), ref));
+          detailedVerificationStatus.append("\nCommit body:\n");
+          detailedVerificationStatus.append(commitMessage);
+          if (fixedCommitMessage.isPresent()) {
+            detailedVerificationStatus.append("\n was fixed.\n");
+          }
+          detailedVerificationStatus.append("Commit author:\n");
+          detailedVerificationStatus.append(fixedAuthorIdent.toString());
+          logger.atWarning().log(detailedVerificationStatus.toString());
+        }
+      }
+      boolean needsFix =
+          !fixedAuthorIdent.equals(originalCommit.getAuthorIdent())
+              || fixedCommitMessage.isPresent();
+
+      if (!rewriteStarted && !needsFix) {
+        changeFixProgress.newTipId = originalCommit;
+        continue;
+      }
+      rewriteStarted = true;
+      changeFixProgress.anyFixesApplied = true;
+      CommitBuilder cb = new CommitBuilder();
+      if (changeFixProgress.newTipId != null) {
+        cb.setParentId(changeFixProgress.newTipId);
+      }
+      cb.setTreeId(originalCommit.getTree());
+      cb.setMessage(commitMessage);
+      cb.setAuthor(fixedAuthorIdent);
+      cb.setCommitter(originalCommit.getCommitterIdent());
+      cb.setEncoding(originalCommit.getEncoding());
+      byte[] newCommitContent = cb.build();
+      checkCommitModification(originalCommit, newCommitContent);
+      changeFixProgress.newTipId =
+          refsUpdate.inserter().insert(Constants.OBJ_COMMIT, newCommitContent);
+      // Only compute diff if the content of the commit was actually changed.
+      if (options.outputDiff && needsFix) {
+        String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
+        checkState(
+            !Strings.isNullOrEmpty(diff),
+            "Expected diff for commit %s of ref %s",
+            originalCommit.getId(),
+            ref.getName());
+        changeFixProgress.commitDiffs.add(CommitDiff.create(originalCommit.getId(), diff));
+      } else if (needsFix) {
+        // Always output old commits SHA1
+        changeFixProgress.commitDiffs.add(CommitDiff.create(originalCommit.getId(), ""));
+      }
+    }
+    return changeFixProgress;
+  }
+
+  /**
+   * In NoteDb, all the meta information is stored in footer lines. If we accidentally drop some of
+   * the footer lines, the original meta information will be lost, and the change might become
+   * unparsable.
+   *
+   * <p>While we can not verify the entire commit content, we at least make sure that the resulting
+   * commit has the same author, committer and footer lines are in the same order and contain same
+   * footer keys as the original commit.
+   *
+   * <p>Commit message and footer values might have been rewritten.
+   */
+  private void checkCommitModification(RevCommit originalCommit, byte[] newCommitContent)
+      throws IOException {
+    RevCommit newCommit = RevCommit.parse(newCommitContent);
+    PersonIdent newAuthorIdent = newCommit.getAuthorIdent();
+    PersonIdent originalAuthorIdent = originalCommit.getAuthorIdent();
+    // The new commit must have same author and committer ident as the original commit.
+    if (!verifyPersonIdent(newAuthorIdent, originalAuthorIdent)) {
+      throw new IllegalStateException(
+          String.format(
+              "New author %s does not match original author %s",
+              newAuthorIdent.toExternalString(), originalAuthorIdent.toExternalString()));
+    }
+    PersonIdent newCommitterIdent = newCommit.getCommitterIdent();
+    PersonIdent originalCommitterIdent = originalCommit.getCommitterIdent();
+    if (!verifyPersonIdent(newCommitterIdent, originalCommitterIdent)) {
+      throw new IllegalStateException(
+          String.format(
+              "New committer %s does not match original committer %s",
+              newCommitterIdent.toExternalString(), originalCommitterIdent.toExternalString()));
+    }
+
+    List<FooterLine> newFooterLines = newCommit.getFooterLines();
+    List<FooterLine> originalFooterLines = originalCommit.getFooterLines();
+    // Number and order of footer lines must remain the same, the value may have changed.
+    if (newFooterLines.size() != originalFooterLines.size()) {
+      String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
+      throw new IllegalStateException(
+          String.format(
+              "Expected footer lines in new commit to match original footer lines. Diff %s", diff));
+    }
+    for (int i = 0; i < newFooterLines.size(); i++) {
+      FooterLine newFooterLine = newFooterLines.get(i);
+      FooterLine originalFooterLine = originalFooterLines.get(i);
+      if (!newFooterLine.getKey().equals(originalFooterLine.getKey())) {
+        String diff = computeDiff(originalCommit.getRawBuffer(), newCommitContent);
+        throw new IllegalStateException(
+            String.format(
+                "Expected footer lines in new commit to match original footer lines. Diff %s",
+                diff));
+      }
+    }
+  }
+
+  private boolean verifyPersonIdent(PersonIdent newIdent, PersonIdent originalIdent) {
+    return newIdent.getTimeZoneOffset() == originalIdent.getTimeZoneOffset()
+        && newIdent.getWhen().getTime() == originalIdent.getWhen().getTime()
+        && newIdent.getEmailAddress().equals(originalIdent.getEmailAddress());
+  }
+
+  private Optional<String> fixAssigneeChangeMessage(
+      ChangeFixProgress changeFixProgress,
+      Optional<Account.Id> oldAssignee,
+      Optional<Account.Id> newAssignee,
+      String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+
+    Matcher assigneeDeletedMatcher = ASSIGNEE_DELETED_PATTERN.matcher(originalChangeMessage);
+    if (assigneeDeletedMatcher.matches()) {
+      if (!NON_REPLACE_ACCOUNT_PATTERN.matcher(assigneeDeletedMatcher.group(1)).matches()) {
+        Optional<String> assigneeReplacement =
+            getPossibleAccountReplacement(
+                changeFixProgress,
+                oldAssignee,
+                getAccountInfoFromNameEmail(assigneeDeletedMatcher.group(1)));
+
+        return Optional.of(
+            assigneeReplacement.isPresent()
+                ? "Assignee deleted: " + assigneeReplacement.get()
+                : "Assignee was deleted.");
+      }
+      return Optional.empty();
+    }
+
+    Matcher assigneeAddedMatcher = ASSIGNEE_ADDED_PATTERN.matcher(originalChangeMessage);
+    if (assigneeAddedMatcher.matches()) {
+      if (!NON_REPLACE_ACCOUNT_PATTERN.matcher(assigneeAddedMatcher.group(1)).matches()) {
+        Optional<String> assigneeReplacement =
+            getPossibleAccountReplacement(
+                changeFixProgress,
+                newAssignee,
+                getAccountInfoFromNameEmail(assigneeAddedMatcher.group(1)));
+        return Optional.of(
+            assigneeReplacement.isPresent()
+                ? "Assignee added: " + assigneeReplacement.get()
+                : "Assignee was added.");
+      }
+      return Optional.empty();
+    }
+
+    Matcher assigneeChangedMatcher = ASSIGNEE_CHANGED_PATTERN.matcher(originalChangeMessage);
+    if (assigneeChangedMatcher.matches()) {
+      if (!NON_REPLACE_ACCOUNT_PATTERN.matcher(assigneeChangedMatcher.group(1)).matches()) {
+        Optional<String> oldAssigneeReplacement =
+            getPossibleAccountReplacement(
+                changeFixProgress,
+                oldAssignee,
+                getAccountInfoFromNameEmail(assigneeChangedMatcher.group(1)));
+        Optional<String> newAssigneeReplacement =
+            getPossibleAccountReplacement(
+                changeFixProgress,
+                newAssignee,
+                getAccountInfoFromNameEmail(assigneeChangedMatcher.group(2)));
+        return Optional.of(
+            oldAssigneeReplacement.isPresent() && newAssigneeReplacement.isPresent()
+                ? String.format(
+                    "Assignee changed from: %s to: %s",
+                    oldAssigneeReplacement.get(), newAssigneeReplacement.get())
+                : "Assignee was changed.");
+      }
+      return Optional.empty();
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixReviewerChangeMessage(String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+    Matcher matcher = REMOVED_REVIEWER_PATTERN.matcher(originalChangeMessage);
+
+    if (matcher.matches() && !ACCOUNT_TEMPLATE_PATTERN.matcher(matcher.group(2)).matches()) {
+      // Since we do not use change messages for reviewer updates on UI, it does not matter what we
+      // rewrite it to.
+      return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixRemoveVoteChangeMessage(
+      ChangeFixProgress changeFixProgress,
+      Optional<Account.Id> reviewer,
+      String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+
+    Matcher matcher = REMOVED_VOTE_PATTERN.matcher(originalChangeMessage);
+    if (matcher.matches() && !NON_REPLACE_ACCOUNT_PATTERN.matcher(matcher.group(2)).matches()) {
+      Optional<String> reviewerReplacement =
+          getPossibleAccountReplacement(
+              changeFixProgress, reviewer, getAccountInfoFromNameEmail(matcher.group(2)));
+      StringBuilder replacement = new StringBuilder();
+      replacement.append("Removed ").append(matcher.group(1));
+      if (reviewerReplacement.isPresent()) {
+        replacement.append(" by ").append(reviewerReplacement.get());
+      }
+      return Optional.of(replacement.toString());
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixRemoveVotesChangeMessage(
+      ChangeFixProgress changeFixProgress, String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)
+        || !originalChangeMessage.startsWith(REMOVED_VOTES_CHANGE_MESSAGE_START)) {
+      return Optional.empty();
+    }
+    String[] lines = originalChangeMessage.split("\\r?\\n");
+    StringBuilder fixedLines = new StringBuilder();
+    boolean anyFixed = false;
+    for (int i = 1; i < lines.length; i++) {
+      if (lines[i].isEmpty()) {
+        continue;
+      }
+      Matcher matcher = REMOVED_VOTES_CHANGE_MESSAGE_PATTERN.matcher(lines[i]);
+      String replacementLine = lines[i];
+      if (matcher.matches() && !NON_REPLACE_ACCOUNT_PATTERN.matcher(matcher.group(2)).matches()) {
+        anyFixed = true;
+        Optional<String> reviewerReplacement =
+            getPossibleAccountReplacement(
+                changeFixProgress, Optional.empty(), getAccountInfoFromNameEmail(matcher.group(2)));
+        replacementLine = "* " + matcher.group(1);
+        if (reviewerReplacement.isPresent()) {
+          replacementLine += " by " + reviewerReplacement.get();
+        }
+        replacementLine += "\n";
+      }
+      fixedLines.append(replacementLine);
+    }
+    if (!anyFixed) {
+      return Optional.empty();
+    }
+    return Optional.of(REMOVED_VOTES_CHANGE_MESSAGE_START + "\n" + fixedLines);
+  }
+
+  private Optional<String> fixDeleteChangeMessageCommitMessage(String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+
+    Matcher matcher = REMOVED_CHANGE_MESSAGE_PATTERN.matcher(originalChangeMessage);
+    if (matcher.matches() && !ACCOUNT_TEMPLATE_PATTERN.matcher(matcher.group(1)).matches()) {
+      String fixedMessage = "Change message removed";
+      if (matcher.group(2) != null) {
+        fixedMessage += matcher.group(2);
+      }
+      return Optional.of(fixedMessage);
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> fixSubmitChangeMessage(String originalChangeMessage) {
+    if (Strings.isNullOrEmpty(originalChangeMessage)) {
+      return Optional.empty();
+    }
+
+    Matcher matcher = SUBMITTED_PATTERN.matcher(originalChangeMessage);
+    if (matcher.matches()) {
+      // See https://gerrit-review.googlesource.com/c/gerrit/+/272654
+      return Optional.of(originalChangeMessage.substring(0, matcher.end(1)));
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Rewrites a code owners change message.
+   *
+   * <p>See https://gerrit-review.googlesource.com/c/plugins/code-owners/+/305409
+   */
+  private Optional<String> fixCodeOwnersOnAddReviewerChangeMessage(
+      ChangeFixProgress changeFixProgress, String originalMessage) {
+    if (Strings.isNullOrEmpty(originalMessage)) {
+      return Optional.empty();
+    }
+
+    Matcher onAddReviewerMatcher = ON_CODE_OWNER_ADD_REVIEWER_PATTERN.matcher(originalMessage);
+    if (!onAddReviewerMatcher.find()
+        || NON_REPLACE_ACCOUNT_PATTERN
+            .matcher(normalizeOnCodeOwnerAddReviewerMatch(onAddReviewerMatcher.group(1)))
+            .matches()) {
+      return Optional.empty();
+    }
+
+    // Pre fix, try to replace with something meaningful.
+    // Retrieve reviewer accounts from cache and try to match by their name.
+    onAddReviewerMatcher.reset();
+    StringBuffer sb = new StringBuffer();
+    while (onAddReviewerMatcher.find()) {
+      String reviewerName = normalizeOnCodeOwnerAddReviewerMatch(onAddReviewerMatcher.group(1));
+      Optional<String> replacementName =
+          getPossibleAccountReplacement(
+              changeFixProgress, Optional.empty(), ParsedAccountInfo.create(reviewerName));
+      onAddReviewerMatcher.appendReplacement(
+          sb,
+          replacementName.isPresent()
+              ? replacementName.get() + ", who was added as reviewer owns the following files"
+              : "Added reviewer owns the following files");
+    }
+    onAddReviewerMatcher.appendTail(sb);
+    sb.append("\n");
+    return Optional.of(sb.toString());
+  }
+
+  /**
+   * See {@link #ON_CODE_OWNER_ADD_REVIEWER_PATTERN}.
+   *
+   * <p>Some of the messages have format '{@link AccountTemplateUtil#ACCOUNT_TEMPLATE}, who...',
+   * while others '{@link AccountTemplateUtil#ACCOUNT_TEMPLATE} who...'.
+   *
+   * <p>Cut the trailing ',' from the match, so that valid patterns are not replaced.
+   */
+  private static String normalizeOnCodeOwnerAddReviewerMatch(String reviewerMatch) {
+    String reviewerName = reviewerMatch;
+    if (reviewerName.charAt(reviewerName.length() - 1) == ',') {
+      reviewerName = reviewerName.substring(0, reviewerName.length() - 1);
+    }
+    return reviewerName;
+  }
+
+  private Optional<String> fixCodeOwnersOnReviewChangeMessage(
+      Optional<Account.Id> reviewer, String originalMessage) {
+    if (Strings.isNullOrEmpty(originalMessage)) {
+      return Optional.empty();
+    }
+    Matcher onCodeOwnerPostReviewMatcher =
+        ON_CODE_OWNER_POST_REVIEW_PATTERN.matcher(originalMessage);
+    if (!onCodeOwnerPostReviewMatcher.matches()) {
+      return Optional.empty();
+    }
+    Matcher onCodeOwnerReviewMatcher = ON_CODE_OWNER_REVIEW_PATTERN.matcher(originalMessage);
+    while (onCodeOwnerReviewMatcher.find()) {
+      String accountName =
+          firstNonNull(onCodeOwnerReviewMatcher.group(1), onCodeOwnerReviewMatcher.group(2));
+      if (!ACCOUNT_TEMPLATE_PATTERN.matcher(accountName).matches()) {
+        return Optional.of(
+            originalMessage.replace(
+                    "by " + accountName,
+                    "by "
+                        + reviewer
+                            .map(AccountTemplateUtil::getAccountTemplate)
+                            .orElse(DEFAULT_ACCOUNT_REPLACEMENT))
+                + "\n");
+      }
+    }
+
+    return Optional.empty();
+  }
+
+  private Optional<String> fixAttentionSetReason(String originalReason) {
+    if (Strings.isNullOrEmpty(originalReason)) {
+      return Optional.empty();
+    }
+    // Only the latest attention set updates are displayed on UI. As long as reason is
+    // human-readable, it does not matter what we rewrite it to.
+
+    Matcher replyByReasonMatcher = REPLY_BY_REASON_PATTERN.matcher(originalReason);
+    if (replyByReasonMatcher.matches()
+        && !OK_ACCOUNT_NAME_PATTERN.matcher(replyByReasonMatcher.group(1)).matches()) {
+      return Optional.of("Someone replied on the change");
+    }
+
+    Matcher addedByReasonMatcher = ADDED_BY_REASON_PATTERN.matcher(originalReason);
+    if (addedByReasonMatcher.matches()
+        && !OK_ACCOUNT_NAME_PATTERN.matcher(addedByReasonMatcher.group(1)).matches()) {
+      return Optional.of("Added by someone using the hovercard menu");
+    }
+
+    Matcher removedByReasonMatcher = REMOVED_BY_REASON_PATTERN.matcher(originalReason);
+    if (removedByReasonMatcher.matches()
+        && !OK_ACCOUNT_NAME_PATTERN.matcher(removedByReasonMatcher.group(1)).matches()) {
+
+      return Optional.of("Removed by someone using the hovercard menu");
+    }
+
+    Matcher removedByIconClickReasonMatcher =
+        REMOVED_BY_ICON_CLICK_REASON_PATTERN.matcher(originalReason);
+    if (removedByIconClickReasonMatcher.matches()
+        && !OK_ACCOUNT_NAME_PATTERN.matcher(removedByIconClickReasonMatcher.group(1)).matches()) {
+
+      return Optional.of("Removed by someone by clicking the attention icon");
+    }
+    return Optional.empty();
+  }
+
+  /**
+   * Fixes commit body case by case, so it does not contain user data. Returns fixed commit message,
+   * or {@link Optional#empty} if no fixes were applied.
+   */
+  private Optional<String> fixedCommitMessage(RevCommit revCommit, ChangeFixProgress fixProgress)
+      throws ConfigInvalidException {
+    byte[] raw = revCommit.getRawBuffer();
+    Charset enc = RawParseUtils.parseEncoding(raw);
+    Optional<CommitMessageRange> commitMessageRange =
+        ChangeNoteUtil.parseCommitMessageRange(revCommit);
+    if (!commitMessageRange.isPresent()) {
+      throw new ConfigInvalidException("Failed to parse commit message " + revCommit.getName());
+    }
+    String changeSubject =
+        RawParseUtils.decode(
+            enc,
+            raw,
+            commitMessageRange.get().subjectStart(),
+            commitMessageRange.get().subjectEnd());
+    Optional<String> fixedChangeMessage = Optional.empty();
+    String originalChangeMessage = null;
+    if (commitMessageRange.isPresent() && commitMessageRange.get().hasChangeMessage()) {
+      originalChangeMessage =
+          RawParseUtils.decode(
+                  enc,
+                  raw,
+                  commitMessageRange.get().changeMessageStart(),
+                  commitMessageRange.get().changeMessageEnd() + 1)
+              .trim();
+    }
+    List<FooterLine> footerLines = revCommit.getFooterLines();
+    StringBuilder footerLinesBuilder = new StringBuilder();
+    boolean anyFootersFixed = false;
+    for (FooterLine fl : footerLines) {
+      String footerKey = fl.getKey();
+      String footerValue = fl.getValue();
+      if (footerKey.equalsIgnoreCase(FOOTER_TAG.getName())) {
+        fixProgress.tag = footerValue;
+      } else if (footerKey.equalsIgnoreCase(FOOTER_ASSIGNEE.getName())) {
+        Account.Id oldAssignee = fixProgress.assigneeId;
+        FixIdentResult fixedAssignee = null;
+        if (footerValue.equals("")) {
+          fixProgress.assigneeId = null;
+        } else {
+          fixedAssignee = getFixedIdentString(fixProgress, footerValue);
+          fixProgress.assigneeId = fixedAssignee.accountId;
+        }
+        if (!fixedChangeMessage.isPresent()) {
+          fixedChangeMessage =
+              fixAssigneeChangeMessage(
+                  fixProgress,
+                  Optional.ofNullable(oldAssignee),
+                  Optional.ofNullable(fixProgress.assigneeId),
+                  originalChangeMessage);
+        }
+        if (fixedAssignee != null && fixedAssignee.fixedIdentString.isPresent()) {
+          addFooter(footerLinesBuilder, footerKey, fixedAssignee.fixedIdentString.get());
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (Arrays.stream(ReviewerStateInternal.values())
+          .anyMatch(state -> footerKey.equalsIgnoreCase(state.getFooterKey().getName()))) {
+        if (!fixedChangeMessage.isPresent()) {
+          fixedChangeMessage = fixReviewerChangeMessage(originalChangeMessage);
+        }
+        FixIdentResult fixedReviewer = getFixedIdentString(fixProgress, footerValue);
+        if (fixedReviewer.fixedIdentString.isPresent()) {
+          addFooter(footerLinesBuilder, footerKey, fixedReviewer.fixedIdentString.get());
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_REAL_USER.getName())) {
+        FixIdentResult fixedRealUser = getFixedIdentString(fixProgress, footerValue);
+        if (fixedRealUser.fixedIdentString.isPresent()) {
+          addFooter(footerLinesBuilder, footerKey, fixedRealUser.fixedIdentString.get());
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_LABEL.getName())) {
+        int voterIdentStart = footerValue.indexOf(' ');
+        FixIdentResult fixedVoter = null;
+        if (voterIdentStart > 0) {
+          String originalIdentString = footerValue.substring(voterIdentStart + 1);
+          fixedVoter = getFixedIdentString(fixProgress, originalIdentString);
+        }
+        if (!fixedChangeMessage.isPresent()) {
+          fixedChangeMessage =
+              fixRemoveVoteChangeMessage(
+                  fixProgress,
+                  fixedVoter == null
+                      ? fixProgress.updateAuthorId
+                      : Optional.of(fixedVoter.accountId),
+                  originalChangeMessage);
+        }
+        if (fixedVoter != null && fixedVoter.fixedIdentString.isPresent()) {
+          String fixedLabelVote =
+              footerValue.substring(0, voterIdentStart) + " " + fixedVoter.fixedIdentString.get();
+          addFooter(footerLinesBuilder, footerKey, fixedLabelVote);
+          anyFootersFixed = true;
+          continue;
+        }
+      } else if (footerKey.equalsIgnoreCase(FOOTER_SUBMITTED_WITH.getName())) {
+        // Record format:
+        // Submitted-with: OK
+        // Submitted-with: OK: Code-Review: User Name <accountId@serverId>
+        int voterIdentStart = StringUtils.ordinalIndexOf(footerValue, ": ", 2);
+        if (voterIdentStart >= 0) {
+          String originalIdentString = footerValue.substring(voterIdentStart + 2);
+          FixIdentResult fixedVoter = getFixedIdentString(fixProgress, originalIdentString);
+          if (fixedVoter.fixedIdentString.isPresent()) {
+            String fixedLabelVote =
+                footerValue.substring(0, voterIdentStart)
+                    + ": "
+                    + fixedVoter.fixedIdentString.get();
+            addFooter(footerLinesBuilder, footerKey, fixedLabelVote);
+            anyFootersFixed = true;
+            continue;
+          }
+        }
+
+      } else if (footerKey.equalsIgnoreCase(FOOTER_ATTENTION.getName())) {
+        AttentionStatusInNoteDb originalAttentionSetUpdate =
+            gson.fromJson(footerValue, AttentionStatusInNoteDb.class);
+        FixIdentResult fixedAttentionAccount =
+            getFixedIdentString(fixProgress, originalAttentionSetUpdate.personIdent);
+        Optional<String> fixedReason = fixAttentionSetReason(originalAttentionSetUpdate.reason);
+        if (fixedAttentionAccount.fixedIdentString.isPresent() || fixedReason.isPresent()) {
+          AttentionStatusInNoteDb fixedAttentionSetUpdate =
+              new AttentionStatusInNoteDb(
+                  fixedAttentionAccount.fixedIdentString.isPresent()
+                      ? fixedAttentionAccount.fixedIdentString.get()
+                      : originalAttentionSetUpdate.personIdent,
+                  originalAttentionSetUpdate.operation,
+                  fixedReason.isPresent() ? fixedReason.get() : originalAttentionSetUpdate.reason);
+          addFooter(footerLinesBuilder, footerKey, gson.toJson(fixedAttentionSetUpdate));
+          anyFootersFixed = true;
+          continue;
+        }
+      }
+      addFooter(footerLinesBuilder, footerKey, footerValue);
+    }
+    // Some of the old commits are missing corresponding footers but still have change messages that
+    // need the fix. For such cases, try to guess or replace with the default string (see
+    // getPossibleAccountReplacement)
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage = fixReviewerChangeMessage(originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage = fixRemoveVotesChangeMessage(fixProgress, originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage =
+          fixRemoveVoteChangeMessage(fixProgress, Optional.empty(), originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage =
+          fixAssigneeChangeMessage(
+              fixProgress, Optional.empty(), Optional.empty(), originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage = fixSubmitChangeMessage(originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage = fixDeleteChangeMessageCommitMessage(originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()) {
+      fixedChangeMessage =
+          fixCodeOwnersOnReviewChangeMessage(fixProgress.updateAuthorId, originalChangeMessage);
+    }
+    if (!fixedChangeMessage.isPresent()
+        && Objects.equals(fixProgress.tag, CODE_OWNER_ADD_REVIEWER_TAG)) {
+      fixedChangeMessage =
+          fixCodeOwnersOnAddReviewerChangeMessage(fixProgress, originalChangeMessage);
+    }
+    if (!anyFootersFixed && !fixedChangeMessage.isPresent()) {
+      return Optional.empty();
+    }
+    StringBuilder fixedCommitBuilder = new StringBuilder();
+    fixedCommitBuilder.append(changeSubject);
+    fixedCommitBuilder.append("\n\n");
+    if (commitMessageRange.get().hasChangeMessage()) {
+      fixedCommitBuilder.append(fixedChangeMessage.orElse(originalChangeMessage));
+      fixedCommitBuilder.append("\n\n");
+    }
+    fixedCommitBuilder.append(footerLinesBuilder);
+    return Optional.of(fixedCommitBuilder.toString());
+  }
+
+  private static StringBuilder addFooter(StringBuilder sb, String footer, String value) {
+    if (value == null) {
+      return sb;
+    }
+    sb.append(footer).append(":");
+    sb.append(" ").append(value);
+    sb.append('\n');
+    return sb;
+  }
+
+  private Optional<Account.Id> parseIdent(ChangeFixProgress changeFixProgress, PersonIdent ident) {
+    Optional<Account.Id> account = NoteDbUtil.parseIdent(ident);
+    if (account.isPresent()) {
+      changeFixProgress.parsedAccounts.putIfAbsent(account.get(), Optional.empty());
+    } else {
+      logger.atWarning().log(
+          "Fixing ref %s, failed to parse id %s", changeFixProgress.changeMetaRef, ident);
+    }
+    return account;
+  }
+
+  /**
+   * Fixes {@code originalIdent} so it does not contain user data, see {@link
+   * ChangeNoteUtil#getAccountIdAsUsername}.
+   */
+  private PersonIdent getFixedIdent(PersonIdent originalIdent, Account.Id identAccount) {
+    return new PersonIdent(
+        ChangeNoteUtil.getAccountIdAsUsername(identAccount),
+        originalIdent.getEmailAddress(),
+        originalIdent.getWhen(),
+        originalIdent.getTimeZone());
+  }
+
+  /**
+   * Parses {@code originalIdentString} and applies the fix, so it does not contain user data, see
+   * {@link ChangeNoteUtil#appendAccountIdIdentString}.
+   *
+   * @param changeFixProgress see {@link ChangeFixProgress}
+   * @param originalIdentString ident to apply the fix to.
+   * @return {@link FixIdentResult}, with {@link FixIdentResult#accountId} parsed from {@code
+   *     originalIdentString} and {@link FixIdentResult#fixedIdentString} if the fix was applied.
+   * @throws ConfigInvalidException if could not parse {@link FixIdentResult#accountId} from {@code
+   *     originalIdentString}
+   */
+  private FixIdentResult getFixedIdentString(
+      ChangeFixProgress changeFixProgress, String originalIdentString)
+      throws ConfigInvalidException {
+    FixIdentResult fixIdentResult = new FixIdentResult();
+    PersonIdent originalIdent = RawParseUtils.parsePersonIdent(originalIdentString);
+    // Ident as String is saved in NoteDB footers, if this fails to parse, something is
+    // wrong with the change and we better not touch it.
+    fixIdentResult.accountId =
+        parseIdent(changeFixProgress, originalIdent)
+            .orElseThrow(
+                () -> new ConfigInvalidException("field to parse id: " + originalIdentString));
+    String fixedIdentString =
+        ChangeNoteUtil.formatAccountIdentString(
+            fixIdentResult.accountId, originalIdent.getEmailAddress());
+    fixIdentResult.fixedIdentString =
+        fixedIdentString.equals(originalIdentString)
+            ? Optional.empty()
+            : Optional.of(fixedIdentString);
+    return fixIdentResult;
+  }
+
+  /** Extracts {@link ParsedAccountInfo} from {@link Account#getNameEmail} */
+  private ParsedAccountInfo getAccountInfoFromNameEmail(String nameEmail) {
+    Matcher nameEmailMatcher = NAME_EMAIL_PATTERN.matcher(nameEmail);
+    if (!nameEmailMatcher.matches()) {
+      return ParsedAccountInfo.create(nameEmail);
+    }
+
+    return ParsedAccountInfo.create(
+        nameEmailMatcher.group(1),
+        nameEmailMatcher.group(2).substring(1, nameEmailMatcher.group(2).length() - 1));
+  }
+
+  /**
+   * Returns replacement for {@code accountName}.
+   *
+   * <p>If {@code account} is known, replace with {@link AccountTemplateUtil#getAccountTemplate}.
+   * Otherwise, try to guess the correct replacement account for {@code accountName} among {@link
+   * ChangeFixProgress#parsedAccounts} that appeared in the change. If this fails {@link
+   * Optional#empty} is returned.
+   *
+   * @param changeFixProgress see {@link ChangeFixProgress}
+   * @param account account that should be used for replacement, if known
+   * @param accountInfo {@link ParsedAccountInfo} to replace.
+   * @return replacement for {@code accountName} or {@link Optional#empty}, if the replacement could
+   *     not be determined.
+   */
+  private Optional<String> getPossibleAccountReplacement(
+      ChangeFixProgress changeFixProgress,
+      Optional<Account.Id> account,
+      ParsedAccountInfo accountInfo) {
+    if (account.isPresent()) {
+      return Optional.of(AccountTemplateUtil.getAccountTemplate(account.get()));
+    }
+    // Retrieve reviewer accounts from cache and try to match by their name.
+    Map<Account.Id, AccountState> missingAccountStateReviewers =
+        accountCache.get(
+            changeFixProgress.parsedAccounts.entrySet().stream()
+                .filter(entry -> !entry.getValue().isPresent())
+                .map(Map.Entry::getKey)
+                .collect(ImmutableSet.toImmutableSet()));
+    changeFixProgress.parsedAccounts.putAll(
+        missingAccountStateReviewers.entrySet().stream()
+            .collect(
+                ImmutableMap.toImmutableMap(
+                    Map.Entry::getKey, e -> Optional.ofNullable(e.getValue()))));
+    Map<Account.Id, AccountState> possibleReplacements = ImmutableMap.of();
+    if (accountInfo.email().isPresent()) {
+      possibleReplacements =
+          changeFixProgress.parsedAccounts.entrySet().stream()
+              .filter(
+                  e ->
+                      e.getValue().isPresent()
+                          && Objects.equals(
+                              e.getValue().get().account().preferredEmail(),
+                              accountInfo.email().get()))
+              .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, e -> e.getValue().get()));
+      // Filter further so we match both email & name
+      if (possibleReplacements.size() > 1) {
+        logger.atWarning().log(
+            "Fixing ref %s, multiple accounts found with the same email address, while replacing %s",
+            changeFixProgress.changeMetaRef, accountInfo);
+        possibleReplacements =
+            possibleReplacements.entrySet().stream()
+                .filter(e -> Objects.equals(e.getValue().account().getName(), accountInfo.name()))
+                .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, Map.Entry::getValue));
+      }
+    }
+    if (possibleReplacements.isEmpty()) {
+      possibleReplacements =
+          changeFixProgress.parsedAccounts.entrySet().stream()
+              .filter(
+                  e ->
+                      e.getValue().isPresent()
+                          && Objects.equals(
+                              e.getValue().get().account().getName(), accountInfo.name()))
+              .collect(ImmutableMap.toImmutableMap(Map.Entry::getKey, e -> e.getValue().get()));
+    }
+    Optional<String> replacementName = Optional.empty();
+    if (possibleReplacements.isEmpty()) {
+      logger.atWarning().log(
+          "Fixing ref %s, could not find reviewer account matching name %s",
+          changeFixProgress.changeMetaRef, accountInfo);
+    } else if (possibleReplacements.size() > 1) {
+      logger.atWarning().log(
+          "Fixing ref %s found multiple reviewer account matching name %s",
+          changeFixProgress.changeMetaRef, accountInfo);
+    } else {
+      replacementName =
+          Optional.of(
+              AccountTemplateUtil.getAccountTemplate(
+                  Iterables.getOnlyElement(possibleReplacements.keySet())));
+    }
+    return replacementName;
+  }
+
+  /**
+   * Cuts tree and parent lines from raw unparsed commit body, so they are not included in diff
+   * comparison.
+   *
+   * @param b raw unparsed commit body, see {@link RevCommit#getRawBuffer()}.
+   *     <p>For parsing, see {@link RawParseUtils#author}, {@link RawParseUtils#commitMessage}, etc.
+   * @return raw unparsed commit body, without tree and parent lines.
+   */
+  public static byte[] cutTreeAndParents(byte[] b) {
+    final int sz = b.length;
+    int ptr = 46; // skip the "tree ..." line.
+    while (ptr < sz && b[ptr] == 'p') {
+      ptr += 48;
+    } // skip this parent.
+    return Arrays.copyOfRange(b, ptr, b.length + 1);
+  }
+
+  private String computeDiff(byte[] oldCommit, byte[] newCommit) throws IOException {
+    RawText oldBody = new RawText(cutTreeAndParents(oldCommit));
+    RawText newBody = new RawText(cutTreeAndParents(newCommit));
+    ByteArrayOutputStream out = new ByteArrayOutputStream();
+    EditList diff = diffAlgorithm.diff(RawTextComparator.DEFAULT, oldBody, newBody);
+    try (DiffFormatter fmt = new DiffFormatter(out)) {
+      // Do not show any unchanged lines, since it is not interesting
+      fmt.setContext(0);
+      fmt.format(diff, oldBody, newBody);
+      fmt.flush();
+      return out.toString();
+    }
+  }
+
+  private static ObjectInserter newPackInserter(Repository repo) {
+    if (!(repo instanceof FileRepository)) {
+      return repo.newObjectInserter();
+    }
+    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
+    ins.checkExisting(false);
+    return ins;
+  }
+
+  /**
+   * Parsed and fixed {@link PersonIdent} string, formatted as {@link
+   * ChangeNoteUtil#appendAccountIdIdentString}
+   */
+  private static class FixIdentResult {
+
+    /** {@link com.google.gerrit.entities.Account.Id} parsed from PersonIdent string. */
+    Account.Id accountId;
+    /**
+     * Fixed ident string, that does not contain user data, or {@link Optional#empty} if fix was not
+     * required.
+     */
+    Optional<String> fixedIdentString;
+  }
+
+  /**
+   * Holds the state of change rewrite progress. Rewrite goes from the oldest commit to the most
+   * recent update.
+   */
+  private static class ChangeFixProgress {
+
+    /** {@link RefNames#changeMetaRef} of the change that is being fixed. */
+    final String changeMetaRef;
+
+    /** Tag at current commit update. */
+    String tag = null;
+
+    /** Assignee at current commit update. */
+    Account.Id assigneeId = null;
+
+    /** Author of the current commit update. */
+    Optional<Account.Id> updateAuthorId = null;
+
+    /**
+     * Accounts parsed so far together with their {@link Account#getName} extracted from {@link
+     * #accountCache} if needed by rewrite. Maps to empty string if was not requested from cache
+     * yet.
+     */
+    Map<Account.Id, Optional<AccountState>> parsedAccounts = new HashMap<>();
+
+    /** Id of the current commit in rewriter walk. */
+    ObjectId newTipId = null;
+    /** If any commits were rewritten by the rewriter. */
+    boolean anyFixesApplied = false;
+
+    /**
+     * Whether all commits seen by the rewriter with the fixes applied passed the verification, see
+     * {@link #verifyCommit}.
+     */
+    boolean isValidAfterFix = true;
+
+    List<CommitDiff> commitDiffs = new ArrayList<>();
+
+    public ChangeFixProgress(String changeMetaRef) {
+      this.changeMetaRef = changeMetaRef;
+    }
+  }
+
+  /**
+   * Account info parsed from {@link Account#getNameEmail}. See {@link
+   * #getAccountInfoFromNameEmail}.
+   */
+  @AutoValue
+  abstract static class ParsedAccountInfo {
+
+    static ParsedAccountInfo create(String fullName, String email) {
+      return new AutoValue_CommitRewriter_ParsedAccountInfo(fullName, Optional.ofNullable(email));
+    }
+
+    static ParsedAccountInfo create(String fullName) {
+      return new AutoValue_CommitRewriter_ParsedAccountInfo(fullName, Optional.empty());
+    }
+
+    abstract String name();
+
+    abstract Optional<String> email();
+  }
+
+  /**
+   * Objects, needed to fix Refs in a single {@link BatchRefUpdate}. Number of changes in a batch
+   * are limited by {@link RunOptions#maxRefsInBatch}.
+   */
+  @AutoValue
+  abstract static class RefsUpdate implements AutoCloseable {
+    static RefsUpdate create(Repository repo) {
+      RevWalk revWalk = new RevWalk(repo);
+      ObjectInserter inserter = newPackInserter(repo);
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      bru.setForceRefLog(true);
+      bru.setRefLogMessage(CommitRewriter.class.getName(), false);
+      bru.setAllowNonFastForwards(true);
+      return new AutoValue_CommitRewriter_RefsUpdate(bru, revWalk, inserter);
+    }
+
+    @Override
+    public void close() {
+      inserter().close();
+      revWalk().close();
+    }
+
+    abstract BatchRefUpdate batchRefUpdate();
+
+    abstract RevWalk revWalk();
+
+    abstract ObjectInserter inserter();
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
index b555fdb..6d6d53d 100644
--- a/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteChangeMessageRewriter.java
@@ -88,7 +88,8 @@
     byte[] raw = commit.getRawBuffer();
 
     Optional<ChangeNoteUtil.CommitMessageRange> range = parseCommitMessageRange(commit);
-    checkState(range.isPresent(), "failed to parse commit message");
+    checkState(
+        range.isPresent() && range.get().hasChangeMessage(), "failed to parse commit message");
 
     // Only replace the commit message body, which is the user-provided message. The subject and
     // footers are NoteDb metadata.
@@ -109,7 +110,6 @@
    * @param commitMessage the full commit message of the new commit.
    * @param inserter the {@code ObjectInserter} for the rewrite process.
    * @return the {@code objectId} of the new commit.
-   * @throws IOException
    */
   private ObjectId rewriteOneCommit(
       RevCommit originalCommit,
diff --git a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
index d0b6247..e8c0fda 100644
--- a/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
+++ b/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -191,8 +191,6 @@
    * @param putInComments the comments put in by this commit.
    * @param deletedComments the comments deleted by this commit.
    * @return the {@code objectId} of the new commit.
-   * @throws IOException
-   * @throws ConfigInvalidException
    */
   private ObjectId rewriteCommit(
       RevCommit originalCommit,
diff --git a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 65758f9..9345d98 100644
--- a/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -31,6 +31,8 @@
 import com.google.gerrit.git.RefUpdateUtil;
 import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.cancellation.RequestStateContext;
+import com.google.gerrit.server.cancellation.RequestStateContext.NonCancellableOperationContext;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -316,7 +318,9 @@
       executed = true;
       return null;
     }
-    try (Timer0.Context timer = metrics.updateLatency.start()) {
+    try (Timer0.Context timer = metrics.updateLatency.start();
+        NonCancellableOperationContext nonCancellableOperationContext =
+            RequestStateContext.startNonCancellableOperation()) {
       stage();
       // ChangeUpdates must execute before ChangeDraftUpdates.
       //
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 3c1d359..7998476 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -22,16 +22,20 @@
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.OutputStream;
 import java.io.OutputStreamWriter;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.AnyObjectId;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -60,11 +64,16 @@
     }
   }
 
+  /** Submit requirements are sorted w.r.t. their names before storing in NoteDb. */
+  private final Comparator<SubmitRequirementResult> SUBMIT_REQUIREMENT_RESULT_COMPARATOR =
+      Comparator.comparing(sr -> sr.submitRequirement().name());
+
   final byte[] baseRaw;
   private final List<? extends Comment> baseComments;
   final Map<Comment.Key, Comment> put;
   private final Set<Comment.Key> delete;
 
+  private List<SubmitRequirementResult> submitRequirementResults;
   private String pushCert;
 
   private RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
@@ -81,6 +90,7 @@
       put = new HashMap<>();
       pushCert = null;
     }
+    submitRequirementResults = new ArrayList<>();
     delete = new HashSet<>();
   }
 
@@ -99,6 +109,10 @@
     put.put(comment.key, comment);
   }
 
+  void putSubmitRequirementResult(SubmitRequirementResult result) {
+    submitRequirementResults.add(result);
+  }
+
   void deleteComment(Comment.Key key) {
     checkArgument(!put.containsKey(key), "cannot both delete and put %s", key);
     delete.add(key);
@@ -126,13 +140,19 @@
 
   private void buildNoteJson(ChangeNoteJson noteUtil, OutputStream out) throws IOException {
     ListMultimap<Integer, Comment> comments = buildCommentMap();
-    if (comments.isEmpty() && pushCert == null) {
+    if (submitRequirementResults.isEmpty() && comments.isEmpty() && pushCert == null) {
       return;
     }
 
     RevisionNoteData data = new RevisionNoteData();
     data.comments = COMMENT_ORDER.sortedCopy(comments.values());
     data.pushCert = pushCert;
+    if (!submitRequirementResults.isEmpty()) {
+      data.submitRequirementResults =
+          submitRequirementResults.stream()
+              .sorted(SUBMIT_REQUIREMENT_RESULT_COMPARATOR)
+              .collect(Collectors.toList());
+    }
 
     try (OutputStreamWriter osw = new OutputStreamWriter(out, UTF_8)) {
       noteUtil.getGson().toJson(data, osw);
diff --git a/java/com/google/gerrit/server/notedb/RevisionNoteData.java b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
index da15b34..c8770f1 100644
--- a/java/com/google/gerrit/server/notedb/RevisionNoteData.java
+++ b/java/com/google/gerrit/server/notedb/RevisionNoteData.java
@@ -15,15 +15,17 @@
 package com.google.gerrit.server.notedb;
 
 import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import java.util.List;
 
 /**
  * Holds the raw data of a RevisionNote.
  *
  * <p>It is intended for serialization to JSON only. It is used for human comments and robot
- * comments.
+ * comments, as well as for storing submit requirements.
  */
 class RevisionNoteData {
   String pushCert;
   List<Comment> comments;
+  List<SubmitRequirementResult> submitRequirementResults;
 }
diff --git a/java/com/google/gerrit/server/notedb/Sequences.java b/java/com/google/gerrit/server/notedb/Sequences.java
index 7a8e28f..7ae98778 100644
--- a/java/com/google/gerrit/server/notedb/Sequences.java
+++ b/java/com/google/gerrit/server/notedb/Sequences.java
@@ -112,8 +112,11 @@
                 .setCumulative()
                 .setUnit(Units.MILLISECONDS),
             Field.ofEnum(SequenceType.class, "sequence", Metadata.Builder::noteDbSequenceType)
+                .description("The sequence from which IDs were retrieved.")
                 .build(),
-            Field.ofBoolean("multiple", Metadata.Builder::multiple).build());
+            Field.ofBoolean("multiple", Metadata.Builder::multiple)
+                .description("Whether more than one ID was retrieved.")
+                .build());
   }
 
   public int nextAccountId() {
diff --git a/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
new file mode 100644
index 0000000..d128633
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/StoreSubmitRequirementsOp.java
@@ -0,0 +1,71 @@
+// 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.notedb;
+
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.inject.Inject;
+
+/** A {@link BatchUpdateOp} that stores the evaluated submit requirements of a change in NoteDb. */
+public class StoreSubmitRequirementsOp implements BatchUpdateOp {
+  private final ChangeData.Factory changeDataFactory;
+  private final SubmitRequirementsEvaluator evaluator;
+  private final boolean storeRequirementsInNoteDb;
+
+  public interface Factory {
+    StoreSubmitRequirementsOp create();
+  }
+
+  @Inject
+  public StoreSubmitRequirementsOp(
+      ChangeData.Factory changeDataFactory,
+      ExperimentFeatures experimentFeatures,
+      SubmitRequirementsEvaluator evaluator) {
+    this.changeDataFactory = changeDataFactory;
+    this.evaluator = evaluator;
+    this.storeRequirementsInNoteDb =
+        experimentFeatures.isFeatureEnabled(
+            ExperimentFeaturesConstants
+                .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws Exception {
+    if (!storeRequirementsInNoteDb) {
+      // Temporarily stop storing submit requirements in NoteDb when the change is merged.
+      return false;
+    }
+    // Create ChangeData using the project/change IDs instead of ctx.getChange(). We do that because
+    // for changes requiring a rebase before submission (e.g. if submit type = RebaseAlways), the
+    // RebaseOp inserts a new patchset that is visible here (via Change#getCurrentPatchset). If we
+    // then try to get ChangeData#currentPatchset it will return null, since it loads patchsets from
+    // NoteDb but tries to find the patchset with the ID of the one just inserted by the rebase op.
+    // Note that this implementation means that, in this case, submit requirement results will be
+    // stored in change notes of the pre last patchset commit. This is fine since submit requirement
+    // results should evaluate to the exact same results for both commits. Additionally, the
+    // pre-last commit is the one for which we displayed the submit requirement results of the last
+    // patchset to the user before it was merged.
+    ChangeData changeData = changeDataFactory.create(ctx.getProject(), ctx.getChange().getId());
+    ChangeUpdate update = ctx.getUpdate(ctx.getChange().currentPatchSetId());
+    // We do not want to store submit requirements in NoteDb for legacy submit records
+    update.putSubmitRequirementResults(
+        evaluator.evaluateAllRequirements(changeData, /* includeLegacy= */ false).values());
+    return !changeData.submitRequirements().isEmpty();
+  }
+}
diff --git a/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
new file mode 100644
index 0000000..3caa4d4
--- /dev/null
+++ b/java/com/google/gerrit/server/notedb/SubmitRequirementProtoConverter.java
@@ -0,0 +1,96 @@
+// 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.notedb;
+
+import com.google.errorprone.annotations.Immutable;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.entities.converter.ProtoConverter;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
+import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementExpressionResultSerializer;
+import com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer;
+import com.google.protobuf.Descriptors.FieldDescriptor;
+import com.google.protobuf.Parser;
+import java.util.Optional;
+
+@Immutable
+public enum SubmitRequirementProtoConverter
+    implements ProtoConverter<SubmitRequirementResultProto, SubmitRequirementResult> {
+  INSTANCE;
+
+  private static final FieldDescriptor SR_APPLICABILITY_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(2);
+  private static final FieldDescriptor SR_OVERRIDE_EXPR_RESULT_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(4);
+  private static final FieldDescriptor SR_LEGACY_FIELD =
+      SubmitRequirementResultProto.getDescriptor().findFieldByNumber(6);
+
+  @Override
+  public SubmitRequirementResultProto toProto(SubmitRequirementResult r) {
+    SubmitRequirementResultProto.Builder builder = SubmitRequirementResultProto.newBuilder();
+    builder
+        .setSubmitRequirement(SubmitRequirementSerializer.serialize(r.submitRequirement()))
+        .setCommit(ObjectIdConverter.create().toByteString(r.patchSetCommitId()));
+    if (r.legacy().isPresent()) {
+      builder.setLegacy(r.legacy().get());
+    }
+    if (r.applicabilityExpressionResult().isPresent()) {
+      builder.setApplicabilityExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.applicabilityExpressionResult().get()));
+    }
+    builder.setSubmittabilityExpressionResult(
+        SubmitRequirementExpressionResultSerializer.serialize(r.submittabilityExpressionResult()));
+    if (r.overrideExpressionResult().isPresent()) {
+      builder.setOverrideExpressionResult(
+          SubmitRequirementExpressionResultSerializer.serialize(
+              r.overrideExpressionResult().get()));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public SubmitRequirementResult fromProto(SubmitRequirementResultProto proto) {
+    SubmitRequirementResult.Builder builder =
+        SubmitRequirementResult.builder()
+            .patchSetCommitId(ObjectIdConverter.create().fromByteString(proto.getCommit()))
+            .submitRequirement(
+                SubmitRequirementSerializer.deserialize(proto.getSubmitRequirement()));
+    if (proto.hasField(SR_LEGACY_FIELD)) {
+      builder.legacy(Optional.of(proto.getLegacy()));
+    }
+    if (proto.hasField(SR_APPLICABILITY_EXPR_RESULT_FIELD)) {
+      builder.applicabilityExpressionResult(
+          Optional.of(
+              SubmitRequirementExpressionResultSerializer.deserialize(
+                  proto.getApplicabilityExpressionResult())));
+    }
+    builder.submittabilityExpressionResult(
+        SubmitRequirementExpressionResultSerializer.deserialize(
+            proto.getSubmittabilityExpressionResult()));
+    if (proto.hasField(SR_OVERRIDE_EXPR_RESULT_FIELD)) {
+      builder.overrideExpressionResult(
+          Optional.of(
+              SubmitRequirementExpressionResultSerializer.deserialize(
+                  proto.getOverrideExpressionResult())));
+    }
+    return builder.build();
+  }
+
+  @Override
+  public Parser<SubmitRequirementResultProto> getParser() {
+    return SubmitRequirementResultProto.parser();
+  }
+}
diff --git a/java/com/google/gerrit/server/patch/AutoMerger.java b/java/com/google/gerrit/server/patch/AutoMerger.java
index 3f17a2e..9a84398 100644
--- a/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -31,6 +31,8 @@
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
 import org.eclipse.jgit.dircache.DirCache;
@@ -70,6 +72,7 @@
  * <p>The second point means that these commits are referenced from NoteDb. The consequence of this
  * is that these refs should never be deleted.
  */
+@Singleton
 public class AutoMerger {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -88,7 +91,7 @@
 
   private final Counter1<OperationType> counter;
   private final Timer1<OperationType> latency;
-  private final PersonIdent gerritIdent;
+  private final Provider<PersonIdent> gerritIdentProvider;
   private final boolean save;
   private final ThreeWayMergeStrategy configuredMergeStrategy;
 
@@ -96,21 +99,25 @@
   AutoMerger(
       MetricMaker metricMaker,
       @GerritServerConfig Config cfg,
-      @GerritPersonIdent PersonIdent gerritIdent) {
+      @GerritPersonIdent Provider<PersonIdent> gerritIdentProvider) {
+    Field<OperationType> operationTypeField =
+        Field.ofEnum(OperationType.class, "type", Metadata.Builder::operationName)
+            .description("The type of the operation (CACHE_LOAD, IN_MEMORY_WRITE, ON_DISK_WRITE).")
+            .build();
     this.counter =
         metricMaker.newCounter(
             "git/auto-merge/num_operations",
             new Description("AutoMerge computations").setRate().setUnit("auto merge computations"),
-            Field.ofEnum(OperationType.class, "type", Metadata.Builder::operationName).build());
+            operationTypeField);
     this.latency =
         metricMaker.newTimer(
             "git/auto-merge/latency",
             new Description("AutoMerge computation latency")
                 .setCumulative()
                 .setUnit("milliseconds"),
-            Field.ofEnum(OperationType.class, "type", Metadata.Builder::operationName).build());
+            operationTypeField);
     this.save = cacheAutomerge(cfg);
-    this.gerritIdent = gerritIdent;
+    this.gerritIdentProvider = gerritIdentProvider;
     this.configuredMergeStrategy = MergeUtil.getMergeStrategy(cfg);
   }
 
@@ -139,7 +146,7 @@
     }
     counter.increment(OperationType.IN_MEMORY_WRITE);
     logger.atInfo().log("Computing in-memory AutoMerge for " + merge.name());
-    try (Timer1.Context ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
+    try (Timer1.Context<OperationType> ignored = latency.start(OperationType.IN_MEMORY_WRITE)) {
       return rw.parseCommit(createAutoMergeCommit(repo.getConfig(), rw, ins, merge, mergeStrategy));
     }
   }
@@ -162,17 +169,47 @@
       return Optional.empty();
     }
 
+    if (repoView.getRef(RefNames.refsCacheAutomerge(maybeMergeCommit.name())).isPresent()) {
+      logger.atFine().log("AutoMerge alredy exists");
+      return Optional.empty();
+    }
+
+    return Optional.of(
+        new ReceiveCommand(
+            ObjectId.zeroId(),
+            createAutoMergeCommit(repoView, rw, ins, maybeMergeCommit),
+            RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+  }
+
+  /**
+   * Creates an auto merge commit for the provided merge commit.
+   *
+   * <p>Callers are expected to ensure that the provided commit indeed has 2 parents.
+   *
+   * @return An auto-merge commit. Headers of the returned RevCommit are parsed.
+   */
+  ObjectId createAutoMergeCommit(
+      RepoView repoView, RevWalk rw, ObjectInserter ins, RevCommit mergeCommit) throws IOException {
     ObjectId autoMerge;
-    try (Timer1.Context ignored = latency.start(OperationType.ON_DISK_WRITE)) {
+    try (Timer1.Context<OperationType> ignored = latency.start(OperationType.ON_DISK_WRITE)) {
       autoMerge =
           createAutoMergeCommit(
-              repoView.getConfig(), rw, ins, maybeMergeCommit, configuredMergeStrategy);
+              repoView.getConfig(), rw, ins, mergeCommit, configuredMergeStrategy);
     }
     counter.increment(OperationType.ON_DISK_WRITE);
     logger.atFine().log("Added %s AutoMerge ref update for commit", autoMerge.name());
-    return Optional.of(
-        new ReceiveCommand(
-            ObjectId.zeroId(), autoMerge, RefNames.refsCacheAutomerge(maybeMergeCommit.name())));
+    return autoMerge;
+  }
+
+  Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName) throws IOException {
+    Ref ref = repo.getRefDatabase().exactRef(refName);
+    if (ref != null && ref.getObjectId() != null) {
+      RevObject obj = rw.parseAny(ref.getObjectId());
+      if (obj instanceof RevCommit) {
+        return Optional.of((RevCommit) obj);
+      }
+    }
+    return Optional.empty();
   }
 
   /**
@@ -219,7 +256,9 @@
     // the input commit, using the server name and timezone.
     PersonIdent ident =
         new PersonIdent(
-            gerritIdent, merge.getCommitterIdent().getWhen(), gerritIdent.getTimeZone());
+            gerritIdentProvider.get(),
+            merge.getCommitterIdent().getWhen(),
+            gerritIdentProvider.get().getTimeZone());
     CommitBuilder cb = new CommitBuilder();
     cb.setAuthor(ident);
     cb.setCommitter(ident);
@@ -241,18 +280,6 @@
     return rw.parseCommit(ins.insert(cb));
   }
 
-  private Optional<RevCommit> lookupCommit(Repository repo, RevWalk rw, String refName)
-      throws IOException {
-    Ref ref = repo.getRefDatabase().exactRef(refName);
-    if (ref != null && ref.getObjectId() != null) {
-      RevObject obj = rw.parseAny(ref.getObjectId());
-      if (obj instanceof RevCommit) {
-        return Optional.of((RevCommit) obj);
-      }
-    }
-    return Optional.empty();
-  }
-
   private static class NonFlushingWrapper extends ObjectInserter.Filter {
     private final ObjectInserter ins;
 
diff --git a/java/com/google/gerrit/server/patch/BaseCommitUtil.java b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
index 7c06a62..dcd3e85 100644
--- a/java/com/google/gerrit/server/patch/BaseCommitUtil.java
+++ b/java/com/google/gerrit/server/patch/BaseCommitUtil.java
@@ -16,40 +16,47 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.git.LockFailureException;
 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.gerrit.server.update.RepoView;
 import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 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.RefUpdate;
 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. */
+@Singleton
 class BaseCommitUtil {
   private final AutoMerger autoMerger;
-  private final ThreeWayMergeStrategy mergeStrategy;
   private final GitRepositoryManager repoManager;
 
+  /** If true, auto-merge results are stored in the repository. */
+  private final boolean saveAutomerge;
+
   @Inject
   BaseCommitUtil(AutoMerger am, @GerritServerConfig Config cfg, GitRepositoryManager repoManager) {
     this.autoMerger = am;
-    this.mergeStrategy = MergeUtil.getMergeStrategy(cfg);
+    this.saveAutomerge = AutoMerger.cacheAutomerge(cfg);
     this.repoManager = repoManager;
   }
 
   RevObject getBaseCommit(Project.NameKey project, ObjectId newCommit, @Nullable Integer parentNum)
       throws IOException {
     try (Repository repo = repoManager.openRepository(project);
-        InMemoryInserter ins = new InMemoryInserter(repo);
+        ObjectInserter ins = newInserter(repo);
         ObjectReader reader = ins.newReader();
         RevWalk rw = new RevWalk(reader)) {
       return getParentCommit(repo, ins, rw, parentNum, newCommit);
@@ -88,7 +95,7 @@
    */
   RevObject getParentCommit(
       Repository repo,
-      InMemoryInserter ins,
+      ObjectInserter ins,
       RevWalk rw,
       @Nullable Integer parentNum,
       ObjectId commitId)
@@ -107,12 +114,70 @@
         }
         // Only support auto-merge for 2 parents, not octopus merges
         if (current.getParentCount() == 2) {
-          return autoMerger.lookupFromGitOrMergeInMemory(repo, rw, ins, current, mergeStrategy);
+          if (!saveAutomerge) {
+            throw new IOException(
+                "diff against auto-merge commits is only supported if 'change.cacheAutomerge' config is set to true.");
+          }
+          // TODO(ghareeb): Avoid persisting auto-merge commits.
+          return getAutoMergeFromGitOrCreate(repo, ins, rw, current);
         }
         return null;
     }
   }
 
+  /**
+   * Gets the auto-merge commit from git if it already exists. If not, the auto-merge is created,
+   * persisted in git and the cache-automerge ref is updated for the merge commit.
+   *
+   * @return the auto-merge {@link RevCommit}
+   */
+  private RevCommit getAutoMergeFromGitOrCreate(
+      Repository repo, ObjectInserter ins, RevWalk rw, RevCommit mergeCommit) throws IOException {
+    String refName = RefNames.refsCacheAutomerge(mergeCommit.name());
+    Optional<RevCommit> autoMergeCommit = autoMerger.lookupCommit(repo, rw, refName);
+    if (autoMergeCommit.isPresent()) {
+      return autoMergeCommit.get();
+    }
+    ObjectId autoMergeId =
+        autoMerger.createAutoMergeCommit(new RepoView(repo, rw, ins), rw, ins, mergeCommit);
+    ins.flush();
+    return updateRef(repo, rw, refName, autoMergeId, mergeCommit);
+  }
+
+  private static RevCommit updateRef(
+      Repository repo, RevWalk rw, String refName, ObjectId autoMergeId, RevCommit merge)
+      throws IOException {
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setNewObjectId(autoMergeId);
+    ru.disableRefLog();
+    switch (ru.forceUpdate()) {
+      case FAST_FORWARD:
+      case FORCED:
+      case NEW:
+      case NO_CHANGE:
+        return rw.parseCommit(autoMergeId);
+      case LOCK_FAILURE:
+        throw new LockFailureException(
+            String.format("Failed to create auto-merge of %s", merge.name()), ru);
+      case IO_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      case RENAMED:
+      default:
+        throw new IOException(
+            String.format(
+                "Failed to create auto-merge of %s: Cannot write %s (%s)",
+                merge.name(), refName, ru.getResult()));
+    }
+  }
+
+  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();
diff --git a/java/com/google/gerrit/server/patch/ComparisonType.java b/java/com/google/gerrit/server/patch/ComparisonType.java
index eca2658..e450779 100644
--- a/java/com/google/gerrit/server/patch/ComparisonType.java
+++ b/java/com/google/gerrit/server/patch/ComparisonType.java
@@ -30,7 +30,7 @@
 
   /**
    * 1-based parent. Available if the old commit is the parent of the new commit and old commit is
-   * not the auto-merge.
+   * not the auto-merge. If set to 0, then comparison is for a root commit.
    */
   abstract Optional<Integer> parentNum();
 
@@ -48,6 +48,10 @@
     return new AutoValue_ComparisonType(Optional.empty(), true);
   }
 
+  public static ComparisonType againstRoot() {
+    return new AutoValue_ComparisonType(Optional.of(0), false);
+  }
+
   private static ComparisonType create(Optional<Integer> parent, boolean automerge) {
     return new AutoValue_ComparisonType(parent, automerge);
   }
diff --git a/java/com/google/gerrit/server/patch/DiffExecutor.java b/java/com/google/gerrit/server/patch/DiffExecutor.java
index 63d5c50..c9b87ff 100644
--- a/java/com/google/gerrit/server/patch/DiffExecutor.java
+++ b/java/com/google/gerrit/server/patch/DiffExecutor.java
@@ -16,14 +16,11 @@
 
 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;
 
-/**
- * Marker on {@link ExecutorService} used by {@link IntraLineLoader} and {@link PatchListLoader}.
- */
+/** Marker on {@link ExecutorService} used by {@link IntraLineLoader}. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface DiffExecutor {}
diff --git a/java/com/google/gerrit/server/patch/DiffOperations.java b/java/com/google/gerrit/server/patch/DiffOperations.java
index 93aefff..d2da736 100644
--- a/java/com/google/gerrit/server/patch/DiffOperations.java
+++ b/java/com/google/gerrit/server/patch/DiffOperations.java
@@ -16,6 +16,7 @@
 
 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.server.patch.filediff.FileDiffOutput;
@@ -45,16 +46,17 @@
    *
    * @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.
+   * @param parentNum 1-based integer specifying which parent to use as base. If zero, the only
+   *     parent will be used or the auto-merge if {@code newCommit} is a merge commit.
+   * @return map of file paths to the file diffs. The map key is the new file path for all {@link
+   *     ChangeType} file diffs except {@link ChangeType#DELETED} entries where the map key contains
+   *     the old file path. The map entries are not sorted by key.
    * @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;
+      Project.NameKey project, ObjectId newCommit, int parentNum) throws DiffNotAvailableException;
 
   /**
    * Returns the list of added, deleted or modified files between two commits (patchsets). The
@@ -63,7 +65,9 @@
    * @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.
+   * @return map of file paths to the file diffs. The map key is the new file path for all {@link
+   *     ChangeType} file diffs except {@link ChangeType#DELETED} entries where the map key contains
+   *     the old file path. The map entries are not sorted by key.
    * @throws DiffNotAvailableException if an internal error occurred in Git while evaluating the
    *     diff.
    */
@@ -80,8 +84,8 @@
    *
    * @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 parentNum 1-based integer specifying which parent to use as base. If zero, 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.
@@ -91,7 +95,7 @@
   FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parentNum,
+      int parentNum,
       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
index 6217239..3423b32 100644
--- a/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
+++ b/java/com/google/gerrit/server/patch/DiffOperationsImpl.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.entities.Patch.MERGE_LIST;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.flogger.FluentLogger;
@@ -28,8 +29,6 @@
 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.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCache;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheImpl;
 import com.google.gerrit.server.patch.diff.ModifiedFilesCacheKey;
@@ -43,34 +42,29 @@
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithm;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 
 /**
  * Provides different file diff operations. Uses the underlying Git/Gerrit caches to speed up the
  * diff computation.
  */
+@Singleton
 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 DiffAlgorithm DEFAULT_DIFF_ALGORITHM =
+      DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS;
   private static final Whitespace DEFAULT_WHITESPACE = Whitespace.IGNORE_NONE;
 
   private final ModifiedFilesCache modifiedFilesCache;
   private final FileDiffCache fileDiffCache;
   private final BaseCommitUtil baseCommitUtil;
-  private final long timeoutMillis;
-  private final ExecutorService diffExecutor;
 
   public static Module module() {
     return new CacheModule() {
@@ -89,30 +83,18 @@
   public DiffOperationsImpl(
       ModifiedFilesCache modifiedFilesCache,
       FileDiffCache fileDiffCache,
-      BaseCommitUtil baseCommit,
-      @DiffExecutor ExecutorService executor,
-      @GerritServerConfig Config cfg) {
+      BaseCommitUtil baseCommit) {
     this.modifiedFilesCache = modifiedFilesCache;
     this.fileDiffCache = fileDiffCache;
     this.baseCommitUtil = baseCommit;
-    this.diffExecutor = executor;
-    this.timeoutMillis =
-        ConfigUtil.getTimeUnit(
-            cfg,
-            "cache",
-            "diff",
-            "timeout",
-            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
-            TimeUnit.MILLISECONDS);
   }
 
   @Override
   public Map<String, FileDiffOutput> listModifiedFilesAgainstParent(
-      Project.NameKey project, ObjectId newCommit, @Nullable Integer parent)
-      throws DiffNotAvailableException {
+      Project.NameKey project, ObjectId newCommit, int parent) throws DiffNotAvailableException {
     try {
       DiffParameters diffParams = computeDiffParameters(project, newCommit, parent);
-      return listModifiedFilesWithTimeout(diffParams);
+      return getModifiedFiles(diffParams);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -130,22 +112,29 @@
             .baseCommit(oldCommit)
             .comparisonType(ComparisonType.againstOtherPatchSet())
             .build();
-    return listModifiedFilesWithTimeout(params);
+    return getModifiedFiles(params);
   }
 
   @Override
   public FileDiffOutput getModifiedFileAgainstParent(
       Project.NameKey project,
       ObjectId newCommit,
-      @Nullable Integer parent,
+      int 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);
-      return getModifiedFileWithTimeout(key, diffParams);
+          createFileDiffCacheKey(
+              project,
+              diffParams.baseCommit(),
+              newCommit,
+              fileName,
+              DEFAULT_DIFF_ALGORITHM,
+              /* useTimeout= */ true,
+              whitespace);
+      return getModifiedFileForKey(key);
     } catch (IOException e) {
       throw new DiffNotAvailableException(
           "Failed to evaluate the parent/base commit for commit " + newCommit, e);
@@ -160,60 +149,16 @@
       String fileName,
       @Nullable DiffPreferencesInfo.Whitespace whitespace)
       throws DiffNotAvailableException {
-    DiffParameters params = // used for logging only
-        DiffParameters.builder()
-            .project(project)
-            .baseCommit(oldCommit)
-            .newCommit(newCommit)
-            .comparisonType(ComparisonType.againstOtherPatchSet())
-            .build();
     FileDiffCacheKey key =
-        createFileDiffCacheKey(project, oldCommit, newCommit, fileName, whitespace);
-    return getModifiedFileWithTimeout(key, params);
-  }
-
-  private Map<String, FileDiffOutput> listModifiedFilesWithTimeout(DiffParameters params)
-      throws DiffNotAvailableException {
-    Future<DiffResult> task =
-        diffExecutor.submit(
-            () -> {
-              ImmutableMap<String, FileDiffOutput> modifiedFiles = getModifiedFiles(params);
-              return DiffResult.create(null, modifiedFiles);
-            });
-    DiffResult diffResult = execDiffWithTimeout(task, params);
-    return diffResult.modifiedFiles();
-  }
-
-  private FileDiffOutput getModifiedFileWithTimeout(FileDiffCacheKey key, DiffParameters params)
-      throws DiffNotAvailableException {
-    Future<DiffResult> task =
-        diffExecutor.submit(
-            () -> {
-              Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
-              FileDiffOutput fileDiffOutput =
-                  diffList.containsKey(key.newFilePath())
-                      ? diffList.get(key.newFilePath())
-                      : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
-              return DiffResult.create(fileDiffOutput, null);
-            });
-    DiffResult result = execDiffWithTimeout(task, params);
-    return result.fileDiff();
-  }
-
-  /** Executes a diff task by employing a timeout. */
-  private DiffResult execDiffWithTimeout(Future<DiffResult> task, DiffParameters params)
-      throws DiffNotAvailableException {
-    try {
-      return task.get(timeoutMillis, TimeUnit.MILLISECONDS);
-    } catch (InterruptedException | TimeoutException e) {
-      throw new DiffNotAvailableException(
-          String.format(
-              "Timeout reached while computing diff for project %s, old commit %s, new commit %s",
-              params.project(), params.baseCommit().name(), params.newCommit().name()),
-          e);
-    } catch (ExecutionException e) {
-      throw new DiffNotAvailableException(e);
-    }
+        createFileDiffCacheKey(
+            project,
+            oldCommit,
+            newCommit,
+            fileName,
+            DEFAULT_DIFF_ALGORITHM,
+            /* useTimeout= */ true,
+            whitespace);
+    return getModifiedFileForKey(key);
   }
 
   private ImmutableMap<String, FileDiffOutput> getModifiedFiles(DiffParameters diffParams)
@@ -230,12 +175,24 @@
       List<FileDiffCacheKey> fileCacheKeys = new ArrayList<>();
       fileCacheKeys.add(
           createFileDiffCacheKey(
-              project, oldCommit, newCommit, COMMIT_MSG, /* whitespace= */ null));
+              project,
+              oldCommit,
+              newCommit,
+              COMMIT_MSG,
+              DEFAULT_DIFF_ALGORITHM,
+              /* useTimeout= */ true,
+              /* whitespace= */ null));
 
       if (cmp.isAgainstAutoMerge() || isMergeAgainstParent(cmp, project, newCommit)) {
         fileCacheKeys.add(
             createFileDiffCacheKey(
-                project, oldCommit, newCommit, MERGE_LIST, /*whitespace = */ null));
+                project,
+                oldCommit,
+                newCommit,
+                MERGE_LIST,
+                DEFAULT_DIFF_ALGORITHM,
+                /* useTimeout= */ true,
+                /*whitespace = */ null));
       }
 
       if (diffParams.skipFiles() == null) {
@@ -249,6 +206,8 @@
                         entity.newPath().isPresent()
                             ? entity.newPath().get()
                             : entity.oldPath().get(),
+                        DEFAULT_DIFF_ALGORITHM,
+                        /* useTimeout= */ true,
                         /* whitespace= */ null))
             .forEach(fileCacheKeys::add);
       }
@@ -258,22 +217,71 @@
     }
   }
 
+  private FileDiffOutput getModifiedFileForKey(FileDiffCacheKey key)
+      throws DiffNotAvailableException {
+    Map<String, FileDiffOutput> diffList = getModifiedFilesForKeys(ImmutableList.of(key));
+    return diffList.containsKey(key.newFilePath())
+        ? diffList.get(key.newFilePath())
+        : FileDiffOutput.empty(key.newFilePath(), key.oldCommit(), key.newCommit());
+  }
+
+  /**
+   * Lookup the file diffs for the input {@code keys}. For results where the cache reports negative
+   * results, e.g. due to timeouts in the cache loader, this method requests the diff again using
+   * the fallback algorithm {@link DiffAlgorithm#HISTOGRAM_NO_FALLBACK}.
+   */
   private ImmutableMap<String, FileDiffOutput> getModifiedFilesForKeys(List<FileDiffCacheKey> keys)
       throws DiffNotAvailableException {
-    ImmutableMap.Builder<String, FileDiffOutput> files = ImmutableMap.builder();
     ImmutableMap<FileDiffCacheKey, FileDiffOutput> fileDiffs = fileDiffCache.getAll(keys);
+    List<FileDiffCacheKey> fallbackKeys = new ArrayList<>();
 
-    for (FileDiffOutput fileDiffOutput : fileDiffs.values()) {
+    ImmutableList.Builder<FileDiffOutput> result = ImmutableList.builder();
+
+    // Use the fallback diff algorithm for negative results
+    for (FileDiffCacheKey key : fileDiffs.keySet()) {
+      FileDiffOutput diff = fileDiffs.get(key);
+      if (diff.isNegative()) {
+        FileDiffCacheKey fallbackKey =
+            createFileDiffCacheKey(
+                key.project(),
+                key.oldCommit(),
+                key.newCommit(),
+                key.newFilePath(),
+                // Use the fallback diff algorithm
+                DiffAlgorithm.HISTOGRAM_NO_FALLBACK,
+                // We don't enforce timeouts with the fallback algorithm. Timeouts were introduced
+                // because of a bug in JGit that happens only when the histogram algorithm uses
+                // Myers as fallback. See https://bugs.chromium.org/p/gerrit/issues/detail?id=487
+                /* useTimeout= */ false,
+                key.whitespace());
+        fallbackKeys.add(fallbackKey);
+      } else {
+        result.add(diff);
+      }
+    }
+    result.addAll(fileDiffCache.getAll(fallbackKeys).values());
+    return mapByFilePath(result.build());
+  }
+
+  /**
+   * Map a collection of {@link FileDiffOutput} based on their file paths. The result map keys
+   * represent the old file path for deleted files, or the new path otherwise.
+   */
+  private ImmutableMap<String, FileDiffOutput> mapByFilePath(
+      ImmutableCollection<FileDiffOutput> fileDiffOutputs) {
+    ImmutableMap.Builder<String, FileDiffOutput> diffs = ImmutableMap.builder();
+
+    for (FileDiffOutput fileDiffOutput : fileDiffOutputs) {
       if (fileDiffOutput.isEmpty() || allDueToRebase(fileDiffOutput)) {
         continue;
       }
       if (fileDiffOutput.changeType() == ChangeType.DELETED) {
-        files.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
+        diffs.put(fileDiffOutput.oldPath().get(), fileDiffOutput);
       } else {
-        files.put(fileDiffOutput.newPath().get(), fileDiffOutput);
+        diffs.put(fileDiffOutput.newPath().get(), fileDiffOutput);
       }
     }
-    return files.build();
+    return diffs.build();
   }
 
   private static boolean allDueToRebase(FileDiffOutput fileDiffOutput) {
@@ -302,6 +310,8 @@
       ObjectId aCommit,
       ObjectId bCommit,
       String newPath,
+      DiffAlgorithm diffAlgorithm,
+      boolean useTimeout,
       @Nullable Whitespace whitespace) {
     whitespace = whitespace == null ? DEFAULT_WHITESPACE : whitespace;
     return FileDiffCacheKey.builder()
@@ -310,37 +320,22 @@
         .newCommit(bCommit)
         .newFilePath(newPath)
         .renameScore(RENAME_SCORE)
-        .diffAlgorithm(DEFAULT_DIFF_ALGORITHM)
+        .diffAlgorithm(diffAlgorithm)
         .whitespace(whitespace)
+        .useTimeout(useTimeout)
         .build();
   }
 
-  /**
-   * All interface methods create their results using this class. This is used so that the timeout
-   * method {@link #execDiffWithTimeout(Future, DiffParameters)} could be reused by all interface
-   * methods.
-   */
-  @AutoValue
-  abstract static class DiffResult {
-    static DiffResult create(
-        @Nullable FileDiffOutput fileDiff,
-        @Nullable ImmutableMap<String, FileDiffOutput> modifiedFiles) {
-      return new AutoValue_DiffOperationsImpl_DiffResult(fileDiff, modifiedFiles);
-    }
-
-    @Nullable
-    abstract FileDiffOutput fileDiff();
-
-    @Nullable
-    abstract ImmutableMap<String, FileDiffOutput> modifiedFiles();
-  }
-
   @AutoValue
   abstract static class DiffParameters {
     abstract Project.NameKey project();
 
     abstract ObjectId newCommit();
 
+    /**
+     * Base commit represents the old commit of the diff. For diffs against the root commit, this
+     * should be set to {@link ObjectId#zeroId()}.
+     */
     abstract ObjectId baseCommit();
 
     abstract ComparisonType comparisonType();
@@ -380,12 +375,17 @@
       Project.NameKey project, ObjectId newCommit, Integer parent) throws IOException {
     DiffParameters.Builder result =
         DiffParameters.builder().project(project).newCommit(newCommit).parent(parent);
-    if (parent != null) {
+    if (parent > 0) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(parent));
       return result.build();
     }
     int numParents = baseCommitUtil.getNumParents(project, newCommit);
+    if (numParents == 0) {
+      result.baseCommit(ObjectId.zeroId());
+      result.comparisonType(ComparisonType.againstRoot());
+      return result.build();
+    }
     if (numParents == 1) {
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, parent));
       result.comparisonType(ComparisonType.againstParent(1));
@@ -394,11 +394,9 @@
     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.");
+              + "with more than two parents is not supported. Commit %s has %d parents."
+              + " Falling back to the diff against the first parent.",
+          newCommit, numParents);
       result.baseCommit(baseCommitUtil.getBaseCommit(project, newCommit, 1).getId());
       result.comparisonType(ComparisonType.againstParent(1));
       result.skipFiles(true);
diff --git a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index b61e0c7..fcce672 100644
--- a/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -16,58 +16,73 @@
 
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.Map;
 import java.util.concurrent.Callable;
+import org.eclipse.jgit.lib.ObjectId;
 
 public class DiffSummaryLoader implements Callable<DiffSummary> {
   public interface Factory {
     DiffSummaryLoader create(DiffSummaryKey key, Project.NameKey project);
   }
 
-  private final PatchListCache patchListCache;
+  private final DiffOperations diffOperations;
   private final DiffSummaryKey key;
   private final Project.NameKey project;
 
   @Inject
-  DiffSummaryLoader(PatchListCache plc, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
-    patchListCache = plc;
+  DiffSummaryLoader(
+      DiffOperations diffOps, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
+    diffOperations = diffOps;
     key = k;
     project = p;
   }
 
   @Override
   public DiffSummary call() throws Exception {
-    PatchList patchList = patchListCache.get(key.toPatchListKey(), project);
-    return toDiffSummary(patchList);
+    ObjectId oldId = key.toPatchListKey().getOldId();
+    ObjectId newId = key.toPatchListKey().getNewId();
+    Map<String, FileDiffOutput> diffList =
+        oldId == null
+            ? diffOperations.listModifiedFilesAgainstParent(project, newId, /* parentNum= */ 0)
+            : diffOperations.listModifiedFiles(project, oldId, newId);
+    return toDiffSummary(diffList);
   }
 
-  private DiffSummary toDiffSummary(PatchList patchList) {
-    List<String> r = new ArrayList<>(patchList.getPatches().size());
-    for (PatchListEntry e : patchList.getPatches()) {
-      if (Patch.isMagic(e.getNewName())) {
+  private DiffSummary toDiffSummary(Map<String, FileDiffOutput> fileDiffs) {
+    List<String> r = new ArrayList<>(fileDiffs.size());
+    int linesInserted = 0;
+    int linesDeleted = 0;
+    for (String path : fileDiffs.keySet()) {
+      if (Patch.isMagic(path)) {
         continue;
       }
-      switch (e.getChangeType()) {
+      FileDiffOutput fileDiff = fileDiffs.get(path);
+      linesInserted += fileDiff.insertions();
+      linesDeleted += fileDiff.deletions();
+      switch (fileDiff.changeType()) {
         case ADDED:
         case MODIFIED:
         case DELETED:
         case COPIED:
         case REWRITE:
-          r.add(e.getNewName());
+          r.add(
+              FilePathAdapter.getNewPath(
+                  fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()));
           break;
 
         case RENAMED:
-          r.add(e.getOldName());
-          r.add(e.getNewName());
+          r.add(FilePathAdapter.getOldPath(fileDiff.oldPath(), fileDiff.changeType()));
+          r.add(
+              FilePathAdapter.getNewPath(
+                  fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()));
           break;
       }
     }
-    return new DiffSummary(
-        r.stream().sorted().toArray(String[]::new),
-        patchList.getInsertions(),
-        patchList.getDeletions());
+    return new DiffSummary(r.stream().sorted().toArray(String[]::new), linesInserted, linesDeleted);
   }
 }
diff --git a/java/com/google/gerrit/server/patch/FilePathAdapter.java b/java/com/google/gerrit/server/patch/FilePathAdapter.java
index ccd1466..2c98f1a 100644
--- a/java/com/google/gerrit/server/patch/FilePathAdapter.java
+++ b/java/com/google/gerrit/server/patch/FilePathAdapter.java
@@ -35,11 +35,12 @@
       case DELETED:
       case ADDED:
       case MODIFIED:
-      case REWRITE:
         return null;
       case COPIED:
       case RENAMED:
         return oldName.get();
+      case REWRITE:
+        return oldName.isPresent() ? oldName.get() : null;
       default:
         throw new IllegalArgumentException("Unsupported type " + changeType);
     }
diff --git a/java/com/google/gerrit/server/patch/IntraLineLoader.java b/java/com/google/gerrit/server/patch/IntraLineLoader.java
index 34ac3d8..d6afa88 100644
--- a/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -25,7 +25,9 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
@@ -33,6 +35,7 @@
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.MyersDiff;
 import org.eclipse.jgit.lib.Config;
@@ -250,13 +253,102 @@
           wordEdits.set(j, new Edit(ab, ae, bb, be));
         }
 
-        edits.set(i, new ReplaceEdit(e, wordEdits));
+        // Validate that the intra-line edits applied to the "a" text produces the "b" text. If this
+        // check fails, fallback to a single replace edit that covers the whole area.
+        if (isValidTransformation(a, b, wordEdits)) {
+          edits.set(i, new ReplaceEdit(e, wordEdits));
+        } else {
+          edits.set(i, new ReplaceEdit(e, Arrays.asList(new Edit(0, a.size(), 0, b.size()))));
+        }
       }
     }
 
     return new IntraLineDiff(edits);
   }
 
+  /**
+   * Validate that the application of the list of {@code edits} to the {@code lText} text produces
+   * the {@code rText} text.
+   *
+   * @return true if {@code lText} + {@code edits} results in the {@code rText} text, and false
+   *     otherwise.
+   */
+  private static boolean isValidTransformation(CharText lText, CharText rText, List<Edit> edits) {
+    // Apply replace and delete edits to the left text
+    Optional<String> left =
+        applyEditsToString(
+            toStringBuilder(lText),
+            toStringBuilder(rText).toString(),
+            edits.stream()
+                .filter(e -> e.getType() == Edit.Type.REPLACE || e.getType() == Edit.Type.DELETE)
+                .collect(Collectors.toList()));
+    // Remove insert edits from the right text
+    Optional<String> right =
+        applyEditsToString(
+            toStringBuilder(rText),
+            null,
+            edits.stream()
+                .filter(e -> e.getType() == Edit.Type.INSERT)
+                .collect(Collectors.toList()));
+
+    return left.isPresent() && right.isPresent() && left.get().contentEquals(right.get());
+  }
+
+  /**
+   * Apply edits to the {@code target} string. Replace edits are applied to target and replaced with
+   * a substring from {@code from}. Delete edits are applied to target. Insert edits are removed
+   * from target.
+   *
+   * @return Optional containing the transformed string, or empty if the transformation fails (due
+   *     to index out of bounds).
+   */
+  private static Optional<String> applyEditsToString(
+      StringBuilder target, String from, List<Edit> edits) {
+    // Process edits right to left to avoid re-computation of indices when characters are removed.
+    try {
+      for (int i = edits.size() - 1; i >= 0; i--) {
+        Edit edit = edits.get(i);
+        if (edit.getType() == Edit.Type.REPLACE) {
+          boundaryCheck(target, edit.getBeginA(), edit.getEndA() - 1);
+          boundaryCheck(from, edit.getBeginB(), edit.getEndB() - 1);
+          target.replace(
+              edit.getBeginA(), edit.getEndA(), from.substring(edit.getBeginB(), edit.getEndB()));
+        } else if (edit.getType() == Edit.Type.DELETE) {
+          boundaryCheck(target, edit.getBeginA(), edit.getEndA() - 1);
+          target.delete(edit.getBeginA(), edit.getEndA());
+        } else if (edit.getType() == Edit.Type.INSERT) {
+          boundaryCheck(target, edit.getBeginB(), edit.getEndB() - 1);
+          target.delete(edit.getBeginB(), edit.getEndB());
+        }
+      }
+      return Optional.of(target.toString());
+    } catch (StringIndexOutOfBoundsException unused) {
+      return Optional.empty();
+    }
+  }
+
+  private static void boundaryCheck(StringBuilder s, int i1, int i2) {
+    if (i1 >= 0 && i2 >= 0 && i1 < s.length() && i2 < s.length()) {
+      return;
+    }
+    throw new StringIndexOutOfBoundsException();
+  }
+
+  private static void boundaryCheck(String s, int i1, int i2) {
+    if (i1 >= 0 && i2 >= 0 && i1 < s.length() && i2 < s.length()) {
+      return;
+    }
+    throw new StringIndexOutOfBoundsException();
+  }
+
+  private static StringBuilder toStringBuilder(CharText text) {
+    StringBuilder result = new StringBuilder();
+    for (int i = 0; i < text.size(); i++) {
+      result.append(text.charAt(i));
+    }
+    return result;
+  }
+
   private static void combineLineEdits(
       List<Edit> edits, ImmutableSet<Edit> editsDueToRebase, Text a, Text b) {
     for (int j = 0; j < edits.size() - 1; ) {
diff --git a/java/com/google/gerrit/server/patch/PatchFile.java b/java/com/google/gerrit/server/patch/PatchFile.java
index ca5223d..81355cc 100644
--- a/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/java/com/google/gerrit/server/patch/PatchFile.java
@@ -18,7 +18,9 @@
 
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.exceptions.NoSuchEntityException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import java.io.IOException;
+import java.util.Map;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -35,7 +37,7 @@
 /** State supporting processing of a single {@link Patch} instance. */
 public class PatchFile {
   private final Repository repo;
-  private final PatchListEntry entry;
+  private final FileDiffOutput diff;
   private final RevTree aTree;
   private final RevTree bTree;
 
@@ -51,21 +53,30 @@
   private Text a;
   private Text b;
 
-  public PatchFile(Repository repo, PatchList patchList, String fileName)
-      throws MissingObjectException, IncorrectObjectTypeException, IOException {
+  public PatchFile(Repository repo, Map<String, FileDiffOutput> modifiedFiles, String fileName)
+      throws IOException {
     this.repo = repo;
-    this.entry = patchList.get(fileName);
+    this.diff =
+        modifiedFiles.values().stream()
+            .filter(f -> f.newPath().isPresent() && f.newPath().get().equals(fileName))
+            .findFirst()
+            .orElse(FileDiffOutput.empty(fileName, ObjectId.zeroId(), ObjectId.zeroId()));
 
+    if (Patch.PATCHSET_LEVEL.equals(fileName)) {
+      aTree = null;
+      bTree = null;
+      return;
+    }
     try (ObjectReader reader = repo.newObjectReader();
         RevWalk rw = new RevWalk(reader)) {
-      final RevCommit bCommit = rw.parseCommit(patchList.getNewId());
+      final RevCommit bCommit = rw.parseCommit(diff.newCommitId());
 
       if (Patch.COMMIT_MSG.equals(fileName)) {
-        if (patchList.getComparisonType().isAgainstParentOrAutoMerge()) {
+        if (diff.comparisonType().isAgainstParentOrAutoMerge()) {
           a = Text.EMPTY;
         } else {
           // For the initial commit, we have an empty tree on Side A
-          RevObject object = rw.parseAny(patchList.getOldId());
+          RevObject object = rw.parseAny(diff.oldCommitId());
           a = object instanceof RevCommit ? Text.forCommit(reader, object) : Text.EMPTY;
         }
         b = Text.forCommit(reader, bCommit);
@@ -74,18 +85,18 @@
         bTree = null;
       } else if (Patch.MERGE_LIST.equals(fileName)) {
         // For the initial commit, we have an empty tree on Side A
-        RevObject object = rw.parseAny(patchList.getOldId());
+        RevObject object = rw.parseAny(diff.oldCommitId());
         a =
             object instanceof RevCommit
-                ? Text.forMergeList(patchList.getComparisonType(), reader, object)
+                ? Text.forMergeList(diff.comparisonType(), reader, object)
                 : Text.EMPTY;
-        b = Text.forMergeList(patchList.getComparisonType(), reader, bCommit);
+        b = Text.forMergeList(diff.comparisonType(), reader, bCommit);
 
         aTree = null;
         bTree = null;
       } else {
-        if (patchList.getOldId() != null) {
-          aTree = rw.parseTree(patchList.getOldId());
+        if (diff.oldCommitId() != null) {
+          aTree = rw.parseTree(diff.oldCommitId());
         } else {
           final RevCommit p = bCommit.getParent(0);
           rw.parseHeaders(p);
@@ -97,11 +108,11 @@
   }
 
   private String getOldName() {
-    String name = entry.getOldName();
+    String name = FilePathAdapter.getOldPath(diff.oldPath(), diff.changeType());
     if (name != null) {
       return name;
     }
-    return entry.getNewName();
+    return FilePathAdapter.getNewPath(diff.oldPath(), diff.newPath(), diff.changeType());
   }
 
   /**
@@ -111,7 +122,6 @@
    * @param line the line number to extract (1 based; 1 is the first line).
    * @return the string version of the file line.
    * @throws IOException the patch or complete file content cannot be read.
-   * @throws NoSuchEntityException
    */
   public String getLine(int file, int line) throws IOException, NoSuchEntityException {
     switch (file) {
@@ -123,7 +133,10 @@
 
       case 1:
         if (b == null) {
-          b = load(bTree, entry.getNewName());
+          b =
+              load(
+                  bTree,
+                  FilePathAdapter.getNewPath(diff.oldPath(), diff.newPath(), diff.changeType()));
         }
         return b.getString(line - 1);
 
@@ -135,7 +148,7 @@
   private Text load(ObjectId tree, String path)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
-    if (path == null) {
+    if (path == null || Patch.PATCHSET_LEVEL.equals(path)) {
       return Text.EMPTY;
     }
     final TreeWalk tw = TreeWalk.forPath(repo, path, tree);
diff --git a/java/com/google/gerrit/server/patch/PatchList.java b/java/com/google/gerrit/server/patch/PatchList.java
index cb95553..b983fb8 100644
--- a/java/com/google/gerrit/server/patch/PatchList.java
+++ b/java/com/google/gerrit/server/patch/PatchList.java
@@ -140,17 +140,17 @@
     return Collections.unmodifiableList(Arrays.asList(patches));
   }
 
-  /** @return the comparison type */
+  /** Returns the comparison type */
   public ComparisonType getComparisonType() {
     return comparisonType;
   }
 
-  /** @return total number of new lines added. */
+  /** Returns total number of new lines added. */
   public int getInsertions() {
     return insertions;
   }
 
-  /** @return total number of lines removed. */
+  /** Returns total number of lines removed. */
   public int getDeletions() {
     return deletions;
   }
diff --git a/java/com/google/gerrit/server/patch/PatchListCache.java b/java/com/google/gerrit/server/patch/PatchListCache.java
index e60302a..b8651e0 100644
--- a/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -14,47 +14,13 @@
 
 package com.google.gerrit.server.patch;
 
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import org.eclipse.jgit.lib.ObjectId;
 
-/** Provides a cached list of {@link PatchListEntry}. */
+/**
+ * Provides a cached list of intra-line and summary diffs. Use {@link DiffOperations} to compute
+ * detailed file diffs.
+ */
 public interface PatchListCache {
-  /**
-   * Returns the patch list - list of modified files - between two commits.
-   *
-   * @param key identifies the old / new commits.
-   * @param project name key identifying a specific git project (repository).
-   * @return patch list containing the modified files between two commits.
-   * @deprecated use {@link DiffOperations} instead.
-   */
-  @Deprecated
-  PatchList get(PatchListKey key, Project.NameKey project) throws PatchListNotAvailableException;
-
-  /**
-   * Returns the patch list - list of modified files - between two commits.
-   *
-   * @param change entity containing all change data.
-   * @param patchSet single revision of a {@link Change}.
-   * @return patch list containing the modified files between two commits.
-   * @deprecated use {@link DiffOperations} instead.
-   */
-  @Deprecated
-  PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException;
-
-  /**
-   * Returns the patch list - list of modified files - between two commits.
-   *
-   * @param change entity containing all change data.
-   * @param patchSet single revision of a {@link Change}.
-   * @param parentNum 1-based parent number when new commit used in comparison is a merge commit.
-   * @return patch list containing the modified files between two commits.
-   * @deprecated use {@link DiffOperations} instead.
-   */
-  @Deprecated
-  ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
-      throws PatchListNotAvailableException;
 
   IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args);
 
diff --git a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index a3e9a54..eab0c22 100644
--- a/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -15,17 +15,12 @@
 
 package com.google.gerrit.server.patch;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
+import com.google.common.flogger.FluentLogger;
 import com.google.common.util.concurrent.UncheckedExecutionException;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-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;
@@ -33,12 +28,12 @@
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.LargeObjectException;
 import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
 
 /** Provides a cached list of {@link PatchListEntry}. */
 @Singleton
 public class PatchListCacheImpl implements PatchListCache {
-  public static final String FILE_NAME = "diff";
+  public static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   static final String INTRA_NAME = "diff_intraline";
   static final String DIFF_SUMMARY = "diff_summary";
 
@@ -46,13 +41,6 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        factory(PatchListLoader.Factory.class);
-        // TODO(davido): Switch off using legacy cache backend, after fixing PatchListLoader
-        // to be recursion free.
-        persist(FILE_NAME, PatchListKey.class, PatchList.class, CacheBackend.GUAVA)
-            .maximumWeight(10 << 20)
-            .weigher(PatchListWeigher.class);
-
         factory(IntraLineLoader.Factory.class);
         persist(INTRA_NAME, IntraLineDiffKey.class, IntraLineDiff.class)
             .maximumWeight(10 << 20)
@@ -70,27 +58,21 @@
     };
   }
 
-  private final Cache<PatchListKey, PatchList> fileCache;
   private final Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
   private final Cache<DiffSummaryKey, DiffSummary> diffSummaryCache;
-  private final PatchListLoader.Factory fileLoaderFactory;
   private final IntraLineLoader.Factory intraLoaderFactory;
   private final DiffSummaryLoader.Factory diffSummaryLoaderFactory;
   private final boolean computeIntraline;
 
   @Inject
   PatchListCacheImpl(
-      @Named(FILE_NAME) Cache<PatchListKey, PatchList> fileCache,
       @Named(INTRA_NAME) Cache<IntraLineDiffKey, IntraLineDiff> intraCache,
       @Named(DIFF_SUMMARY) Cache<DiffSummaryKey, DiffSummary> diffSummaryCache,
-      PatchListLoader.Factory fileLoaderFactory,
       IntraLineLoader.Factory intraLoaderFactory,
       DiffSummaryLoader.Factory diffSummaryLoaderFactory,
       @GerritServerConfig Config cfg) {
-    this.fileCache = fileCache;
     this.intraCache = intraCache;
     this.diffSummaryCache = diffSummaryCache;
-    this.fileLoaderFactory = fileLoaderFactory;
     this.intraLoaderFactory = intraLoaderFactory;
     this.diffSummaryLoaderFactory = diffSummaryLoaderFactory;
 
@@ -100,52 +82,6 @@
   }
 
   @Override
-  public PatchList get(PatchListKey key, Project.NameKey project)
-      throws PatchListNotAvailableException {
-    try {
-      PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
-      if (pl instanceof LargeObjectTombstone) {
-        throw new PatchListObjectTooLargeException(
-            "Error computing " + key + ". Previous attempt failed with LargeObjectException");
-      }
-      return pl;
-    } catch (ExecutionException e) {
-      PatchListLoader.logger.atWarning().withCause(e).log("Error computing %s", key);
-      throw new PatchListNotAvailableException(e);
-    } catch (UncheckedExecutionException e) {
-      if (e.getCause() instanceof LargeObjectException) {
-        // Cache negative result so we don't need to redo expensive computations that would yield
-        // the same result.
-        fileCache.put(key, new LargeObjectTombstone());
-        PatchListLoader.logger.atWarning().withCause(e).log("Error computing %s", key);
-        throw new PatchListNotAvailableException(e);
-      }
-      throw e;
-    }
-  }
-
-  @Override
-  public PatchList get(Change change, PatchSet patchSet) throws PatchListNotAvailableException {
-    return get(change, patchSet, null);
-  }
-
-  @Override
-  public ObjectId getOldId(Change change, PatchSet patchSet, Integer parentNum)
-      throws PatchListNotAvailableException {
-    return get(change, patchSet, parentNum).getOldId();
-  }
-
-  private PatchList get(Change change, PatchSet patchSet, Integer parentNum)
-      throws PatchListNotAvailableException {
-    Project.NameKey project = change.getProject();
-    ObjectId b = patchSet.commitId();
-    if (parentNum != null) {
-      return get(PatchListKey.againstParentNum(parentNum, b, Whitespace.IGNORE_NONE), project);
-    }
-    return get(PatchListKey.againstDefaultBase(b, Whitespace.IGNORE_NONE), project);
-  }
-
-  @Override
   public IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args) {
     if (computeIntraline) {
       try {
@@ -164,28 +100,14 @@
     try {
       return diffSummaryCache.get(key, diffSummaryLoaderFactory.create(key, project));
     } catch (ExecutionException e) {
-      PatchListLoader.logger.atWarning().withCause(e).log("Error computing %s", key);
+      logger.atWarning().withCause(e).log("Error computing %s", key);
       throw new PatchListNotAvailableException(e);
     } catch (UncheckedExecutionException e) {
       if (e.getCause() instanceof LargeObjectException) {
-        PatchListLoader.logger.atWarning().withCause(e).log("Error computing %s", key);
+        logger.atWarning().withCause(e).log("Error computing %s", key);
         throw new PatchListNotAvailableException(e);
       }
       throw e;
     }
   }
-
-  /** Used to cache negative results in {@code fileCache}. */
-  @VisibleForTesting
-  public static class LargeObjectTombstone extends PatchList {
-    private static final long serialVersionUID = 1L;
-
-    @VisibleForTesting
-    public LargeObjectTombstone() {
-      // Initialize super class with valid values. We don't care about the inner state, but need to
-      // pass valid values that don't break (de)serialization.
-      super(
-          null, ObjectId.zeroId(), false, ComparisonType.againstAutoMerge(), new PatchListEntry[0]);
-    }
-  }
 }
diff --git a/java/com/google/gerrit/server/patch/PatchListWeigher.java b/java/com/google/gerrit/server/patch/PatchListWeigher.java
deleted file mode 100644
index 942d0e0..0000000
--- a/java/com/google/gerrit/server/patch/PatchListWeigher.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.common.cache.Weigher;
-
-/** Approximates memory usage for PatchList in bytes of memory used. */
-public class PatchListWeigher implements Weigher<PatchListKey, PatchList> {
-  @Override
-  public int weigh(PatchListKey key, PatchList value) {
-    int size =
-        16
-            + 4 * 8
-            + 2 * 36
-            + 8 // Size of PatchListKey, 64 bit JVM
-            + 16
-            + 3 * 8
-            + 3 * 4
-            + 20; // Size of PatchList, 64 bit JVM
-    for (PatchListEntry e : value.getPatches()) {
-      size += e.weigh();
-    }
-    return size;
-  }
-}
diff --git a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 5998bba..33300e3 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -71,28 +71,8 @@
     intralineDiffCalculator = calculator;
   }
 
-  /** Convert into {@link PatchScript} using the old diff cache output. */
-  PatchScript toPatchScriptOld(Repository git, PatchList list, PatchListEntry content)
-      throws IOException {
-
-    PatchFileChange change =
-        new PatchFileChange(
-            content.getEdits(),
-            content.getEditsDueToRebase(),
-            content.getHeaderLines(),
-            content.getOldName(),
-            content.getNewName(),
-            content.getChangeType(),
-            content.getPatchType());
-    SidesResolver sidesResolver = new SidesResolver(git, list.getComparisonType());
-    ResolvedSides sides =
-        resolveSides(
-            git, sidesResolver, oldName(change), newName(change), list.getOldId(), list.getNewId());
-    return build(sides.a, sides.b, change);
-  }
-
   /** Convert into {@link PatchScript} using the new diff cache output. */
-  PatchScript toPatchScriptNew(Repository git, FileDiffOutput content) throws IOException {
+  PatchScript toPatchScript(Repository git, FileDiffOutput content) throws IOException {
     PatchFileChange change =
         new PatchFileChange(
             content.edits().stream().map(TaggedEdit::jgitEdit).collect(toImmutableList()),
@@ -235,10 +215,10 @@
         return null;
       case DELETED:
       case MODIFIED:
-      case REWRITE:
         return entry.getNewName();
       case COPIED:
       case RENAMED:
+      case REWRITE:
       default:
         return entry.getOldName();
     }
diff --git a/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 885459a..02f125a 100644
--- a/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -26,20 +26,13 @@
 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.metrics.Counter1;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LargeObjectException;
-import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchScriptBuilder.IntraLineDiffCalculatorResult;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
@@ -50,23 +43,15 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 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 com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
-import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import org.apache.commons.lang.exception.ExceptionUtils;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 
@@ -93,33 +78,10 @@
         CurrentUser currentUser);
   }
 
-  /** These metrics are temporary for launching the new redesigned diff cache. */
-  @Singleton
-  static class Metrics {
-    final Counter1<String> diffs;
-    static final String MATCH = "match";
-    static final String MISMATCH = "mismatch";
-    static final String ERROR = "error";
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      diffs =
-          metricMaker.newCounter(
-              "diff/get_diff/dark_launch",
-              new Description(
-                      "Total number of matching, non-matching, or error in diffs in the old and new diff cache implementations.")
-                  .setRate()
-                  .setUnit("count"),
-              Field.ofString("type", Metadata.Builder::eventType).build());
-    }
-  }
-
   private final GitRepositoryManager repoManager;
   private final PatchSetUtil psUtil;
   private final Provider<PatchScriptBuilder> builderFactory;
   private final PatchListCache patchListCache;
-  private final Metrics metrics;
-  private final ExecutorService executor;
 
   private final String fileName;
   @Nullable private final PatchSet.Id psa;
@@ -137,8 +99,6 @@
 
   private ChangeNotes notes;
 
-  private final boolean runNewDiffCache;
-
   @AssistedInject
   PatchScriptFactory(
       GitRepositoryManager grm,
@@ -149,9 +109,6 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       DiffOperations diffOperations,
-      Metrics metrics,
-      @DiffExecutor ExecutorService executor,
-      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
@@ -167,18 +124,13 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.diffOperations = diffOperations;
-    this.metrics = metrics;
-    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = patchSetA;
-    this.parentNum = -1;
+    this.parentNum = 0;
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
-
-    this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
-
     changeId = patchSetB.changeId();
   }
 
@@ -192,9 +144,6 @@
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       DiffOperations diffOperations,
-      Metrics metrics,
-      @DiffExecutor ExecutorService executor,
-      @GerritServerConfig Config cfg,
       @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
@@ -210,8 +159,6 @@
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.diffOperations = diffOperations;
-    this.metrics = metrics;
-    this.executor = executor;
 
     this.fileName = fileName;
     this.psa = null;
@@ -219,11 +166,8 @@
     this.psb = patchSetB;
     this.diffPrefs = diffPrefs;
     this.currentUser = currentUser;
-
-    this.runNewDiffCache = cfg.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
-
     changeId = patchSetB.changeId();
-    checkArgument(parentNum >= 0, "parentNum must be >= 0");
+    checkArgument(parentNum > 0, "parentNum must be > 0");
   }
 
   @Override
@@ -259,17 +203,7 @@
           }
           bId = edit.get().getEditCommit();
         }
-        if (runNewDiffCache) {
-          PatchScript patchScript = getPatchScriptWithNewDiffCache(git, aId, bId);
-          // TODO(ghareeb): remove the async run. This is temporarily used to keep sanity checking
-          // the results while rolling out the new diff cache.
-          runOldDiffCacheAsyncAndExportMetrics(git, aId, bId, patchScript);
-          return patchScript;
-        } else {
-          return getPatchScriptWithOldDiffCache(git, aId, bId);
-        }
-      } catch (PatchListNotAvailableException e) {
-        throw new NoSuchChangeException(changeId, e);
+        return getPatchScript(git, aId, bId);
       } catch (DiffNotAvailableException e) {
         throw new StorageException(e);
       } catch (IOException e) {
@@ -287,116 +221,22 @@
     }
   }
 
-  private void runOldDiffCacheAsyncAndExportMetrics(
-      Repository git, ObjectId aId, ObjectId bId, PatchScript expected) {
-    @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError =
-        executor.submit(
-            () -> {
-              try {
-                PatchScript patchScript = getPatchScriptWithOldDiffCache(git, aId, bId);
-                if (areEqualPatchscripts(patchScript, expected)) {
-                  metrics.diffs.increment(Metrics.MATCH);
-                } else {
-                  metrics.diffs.increment(Metrics.MISMATCH);
-                  logger.atWarning().atMostEvery(10, TimeUnit.SECONDS).log(
-                      "Mismatching diff for change %s, old commit ID: %s, new commit ID: %s, file name: %s.",
-                      changeId.toString(), aId, bId, fileName);
-                }
-              } catch (PatchListNotAvailableException | IOException e) {
-                metrics.diffs.increment(Metrics.ERROR);
-                logger.atSevere().atMostEvery(10, TimeUnit.SECONDS).log(
-                    String.format(
-                            "Error computing new diff for change %s, old commit ID: %s, new commit ID: %s.\n",
-                            changeId.toString(), aId, bId)
-                        + ExceptionUtils.getStackTrace(e));
-              }
-            });
-  }
-
-  private PatchScript getPatchScriptWithOldDiffCache(Repository git, ObjectId aId, ObjectId bId)
-      throws IOException, PatchListNotAvailableException {
-    PatchScriptBuilder patchScriptBuilder = newBuilder();
-    PatchList list = listFor(keyFor(aId, bId, diffPrefs.ignoreWhitespace));
-    PatchListEntry content = list.get(fileName);
-    return patchScriptBuilder.toPatchScriptOld(git, list, content);
-  }
-
-  private PatchScript getPatchScriptWithNewDiffCache(Repository git, ObjectId aId, ObjectId bId)
+  private PatchScript getPatchScript(Repository git, ObjectId aId, ObjectId bId)
       throws IOException, DiffNotAvailableException {
     FileDiffOutput fileDiffOutput =
         aId == null
             ? diffOperations.getModifiedFileAgainstParent(
-                notes.getProjectName(),
-                bId,
-                parentNum == -1 ? null : parentNum + 1,
-                fileName,
-                diffPrefs.ignoreWhitespace)
+                notes.getProjectName(), bId, parentNum, fileName, diffPrefs.ignoreWhitespace)
             : diffOperations.getModifiedFile(
                 notes.getProjectName(), aId, bId, fileName, diffPrefs.ignoreWhitespace);
-    return newBuilder().toPatchScriptNew(git, fileDiffOutput);
-  }
-
-  /**
-   * The comparison is not exhaustive but is using the most important fields. Comparing all fields
-   * will require some work in {@link PatchScript} to, e.g., convert it to autovalue. This
-   * comparison method shall give a strong signal that both patchscripts are almost identical.
-   */
-  private static boolean areEqualPatchscripts(PatchScript ps1, PatchScript ps2) {
-    boolean equal = true;
-    if (!ps1.getChangeType().equals(ps2.getChangeType())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching change type: old = %s, new = %s.", ps1.getChangeType(), ps2.getChangeType());
-    }
-    if (!ps1.getPatchHeader().equals(ps2.getPatchHeader())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching patch header: old = %s, new = %s.",
-          ps1.getPatchHeader(), ps2.getPatchHeader());
-    }
-    if (!Objects.equals(ps1.getOldName(), ps2.getOldName())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching old name: old = %s, new = %s.", ps1.getOldName(), ps2.getOldName());
-    }
-    if (!Objects.equals(ps1.getNewName(), ps2.getNewName())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching new name: old = %s, new = %s.", ps1.getNewName(), ps2.getNewName());
-    }
-    if (!ps1.getEdits().containsAll(ps2.getEdits())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching edits: old = %s, new = %s.", ps1.getEdits(), ps2.getEdits());
-    }
-    if (!ps2.getEdits().containsAll(ps1.getEdits())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching edits: old = %s, new = %s.", ps1.getEdits(), ps2.getEdits());
-    }
-    if (!ps1.getEditsDueToRebase().equals(ps2.getEditsDueToRebase())) {
-      equal = false;
-      logger.atWarning().log(
-          "Mismatching edits due to rebase: old = %s, new = %s.",
-          ps1.getEditsDueToRebase(), ps2.getEditsDueToRebase());
-    }
-    if (!ps1.getA().equals(ps2.getA())) {
-      equal = false;
-      logger.atWarning().log("Mismatching sparse file content in old commit.");
-    }
-    if (!ps1.getB().equals(ps2.getB())) {
-      equal = false;
-      logger.atWarning().log("Mismatching sparse file content in new commit.");
-    }
-    return equal;
+    return newBuilder().toPatchScript(git, fileDiffOutput);
   }
 
   private Optional<ObjectId> getAId() {
     if (psa == null) {
       return Optional.empty();
     }
-    checkState(parentNum < 0, "expected no parentNum when psa is present");
+    checkState(parentNum == 0, "expected no parentNum when psa is present");
     checkArgument(psa.get() != 0, "edit not supported for left side");
     return Optional.of(getCommitId(psa));
   }
@@ -409,17 +249,6 @@
     return Optional.of(getCommitId(psb));
   }
 
-  private PatchListKey keyFor(ObjectId aId, ObjectId bId, Whitespace whitespace) {
-    if (parentNum < 0) {
-      return PatchListKey.againstCommit(aId, bId, whitespace);
-    }
-    return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
-  }
-
-  private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
-    return patchListCache.get(key, notes.getProjectName());
-  }
-
   private PatchScriptBuilder newBuilder() {
     final PatchScriptBuilder b = builderFactory.get();
     b.setDiffPrefs(diffPrefs);
diff --git a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
index 18d532b..572d73d 100644
--- a/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
+++ b/java/com/google/gerrit/server/patch/SubmitWithStickyApprovalDiff.java
@@ -16,8 +16,10 @@
 
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.entities.PatchSet;
@@ -27,23 +29,32 @@
 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.diff.DiffInfoCreator;
+import com.google.gerrit.server.git.GitRepositoryManager;
 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.patch.filediff.FileDiffOutput;
 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.ArrayList;
+import java.util.Arrays;
 import java.util.List;
+import java.util.Map;
+import java.util.Optional;
 import java.util.stream.Collectors;
-import org.eclipse.jgit.diff.Edit;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.internal.JGitText;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.eclipse.jgit.util.TemporaryBuffer;
 
 /**
  * This class is used on submit to compute the diff between the latest approved patch-set, and the
@@ -58,20 +69,25 @@
  * <p>We exclude the magic files from the returned diff to make it shorter and more concise.
  */
 public class SubmitWithStickyApprovalDiff {
+  private static final int HEAP_EST_SIZE = 32 * 1024;
+
+  private final DiffOperations diffOperations;
   private final ProjectCache projectCache;
   private final PatchScriptFactory.Factory patchScriptFactoryFactory;
-  private final PatchListCache patchListCache;
+  private final GitRepositoryManager repositoryManager;
   private final int maxCumulativeSize;
 
   @Inject
   SubmitWithStickyApprovalDiff(
+      DiffOperations diffOperations,
       ProjectCache projectCache,
       PatchScriptFactory.Factory patchScriptFactoryFactory,
-      PatchListCache patchListCache,
+      GitRepositoryManager repositoryManager,
       @GerritServerConfig Config serverConfig) {
+    this.diffOperations = diffOperations;
     this.projectCache = projectCache;
     this.patchScriptFactoryFactory = patchScriptFactoryFactory;
-    this.patchListCache = patchListCache;
+    this.repositoryManager = repositoryManager;
     maxCumulativeSize =
         serverConfig.getInt(
             "change",
@@ -82,16 +98,7 @@
   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 currentPatchset = notes.getCurrentPatchSet();
 
     PatchSet.Id latestApprovedPatchsetId = getLatestApprovedPatchsetId(notes);
     if (latestApprovedPatchsetId.get() == currentPatchset.id().get()) {
@@ -102,40 +109,58 @@
         new StringBuilder(
             String.format(
                 "\n\n%d is the latest approved patch-set.\n", latestApprovedPatchsetId.get()));
-    PatchList patchList =
-        getPatchList(
+    Map<String, FileDiffOutput> modifiedFiles =
+        listModifiedFiles(
             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()))
+    List<FileDiffOutput> modifiedFilesList =
+        modifiedFiles.values().stream()
+            .filter(p -> !Patch.isMagic(p.newPath().orElse("")))
             .collect(Collectors.toList());
 
-    if (patchListEntryList.isEmpty()) {
+    if (modifiedFilesList.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());
+    TemporaryBuffer.Heap buffer =
+        new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxCumulativeSize), maxCumulativeSize);
+    try (Repository repository = repositoryManager.openRepository(notes.getProjectName());
+        DiffFormatter formatter = new DiffFormatter(buffer)) {
+      formatter.setRepository(repository);
+      formatter.setDetectRenames(true);
+      boolean isDiffTooLarge = false;
+      List<String> formatterResult = null;
+      try {
+        formatter.format(
+            modifiedFilesList.get(0).oldCommitId(), modifiedFilesList.get(0).newCommitId());
+        // This returns the diff for all the files.
+        formatterResult =
+            Arrays.stream(RawParseUtils.decode(buffer.toByteArray()).split("\n"))
+                .collect(Collectors.toList());
+      } catch (IOException e) {
+        if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
+          isDiffTooLarge = true;
+        } else {
+          throw e;
+        }
+      }
+      for (FileDiffOutput fileDiff : modifiedFilesList) {
+        diff.append(
+            getDiffForFile(
+                notes,
+                currentPatchset.id(),
+                latestApprovedPatchsetId,
+                fileDiff,
+                currentUser,
+                formatterResult,
+                isDiffTooLarge));
+      }
     }
     return diff.toString();
   }
@@ -144,28 +169,35 @@
       ChangeNotes notes,
       PatchSet.Id currentPatchsetId,
       PatchSet.Id latestApprovedPatchsetId,
-      PatchListEntry patchListEntry,
-      CurrentUser currentUser)
+      FileDiffOutput fileDiffOutput,
+      CurrentUser currentUser,
+      @Nullable List<String> formatterResult,
+      boolean isDiffTooLarge)
       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()));
+                "```\nThe name of the file: %s\nInsertions: %d, Deletions: %d.\n\n",
+                fileDiffOutput.newPath().isPresent()
+                    ? fileDiffOutput.newPath().get()
+                    : fileDiffOutput.oldPath().get(),
+                fileDiffOutput.insertions(),
+                fileDiffOutput.deletions()));
     DiffPreferencesInfo diffPreferencesInfo = createDefaultDiffPreferencesInfo();
     PatchScriptFactory patchScriptFactory =
         patchScriptFactoryFactory.create(
             notes,
-            patchListEntry.getNewName(),
+            fileDiffOutput.newPath().isPresent()
+                ? fileDiffOutput.newPath().get()
+                : fileDiffOutput.oldPath().get(),
             latestApprovedPatchsetId,
             currentPatchsetId,
             diffPreferencesInfo,
             currentUser);
     PatchScript patchScript = null;
     try {
+      // TODO(paiking): we can get rid of this call to optimize by checking the diff for renames.
       patchScript = patchScriptFactory.call();
     } catch (LargeObjectException exception) {
       diff.append("The file content is too large for showing the full diff. \n\n");
@@ -175,59 +207,62 @@
       diff.append(
           String.format(
               "The file %s was renamed to %s\n",
-              patchListEntry.getOldName(), patchListEntry.getNewName()));
+              fileDiffOutput.oldPath().get(), fileDiffOutput.newPath().get()));
     }
-    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;
+    if (isDiffTooLarge) {
+      diff.append("The diff is too large to show. Please review the diff.");
+      diff.append("\n```\n");
+      return diff.toString();
     }
-    for (Edit edit : patchScript.getEdits()) {
-      diff.append(getDiffForEdit(fileA, fileB, edit));
-    }
-    if (editsExist) {
-      diff.append("```\n");
-    }
+    // This filters only the file we need.
+    // TODO(paiking): we can make this more efficient by mapping the files to their respective
+    //  diffs prior to this method, such that we need to go over the diff only once.
+    diff.append(getDiffForFile(patchScript, formatterResult));
+    // This line (and the ``` above) are useful for formatting in the web UI.
+    diff.append("\n```\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.
+  /**
+   * Show patch set as unified difference for a specific file. We on purpose are not using {@link
+   * DiffInfoCreator} since we'd like to get the original git/JGit style diff.
+   */
+  public String getDiffForFile(PatchScript patchScript, List<String> formatterResult) {
+    // only return information about the current file, and not about files that are not
+    // relevant. DiffFormatter returns other potential files because of rebases, which we can
+    // ignore.
+    List<String> modifiedFormatterResult = new ArrayList<>();
+    int indexOfFormatterResult = 0;
+    while (formatterResult.size() > indexOfFormatterResult
+        && !formatterResult
+            .get(indexOfFormatterResult)
+            .equals(
+                String.format(
+                    "diff --git a/%s b/%s",
+                    patchScript.getOldName() != null
+                        ? patchScript.getOldName()
+                        : patchScript.getNewName(),
+                    patchScript.getNewName()))) {
+      indexOfFormatterResult++;
     }
-    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)));
+    // remove non user friendly information.
+    while (formatterResult.size() > indexOfFormatterResult
+        && !formatterResult.get(indexOfFormatterResult).startsWith("@@")) {
+      indexOfFormatterResult++;
     }
-    return diff.toString();
+    for (; indexOfFormatterResult < formatterResult.size(); indexOfFormatterResult++) {
+      if (formatterResult.get(indexOfFormatterResult).startsWith("diff --git")) {
+        break;
+      }
+      modifiedFormatterResult.add(formatterResult.get(indexOfFormatterResult));
+    }
+    if (modifiedFormatterResult.size() == 0) {
+      // This happens for diffs that are just renames, but we already account for renames.
+      return "";
+    }
+    return modifiedFormatterResult.stream()
+        .filter(s -> !s.equals("\\ No newline at end of file"))
+        .collect(Collectors.joining("\n"));
   }
 
   private DiffPreferencesInfo createDefaultDiffPreferencesInfo() {
@@ -245,10 +280,9 @@
       if (!patchSetApproval.label().equals(LabelId.CODE_REVIEW)) {
         continue;
       }
-      if (!projectState
-          .getLabelTypes(notes)
-          .byLabel(patchSetApproval.labelId())
-          .isMaxPositive(patchSetApproval)) {
+      Optional<LabelType> lt =
+          projectState.getLabelTypes(notes).byLabel(patchSetApproval.labelId());
+      if (!lt.isPresent() || !lt.get().isMaxPositive(patchSetApproval)) {
         continue;
       }
       if (patchSetApproval.patchSetId().get() > maxPatchSetId.get()) {
@@ -259,16 +293,14 @@
   }
 
   /**
-   * Gets the {@link PatchList} between the two latest patch-sets. Can be used to compute difference
-   * in files between those two patch-sets .
+   * Gets the list of modified files 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);
+  private Map<String, FileDiffOutput> listModifiedFiles(
+      Project.NameKey project, PatchSet ps, PatchSet priorPatchSet) {
     try {
-      return patchListCache.get(key, project);
-    } catch (PatchListNotAvailableException ex) {
+      return diffOperations.listModifiedFiles(project, priorPatchSet.commitId(), ps.commitId());
+    } catch (DiffNotAvailableException 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.",
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
index bcae238..76d1710 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCache.java
@@ -28,16 +28,17 @@
  * 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
+ * org.eclipse.jgit.lib.ObjectId#zeroId()}, 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 {
 
   /**
+   * Returns the list of {@link ModifiedFile}s between the 2 git commits identified by the key
+   *
    * @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.
    */
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
index 6023c0e..460c2e2 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheImpl.java
@@ -15,15 +15,17 @@
 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.ArrayListMultimap;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Patch.ChangeType;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -34,9 +36,11 @@
 import com.google.gerrit.server.patch.gitdiff.ModifiedFile;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Stream;
@@ -51,11 +55,11 @@
  * <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)}
+ * <p>If the {@link ModifiedFilesCacheKey#aCommit()} is equal to {@link ObjectId#zeroId()}, 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)}
  */
+@Singleton
 public class ModifiedFilesCacheImpl implements ModifiedFilesCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -82,7 +86,7 @@
             .valueSerializer(GitModifiedFilesCacheImpl.ValueSerializer.INSTANCE)
             .maximumWeight(10 << 20)
             .weigher(ModifiedFilesWeigher.class)
-            .version(1)
+            .version(3)
             .loader(ModifiedFilesLoader.class);
       }
     };
@@ -128,7 +132,7 @@
     private ImmutableList<ModifiedFile> loadModifiedFiles(ModifiedFilesCacheKey key, RevWalk rw)
         throws IOException, DiffNotAvailableException {
       ObjectId aTree =
-          key.aCommit().equals(EMPTY_TREE_ID)
+          key.aCommit().equals(ObjectId.zeroId())
               ? key.aCommit()
               : DiffUtil.getTreeId(rw, key.aCommit());
       ObjectId bTree = DiffUtil.getTreeId(rw, key.bCommit());
@@ -139,8 +143,8 @@
               .bTree(bTree)
               .renameScore(key.renameScore())
               .build();
-      List<ModifiedFile> modifiedFiles = gitCache.get(gitKey);
-      if (key.aCommit().equals(EMPTY_TREE_ID)) {
+      List<ModifiedFile> modifiedFiles = mergeRewrittenEntries(gitCache.get(gitKey));
+      if (key.aCommit().equals(ObjectId.zeroId())) {
         return ImmutableList.copyOf(modifiedFiles);
       }
       RevCommit revCommitA = DiffUtil.getRevCommit(rw, key.aCommit());
@@ -202,5 +206,37 @@
       // value as the set of file paths shouldn't contain it.
       return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
     }
+
+    /**
+     * Return the {@code modifiedFiles} input list while merging rewritten entries.
+     *
+     * <p>Background: In some cases, JGit returns two diff entries (ADDED/DELETED, RENAMED/DELETED,
+     * etc...) for the same file path. This happens e.g. when a file's mode is changed between
+     * patchsets, for example converting a symlink file to a regular file. We identify this case and
+     * return a single modified file with changeType = {@link ChangeType#REWRITE}.
+     */
+    private static List<ModifiedFile> mergeRewrittenEntries(List<ModifiedFile> modifiedFiles) {
+      List<ModifiedFile> result = new ArrayList<>();
+      ListMultimap<String, ModifiedFile> byPath = ArrayListMultimap.create();
+      modifiedFiles.stream()
+          .forEach(
+              f -> {
+                if (f.changeType() == ChangeType.DELETED) {
+                  byPath.get(f.oldPath().get()).add(f);
+                } else {
+                  byPath.get(f.newPath().get()).add(f);
+                }
+              });
+      for (String path : byPath.keySet()) {
+        List<ModifiedFile> entries = byPath.get(path);
+        if (entries.size() == 1) {
+          result.add(entries.get(0));
+        } else {
+          // More than one. Return a single REWRITE entry.
+          result.add(entries.get(0).toBuilder().changeType(ChangeType.REWRITE).build());
+        }
+      }
+      return result;
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
index 2ac3f5e..4a406c8 100644
--- a/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
+++ b/java/com/google/gerrit/server/patch/diff/ModifiedFilesCacheKey.java
@@ -32,10 +32,10 @@
   /** A specific git project / repository. */
   public abstract Project.NameKey project();
 
-  /** @return the old commit ID used in the git tree diff */
+  /** Returns 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 */
+  /** Returns the new commit ID used in the git tree diff */
   public abstract ObjectId bCommit();
 
   /**
diff --git a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
index 12decc3..0923252 100644
--- a/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
+++ b/java/com/google/gerrit/server/patch/filediff/AllDiffsEvaluator.java
@@ -14,8 +14,6 @@
 
 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;
@@ -203,7 +201,7 @@
       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);
+        aCommit.equals(ObjectId.zeroId()) ? ObjectId.zeroId() : DiffUtil.getTreeId(rw, aCommit);
     ObjectId newTreeId = DiffUtil.getTreeId(rw, bCommit);
     return GitFileDiffCacheKey.builder()
         .project(key.project())
@@ -213,6 +211,7 @@
         .renameScore(key.renameScore())
         .diffAlgorithm(key.diffAlgorithm())
         .whitespace(key.whitespace())
+        .useTimeout(key.useTimeout())
         .build();
   }
 }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
index 395312f..92c3b39 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheImpl.java
@@ -16,7 +16,6 @@
 
 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;
@@ -26,11 +25,15 @@
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Streams;
 import com.google.common.flogger.FluentLogger;
+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.Whitespace;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
+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.patch.AutoMerger;
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
@@ -43,6 +46,7 @@
 import com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.DiffAlgorithmFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -71,10 +75,10 @@
  * 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.
+ * <p>If the {@link FileDiffCacheKey#oldCommit()} is equal to {@link ObjectId#zeroId()}, the git
+ * diff will be evaluated against the empty tree.
  */
+@Singleton
 public class FileDiffCacheImpl implements FileDiffCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
@@ -93,7 +97,7 @@
         persist(DIFF, FileDiffCacheKey.class, FileDiffOutput.class)
             .maximumWeight(10 << 20)
             .weigher(FileDiffWeigher.class)
-            .version(4)
+            .version(8)
             .keySerializer(FileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(FileDiffOutput.Serializer.INSTANCE)
             .loader(FileDiffLoader.class);
@@ -151,44 +155,56 @@
 
     @Override
     public FileDiffOutput load(FileDiffCacheKey key) throws IOException, DiffNotAvailableException {
-      return loadAll(ImmutableList.of(key)).get(key);
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading a single key from file diff cache",
+              Metadata.builder().filePath(key.newFilePath()).build())) {
+        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();
+      try (TraceTimer timer = TraceContext.newTimer("Loading multiple keys from file diff cache")) {
+        ImmutableMap.Builder<FileDiffCacheKey, FileDiffOutput> result = ImmutableMap.builder();
 
-      Map<Project.NameKey, List<FileDiffCacheKey>> keysByProject =
-          Streams.stream(keys).distinct().collect(Collectors.groupingBy(FileDiffCacheKey::project));
+        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<>();
+        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)) {
+          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);
+            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());
           }
-          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();
       }
-      return result.build();
     }
 
     private ComparisonType getComparisonType(
         RevWalk rw, ObjectReader reader, ObjectId oldCommitId, ObjectId newCommitId)
         throws IOException {
+      if (oldCommitId.equals(ObjectId.zeroId())) {
+        return ComparisonType.againstRoot();
+      }
       RevCommit oldCommit = DiffUtil.getRevCommit(rw, oldCommitId);
       RevCommit newCommit = DiffUtil.getRevCommit(rw, newCommitId);
       for (int i = 0; i < newCommit.getParentCount(); i++) {
@@ -211,7 +227,7 @@
     }
 
     /**
-     * Creates a {@link FileDiffOutput} entry for the "Commit message" and "Merge list" file paths.
+     * Creates a {@link FileDiffOutput} entry for the "Commit message" or "Merge list" magic paths.
      */
     private FileDiffOutput createMagicPathEntry(
         FileDiffCacheKey key, ObjectReader reader, RevWalk rw, MagicPath magicPath) {
@@ -219,7 +235,10 @@
         RawTextComparator cmp = comparatorFor(key.whitespace());
         ComparisonType comparisonType =
             getComparisonType(rw, reader, key.oldCommit(), key.newCommit());
-        RevCommit aCommit = DiffUtil.getRevCommit(rw, key.oldCommit());
+        RevCommit aCommit =
+            key.oldCommit().equals(ObjectId.zeroId())
+                ? null
+                : DiffUtil.getRevCommit(rw, key.oldCommit());
         RevCommit bCommit = DiffUtil.getRevCommit(rw, key.newCommit());
         return magicPath == MagicPath.COMMIT
             ? createCommitEntry(reader, aCommit, bCommit, comparisonType, cmp, key.diffAlgorithm())
@@ -248,16 +267,19 @@
       }
     }
 
+    /**
+     * Creates a commit entry. {@code oldCommit} is null if the comparison is against a root commit.
+     */
     private FileDiffOutput createCommitEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forCommit(reader, oldCommit);
       Text bText = Text.forCommit(reader, newCommit);
@@ -272,16 +294,20 @@
           diffAlgorithm);
     }
 
+    /**
+     * Creates a merge list entry. {@code oldCommit} is null if the comparison is against a root
+     * commit.
+     */
     private FileDiffOutput createMergeListEntry(
         ObjectReader reader,
-        RevCommit oldCommit,
+        @Nullable RevCommit oldCommit,
         RevCommit newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
         GitFileDiffCacheImpl.DiffAlgorithm diffAlgorithm)
         throws IOException {
       Text aText =
-          comparisonType.isAgainstParentOrAutoMerge()
+          oldCommit == null || comparisonType.isAgainstParentOrAutoMerge()
               ? Text.EMPTY
               : Text.forMergeList(comparisonType, reader, oldCommit);
       Text bText = Text.forMergeList(comparisonType, reader, newCommit);
@@ -297,7 +323,7 @@
     }
 
     private static FileDiffOutput createMagicFileDiffOutput(
-        ObjectId oldCommit,
+        @Nullable ObjectId oldCommit,
         ObjectId newCommit,
         ComparisonType comparisonType,
         RawTextComparator rawTextComparator,
@@ -317,7 +343,7 @@
       FileHeader fileHeader = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
       Patch.ChangeType changeType = FileHeaderUtil.getChangeType(fileHeader);
       return FileDiffOutput.builder()
-          .oldCommitId(oldCommit)
+          .oldCommitId(oldCommit == null ? ObjectId.zeroId() : oldCommit)
           .newCommitId(newCommit)
           .comparisonType(comparisonType)
           .oldPath(FileHeaderUtil.getOldPath(fileHeader))
@@ -364,6 +390,19 @@
 
       for (AugmentedFileDiffCacheKey augmentedKey : allFileDiffs.keySet()) {
         AllFileGitDiffs allDiffs = allFileDiffs.get(augmentedKey);
+        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
+
+        if (mainGitDiff.isNegative()) {
+          // If the result of the git diff computation was negative, i.e. due to timeout, cache a
+          // negative result.
+          result.put(
+              augmentedKey.key(),
+              FileDiffOutput.createNegative(
+                  mainGitDiff.newPath().orElse(""),
+                  augmentedKey.key().oldCommit(),
+                  augmentedKey.key().newCommit()));
+          continue;
+        }
 
         FileEdits rebaseFileEdits = FileEdits.empty();
         if (!augmentedKey.ignoreRebase()) {
@@ -371,12 +410,13 @@
         }
         List<Edit> rebaseEdits = rebaseFileEdits.edits();
 
-        RevTree aTree = rw.parseTree(allDiffs.mainDiff().gitKey().oldTree());
+        ObjectId oldTreeId = allDiffs.mainDiff().gitKey().oldTree();
+
+        RevTree aTree = oldTreeId.equals(ObjectId.zeroId()) ? null : rw.parseTree(oldTreeId);
         RevTree bTree = rw.parseTree(allDiffs.mainDiff().gitKey().newTree());
-        GitFileDiff mainGitDiff = allDiffs.mainDiff().gitDiff();
 
         Long oldSize =
-            mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
+            aTree != null && mainGitDiff.oldMode().isPresent() && mainGitDiff.oldPath().isPresent()
                 ? new FileSizeEvaluator(reader, aTree)
                     .compute(
                         mainGitDiff.oldId(),
@@ -425,7 +465,7 @@
     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)) {
+        if (key.oldCommit().equals(ObjectId.zeroId())) {
           result.add(AugmentedFileDiffCacheKey.builder().key(key).ignoreRebase(true).build());
           continue;
         }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
index a478fcf..5880b65 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffCacheKey.java
@@ -35,7 +35,10 @@
   /** 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. */
+  /**
+   * The 20 bytes SHA-1 commit ID of the old commit used in the diff. If set to {@link
+   * ObjectId#zeroId()}, an empty tree is used for the diff.
+   */
   public abstract ObjectId oldCommit();
 
   /** The 20 bytes SHA-1 commit ID of the new commit used in the diff. */
@@ -55,6 +58,9 @@
 
   public abstract DiffPreferencesInfo.Whitespace whitespace();
 
+  /** Employ a timeout on the git computation while formatting the file header. */
+  public abstract boolean useTimeout();
+
   /** Number of bytes that this entity occupies. */
   public int weight() {
     return stringSize(project().get())
@@ -62,13 +68,16 @@
         + stringSize(newFilePath())
         + 4 // renameScore
         + 4 // diffAlgorithm
-        + 4; // whitespace
+        + 4 // whitespace
+        + 1; // useTimeout
   }
 
   public static FileDiffCacheKey.Builder builder() {
     return new AutoValue_FileDiffCacheKey.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -91,6 +100,8 @@
 
     public abstract FileDiffCacheKey.Builder whitespace(Whitespace value);
 
+    public abstract FileDiffCacheKey.Builder useTimeout(boolean value);
+
     public abstract FileDiffCacheKey build();
   }
 
@@ -109,6 +120,7 @@
               .setRenameScore(key.renameScore())
               .setDiffAlgorithm(key.diffAlgorithm().name())
               .setWhitespace(key.whitespace().name())
+              .setUseTimeout(key.useTimeout())
               .build());
     }
 
@@ -124,6 +136,7 @@
           .renameScore(proto.getRenameScore())
           .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
           .whitespace(Whitespace.valueOf(proto.getWhitespace()))
+          .useTimeout(proto.getUseTimeout())
           .build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
index e7f47ef..242c1a4 100644
--- a/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
+++ b/java/com/google/gerrit/server/patch/filediff/FileDiffOutput.java
@@ -37,7 +37,10 @@
 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. */
+  /**
+   * The 20 bytes SHA-1 object ID of the old git commit used in the diff, or {@link
+   * ObjectId#zeroId()} if {@link #newCommitId()} was a root commit.
+   */
   public abstract ObjectId oldCommitId();
 
   /** The 20 bytes SHA-1 object ID of the new git commit used in the diff. */
@@ -79,6 +82,14 @@
   /** Difference in file size between the old and new commits. */
   public abstract long sizeDelta();
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff, i.e. for diffs
+   * taking a very long time to compute. We cache negative result in this case.
+   */
+  public abstract Optional<Boolean> negative();
+
+  public abstract Builder toBuilder();
+
   /** 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);
@@ -122,11 +133,31 @@
         .build();
   }
 
+  /**
+   * Create a negative file diff. We use this to cache negative diffs for entries that result in
+   * timeouts.
+   */
+  public static FileDiffOutput createNegative(
+      String filePath, ObjectId oldCommitId, ObjectId newCommitId) {
+    return empty(filePath, oldCommitId, newCommitId)
+        .toBuilder()
+        .negative(Optional.of(true))
+        .build();
+  }
+
   /** Returns true if this entity represents an unchanged file between two commits. */
   public boolean isEmpty() {
     return headerLines().isEmpty() && edits().isEmpty();
   }
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
+   * result in this case.
+   */
+  public boolean isNegative() {
+    return negative().isPresent() && negative().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_FileDiffOutput.Builder();
   }
@@ -151,6 +182,9 @@
     for (String s : headerLines()) {
       s += stringSize(s);
     }
+    if (negative().isPresent()) {
+      result += 1;
+    }
     return result;
   }
 
@@ -179,6 +213,8 @@
 
     public abstract Builder sizeDelta(long value);
 
+    public abstract Builder negative(Optional<Boolean> value);
+
     public abstract FileDiffOutput build();
   }
 
@@ -194,6 +230,9 @@
     private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
         FileDiffOutputProto.getDescriptor().findFieldByNumber(4);
 
+    private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
+        FileDiffOutputProto.getDescriptor().findFieldByNumber(12);
+
     @Override
     public byte[] serialize(FileDiffOutput fileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -234,6 +273,10 @@
         builder.setPatchType(fileDiff.patchType().get().name());
       }
 
+      if (fileDiff.negative().isPresent()) {
+        builder.setNegative(fileDiff.negative().get());
+      }
+
       return Protos.toByteArray(builder.build());
     }
 
@@ -272,6 +315,9 @@
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
         builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
       }
+      if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
+        builder.negative(Optional.of(proto.getNegative()));
+      }
       return builder.build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java b/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
deleted file mode 100644
index 017e276..0000000
--- a/java/com/google/gerrit/server/patch/filediff/PatchListLoader.java
+++ /dev/null
@@ -1,680 +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.patch.filediff;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toSet;
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableMultimap;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.Multimap;
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.entities.Patch;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.server.config.ConfigUtil;
-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.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;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.TimeoutException;
-import org.eclipse.jgit.diff.DiffEntry;
-import org.eclipse.jgit.diff.DiffEntry.ChangeType;
-import org.eclipse.jgit.diff.DiffFormatter;
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.diff.EditList;
-import org.eclipse.jgit.diff.HistogramDiff;
-import org.eclipse.jgit.diff.RawText;
-import org.eclipse.jgit.diff.RawTextComparator;
-import org.eclipse.jgit.lib.AbbreviatedObjectId;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.FileMode;
-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.patch.FileHeader;
-import org.eclipse.jgit.patch.FileHeader.PatchType;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTree;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.util.io.DisabledOutputStream;
-
-public class PatchListLoader implements Callable<PatchList> {
-  public static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public interface Factory {
-    PatchListLoader create(PatchListKey key, Project.NameKey project);
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final PatchListCache patchListCache;
-  private final ThreeWayMergeStrategy mergeStrategy;
-  private final ExecutorService diffExecutor;
-  private final AutoMerger autoMerger;
-  private final PatchListKey key;
-  private final Project.NameKey project;
-  private final long timeoutMillis;
-
-  @Inject
-  PatchListLoader(
-      GitRepositoryManager mgr,
-      PatchListCache plc,
-      @GerritServerConfig Config cfg,
-      @DiffExecutor ExecutorService de,
-      AutoMerger am,
-      @Assisted PatchListKey k,
-      @Assisted Project.NameKey p) {
-    repoManager = mgr;
-    patchListCache = plc;
-    mergeStrategy = MergeUtil.getMergeStrategy(cfg);
-    diffExecutor = de;
-    autoMerger = am;
-    key = k;
-    project = p;
-    timeoutMillis =
-        ConfigUtil.getTimeUnit(
-            cfg,
-            "cache",
-            PatchListCacheImpl.FILE_NAME,
-            "timeout",
-            TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
-            TimeUnit.MILLISECONDS);
-  }
-
-  @Override
-  public PatchList call() throws IOException, PatchListNotAvailableException {
-    try (Repository repo = repoManager.openRepository(project);
-        InMemoryInserter ins = new InMemoryInserter(repo);
-        ObjectReader reader = ins.newReader();
-        RevWalk rw = new RevWalk(reader)) {
-      return readPatchList(repo, rw, ins);
-    }
-  }
-
-  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 PatchList readPatchList(Repository repo, RevWalk rw, InMemoryInserter ins)
-      throws IOException, PatchListNotAvailableException {
-    ObjectReader reader = rw.getObjectReader();
-    checkArgument(reader.getCreatedFromInserter() == ins);
-    RawTextComparator cmp = comparatorFor(key.getWhitespace());
-    try (DiffFormatter df = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-      RevCommit b = rw.parseCommit(key.getNewId());
-      RevObject a = aFor(key, repo, rw, ins, b);
-
-      if (a == null) {
-        // TODO(sop) Remove this case.
-        // This is an octopus merge commit which should be compared against the
-        // auto-merge. However since we don't support computing the auto-merge
-        // for octopus merge commits, we fall back to diffing against the first
-        // parent, even though this wasn't what was requested.
-        //
-        ComparisonType comparisonType = ComparisonType.againstParent(1);
-        PatchListEntry[] entries = new PatchListEntry[2];
-        entries[0] = newCommitMessage(cmp, reader, null, b);
-        entries[1] = newMergeList(cmp, reader, null, b, comparisonType);
-        return new PatchList(a, b, true, comparisonType, entries);
-      }
-
-      ComparisonType comparisonType = getComparisonType(a, b);
-
-      RevCommit aCommit = a instanceof RevCommit ? (RevCommit) a : null;
-      RevTree aTree = rw.parseTree(a);
-      RevTree bTree = b.getTree();
-
-      df.setReader(reader, repo.getConfig());
-      df.setDiffComparator(cmp);
-      df.setDetectRenames(true);
-      List<DiffEntry> diffEntries = df.scan(aTree, bTree);
-
-      EditsDueToRebaseResult editsDueToRebaseResult =
-          determineEditsDueToRebase(aCommit, b, diffEntries, df, rw);
-      diffEntries = editsDueToRebaseResult.getRelevantOriginalDiffEntries();
-      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath =
-          editsDueToRebaseResult.getEditsDueToRebasePerFilePath();
-
-      List<PatchListEntry> entries = new ArrayList<>();
-      entries.add(
-          newCommitMessage(
-              cmp, reader, comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit, b));
-      boolean isMerge = b.getParentCount() > 1;
-      if (isMerge) {
-        entries.add(
-            newMergeList(
-                cmp,
-                reader,
-                comparisonType.isAgainstParentOrAutoMerge() ? null : aCommit,
-                b,
-                comparisonType));
-      }
-      for (DiffEntry diffEntry : diffEntries) {
-        Set<ContextAwareEdit> editsDueToRebase =
-            getEditsDueToRebase(editsDueToRebasePerFilePath, diffEntry);
-        Optional<PatchListEntry> patchListEntry =
-            getPatchListEntry(reader, df, diffEntry, aTree, bTree, editsDueToRebase);
-        patchListEntry.ifPresent(entries::add);
-      }
-      return new PatchList(
-          a, b, isMerge, comparisonType, entries.toArray(new PatchListEntry[entries.size()]));
-    }
-  }
-
-  /**
-   * Identifies the edits which are present between {@code commitA} and {@code commitB} due to other
-   * commits in between those two. Edits which cannot be clearly attributed to those other commits
-   * (because they overlap with modifications introduced by {@code commitA} or {@code commitB}) are
-   * omitted from the result. The edits are expressed as differences between {@code treeA} of {@code
-   * commitA} and {@code treeB} of {@code commitB}.
-   *
-   * <p><b>Note:</b> If one of the commits is a merge commit, an empty {@code Multimap} will be
-   * returned.
-   *
-   * <p><b>Warning:</b> This method assumes that commitA and commitB are either a parent and child
-   * commit or represent two patch sets which belong to the same change. No checks are made to
-   * confirm this assumption! Passing arbitrary commits to this method may lead to strange results
-   * or take very long.
-   *
-   * <p>This logic could be expanded to arbitrary commits if the following adjustments were applied:
-   *
-   * <ul>
-   *   <li>If {@code commitA} is an ancestor of {@code commitB} (or the other way around), {@code
-   *       commitA} (or {@code commitB}) is used instead of its parent in this method.
-   *   <li>Special handling for merge commits is added. If only one of them is a merge commit, the
-   *       whole computation has to be done between the single parent and all parents of the merge
-   *       commit. If both of them are merge commits, all combinations of parents have to be
-   *       considered. Alternatively, we could decide to not support this feature for merge commits
-   *       (or just for specific types of merge commits).
-   * </ul>
-   *
-   * @param commitA the commit defining {@code treeA}
-   * @param commitB the commit defining {@code treeB}
-   * @param diffEntries the list of {@code DiffEntries} for the diff between {@code commitA} and
-   *     {@code commitB}
-   * @param df the {@code DiffFormatter}
-   * @param rw the current {@code RevWalk}
-   * @return an aggregated result of the computation
-   * @throws PatchListNotAvailableException if the edits can't be identified
-   * @throws IOException if an error occurred while accessing the repository
-   */
-  private EditsDueToRebaseResult determineEditsDueToRebase(
-      RevCommit commitA,
-      RevCommit commitB,
-      List<DiffEntry> diffEntries,
-      DiffFormatter df,
-      RevWalk rw)
-      throws PatchListNotAvailableException, IOException {
-    if (commitA == null
-        || isRootOrMergeCommit(commitA)
-        || isRootOrMergeCommit(commitB)
-        || areParentChild(commitA, commitB)
-        || haveCommonParent(commitA, commitB)) {
-      return EditsDueToRebaseResult.create(diffEntries, ImmutableMultimap.of());
-    }
-
-    PatchListKey oldKey = PatchListKey.againstDefaultBase(key.getOldId(), key.getWhitespace());
-    PatchList oldPatchList = patchListCache.get(oldKey, project);
-    PatchListKey newKey = PatchListKey.againstDefaultBase(key.getNewId(), key.getWhitespace());
-    PatchList newPatchList = patchListCache.get(newKey, project);
-
-    List<PatchListEntry> oldPatches = oldPatchList.getPatches();
-    List<PatchListEntry> newPatches = newPatchList.getPatches();
-    // TODO(aliceks): Have separate but more limited lists for parents and patch sets (but don't
-    // mess up renames/copies).
-    Set<String> touchedFilePaths = new HashSet<>();
-    for (PatchListEntry patchListEntry : oldPatches) {
-      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
-    }
-    for (PatchListEntry patchListEntry : newPatches) {
-      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
-    }
-
-    List<DiffEntry> relevantDiffEntries =
-        diffEntries.stream()
-            .filter(diffEntry -> isTouched(touchedFilePaths, diffEntry))
-            .collect(toImmutableList());
-
-    RevCommit parentCommitA = commitA.getParent(0);
-    rw.parseBody(parentCommitA);
-    RevCommit parentCommitB = commitB.getParent(0);
-    rw.parseBody(parentCommitB);
-    List<DiffEntry> parentDiffEntries = df.scan(parentCommitA, parentCommitB);
-    // TODO(aliceks): Find a way to not construct a PatchListEntry as it contains many unnecessary
-    // details and we don't fill all of them properly.
-    List<PatchListEntry> parentPatchListEntries =
-        getRelevantPatchListEntries(
-            parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
-
-    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;
-  }
-
-  private static boolean areParentChild(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));
-  }
-
-  private static Set<String> getTouchedFilePaths(PatchListEntry patchListEntry) {
-    String oldFilePath = patchListEntry.getOldName();
-    String newFilePath = patchListEntry.getNewName();
-
-    return oldFilePath == null
-        ? ImmutableSet.of(newFilePath)
-        : ImmutableSet.of(oldFilePath, newFilePath);
-  }
-
-  private static boolean isTouched(Set<String> touchedFilePaths, DiffEntry diffEntry) {
-    String oldFilePath = diffEntry.getOldPath();
-    String newFilePath = diffEntry.getNewPath();
-    // 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);
-  }
-
-  private List<PatchListEntry> getRelevantPatchListEntries(
-      List<DiffEntry> parentDiffEntries,
-      RevCommit parentCommitA,
-      RevCommit parentCommitB,
-      Set<String> touchedFilePaths,
-      DiffFormatter diffFormatter)
-      throws IOException {
-    List<PatchListEntry> parentPatchListEntries = new ArrayList<>(parentDiffEntries.size());
-    for (DiffEntry parentDiffEntry : parentDiffEntries) {
-      if (!isTouched(touchedFilePaths, parentDiffEntry)) {
-        continue;
-      }
-      FileHeader fileHeader = toFileHeader(parentCommitB, diffFormatter, parentDiffEntry);
-      // The code which uses this PatchListEntry doesn't care about the last three parameters. As
-      // they are expensive to compute, we use arbitrary values for them.
-      PatchListEntry patchListEntry =
-          newEntry(parentCommitA.getTree(), fileHeader, ImmutableSet.of(), 0, 0);
-      parentPatchListEntries.add(patchListEntry);
-    }
-    return parentPatchListEntries;
-  }
-
-  private static Set<ContextAwareEdit> getEditsDueToRebase(
-      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath, DiffEntry diffEntry) {
-    if (editsDueToRebasePerFilePath.isEmpty()) {
-      return ImmutableSet.of();
-    }
-
-    String filePath = diffEntry.getNewPath();
-    if (diffEntry.getChangeType() == ChangeType.DELETE) {
-      filePath = diffEntry.getOldPath();
-    }
-    return ImmutableSet.copyOf(editsDueToRebasePerFilePath.get(filePath));
-  }
-
-  private Optional<PatchListEntry> getPatchListEntry(
-      ObjectReader objectReader,
-      DiffFormatter diffFormatter,
-      DiffEntry diffEntry,
-      RevTree treeA,
-      RevTree treeB,
-      Set<ContextAwareEdit> editsDueToRebase)
-      throws IOException {
-    FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
-    long oldSize =
-        getFileSize(
-            objectReader,
-            diffEntry.getOldId(),
-            diffEntry.getOldMode(),
-            diffEntry.getOldPath(),
-            treeA);
-    long newSize =
-        getFileSize(
-            objectReader,
-            diffEntry.getNewId(),
-            diffEntry.getNewMode(),
-            diffEntry.getNewPath(),
-            treeB);
-    Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
-    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(toFileEdits(patchListEntry)).allMatch(editsDueToRebase::contains)) {
-      return Optional.empty();
-    }
-    return Optional.of(patchListEntry);
-  }
-
-  private static Set<Edit> getContentEdits(Set<ContextAwareEdit> editsDueToRebase) {
-    return editsDueToRebase.stream()
-        .map(ContextAwareEdit::toEdit)
-        .filter(Optional::isPresent)
-        .map(Optional::get)
-        .collect(toSet());
-  }
-
-  private ComparisonType getComparisonType(RevObject a, RevCommit b) {
-    for (int i = 0; i < b.getParentCount(); i++) {
-      if (b.getParent(i).equals(a)) {
-        return ComparisonType.againstParent(i + 1);
-      }
-    }
-
-    if (key.getOldId() == null && b.getParentCount() > 0) {
-      return ComparisonType.againstAutoMerge();
-    }
-
-    return ComparisonType.againstOtherPatchSet();
-  }
-
-  private static long getFileSize(
-      ObjectReader reader, AbbreviatedObjectId abbreviatedId, FileMode mode, String path, RevTree t)
-      throws IOException {
-    if (!isBlob(mode)) {
-      return 0;
-    }
-    ObjectId fileId =
-        toObjectId(reader, abbreviatedId).orElseGet(() -> lookupObjectId(reader, path, t));
-    if (ObjectId.zeroId().equals(fileId)) {
-      return 0;
-    }
-    return reader.getObjectSize(fileId, OBJ_BLOB);
-  }
-
-  private static boolean isBlob(FileMode mode) {
-    int t = mode.getBits() & FileMode.TYPE_MASK;
-    return t == FileMode.TYPE_FILE || t == FileMode.TYPE_SYMLINK;
-  }
-
-  private static Optional<ObjectId> toObjectId(
-      ObjectReader reader, 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 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 FileHeader toFileHeader(
-      ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
-
-    Future<FileHeader> result =
-        diffExecutor.submit(
-            () -> {
-              synchronized (diffEntry) {
-                return diffFormatter.toFileHeader(diffEntry);
-              }
-            });
-
-    try {
-      return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
-    } catch (InterruptedException | TimeoutException e) {
-      logger.atWarning().log(
-          "%s ms timeout reached for Diff loader in project %s"
-              + " on commit %s on path %s comparing %s..%s",
-          timeoutMillis,
-          project,
-          commitB.name(),
-          diffEntry.getNewPath(),
-          diffEntry.getOldId().name(),
-          diffEntry.getNewId().name());
-      result.cancel(true);
-      synchronized (diffEntry) {
-        return toFileHeaderWithoutMyersDiff(diffFormatter, diffEntry);
-      }
-    } catch (ExecutionException e) {
-      // If there was an error computing the result, carry it
-      // up to the caller so the cache knows this key is invalid.
-      Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
-      throw new IOException(e.getMessage(), e.getCause());
-    }
-  }
-
-  private FileHeader toFileHeaderWithoutMyersDiff(DiffFormatter diffFormatter, DiffEntry diffEntry)
-      throws IOException {
-    HistogramDiff histogramDiff = new HistogramDiff();
-    histogramDiff.setFallbackAlgorithm(null);
-    diffFormatter.setDiffAlgorithm(histogramDiff);
-    return diffFormatter.toFileHeader(diffEntry);
-  }
-
-  private PatchListEntry newCommitMessage(
-      RawTextComparator cmp, ObjectReader reader, RevCommit aCommit, RevCommit bCommit)
-      throws IOException {
-    Text aText = aCommit != null ? Text.forCommit(reader, aCommit) : Text.EMPTY;
-    Text bText = Text.forCommit(reader, bCommit);
-    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.COMMIT_MSG);
-  }
-
-  private PatchListEntry newMergeList(
-      RawTextComparator cmp,
-      ObjectReader reader,
-      RevCommit aCommit,
-      RevCommit bCommit,
-      ComparisonType comparisonType)
-      throws IOException {
-    Text aText = aCommit != null ? Text.forMergeList(comparisonType, reader, aCommit) : Text.EMPTY;
-    Text bText = Text.forMergeList(comparisonType, reader, bCommit);
-    return createPatchListEntry(cmp, aCommit, aText, bText, Patch.MERGE_LIST);
-  }
-
-  private static PatchListEntry createPatchListEntry(
-      RawTextComparator cmp, RevCommit aCommit, Text aText, Text bText, String fileName) {
-    byte[] rawHdr = getRawHeader(aCommit != null, 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 = new HistogramDiff().diff(cmp, aRawText, bRawText);
-    FileHeader fh = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
-    return new PatchListEntry(fh, edits, ImmutableSet.of(), size, sizeDelta);
-  }
-
-  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 static PatchListEntry newEntry(
-      RevTree aTree, FileHeader fileHeader, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
-    if (aTree == null // want combined diff
-        || fileHeader.getPatchType() != PatchType.UNIFIED
-        || fileHeader.getHunks().isEmpty()) {
-      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
-    }
-
-    List<Edit> edits = fileHeader.toEditList();
-    if (edits.isEmpty()) {
-      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
-    }
-    return new PatchListEntry(fileHeader, edits, editsDueToRebase, size, sizeDelta);
-  }
-
-  private RevObject aFor(
-      PatchListKey key, Repository repo, RevWalk rw, InMemoryInserter ins, RevCommit b)
-      throws IOException {
-    if (key.getOldId() != null) {
-      return rw.parseAny(key.getOldId());
-    }
-
-    switch (b.getParentCount()) {
-      case 0:
-        return rw.parseAny(emptyTree(ins));
-      case 1:
-        {
-          RevCommit r = b.getParent(0);
-          rw.parseBody(r);
-          return r;
-        }
-      default:
-        if (key.getParentNum() != null) {
-          RevCommit r = b.getParent(key.getParentNum() - 1);
-          rw.parseBody(r);
-          return r;
-        }
-        // Only support auto-merge for 2 parents, not octopus merges
-        if (b.getParentCount() == 2) {
-          return autoMerger.lookupFromGitOrMergeInMemory(repo, rw, ins, b, mergeStrategy);
-        }
-        return null;
-    }
-  }
-
-  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
-    ObjectId id = ins.insert(Constants.OBJ_TREE, new byte[] {});
-    ins.flush();
-    return id;
-  }
-
-  @AutoValue
-  abstract static class EditsDueToRebaseResult {
-    public static EditsDueToRebaseResult create(
-        List<DiffEntry> relevantDiffEntries,
-        Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath) {
-      return new AutoValue_PatchListLoader_EditsDueToRebaseResult(
-          relevantDiffEntries, editsDueToRebasePerFilePath);
-    }
-
-    public abstract List<DiffEntry> getRelevantOriginalDiffEntries();
-
-    /** Returns the edits per file path they modify in {@code treeB}. */
-    public abstract Multimap<String, ContextAwareEdit> getEditsDueToRebasePerFilePath();
-  }
-}
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
index b3b82bb..b7144d7 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheImpl.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.patch.DiffNotAvailableException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
@@ -38,11 +39,13 @@
 import org.eclipse.jgit.diff.DiffEntry;
 import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.diff.DiffFormatter;
+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 GitModifiedFilesCache} */
+@Singleton
 public class GitModifiedFilesCacheImpl implements GitModifiedFilesCache {
   private static final String GIT_MODIFIED_FILES = "git_modified_files";
   private static final ImmutableMap<ChangeType, Patch.ChangeType> changeTypeMap =
@@ -130,7 +133,7 @@
         }
         // 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());
+        return df.scan(key.aTree().equals(ObjectId.zeroId()) ? null : key.aTree(), key.bTree());
       }
     }
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
index fb8fce1..16b0e65 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/GitModifiedFilesCacheKey.java
@@ -37,7 +37,8 @@
 
   /**
    * The git SHA-1 {@link ObjectId} of the first git tree object for which the diff should be
-   * computed.
+   * computed. If equals to {@link ObjectId#zeroId()}, a null tree is used for the diff scan, and
+   * {@link #bTree()} is treated as an added tree.
    */
   public abstract ObjectId aTree();
 
diff --git a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
index 9512094..f4e7ca3 100644
--- a/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
+++ b/java/com/google/gerrit/server/patch/gitdiff/ModifiedFile.java
@@ -51,6 +51,8 @@
     return new AutoValue_ModifiedFile.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   /** Computes this object's weight, which is its size in bytes. */
   public int weight() {
     int weight = 1; // the changeType field
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
index e1af81d..2f23c8c 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiff.java
@@ -29,13 +29,12 @@
 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.lib.ObjectId;
 import org.eclipse.jgit.patch.FileHeader;
 
 /**
@@ -65,8 +64,7 @@
    * 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);
+  static GitFileDiff create(DiffEntry diffEntry, FileHeader fileHeader) {
     ImmutableList<Edit> edits =
         fileHeader.toEditList().stream().map(Edit::fromJGitEdit).collect(toImmutableList());
 
@@ -102,6 +100,15 @@
         .build();
   }
 
+  /**
+   * Create a negative result to be cached, i.e. if the diff computation did not finish in a
+   * reasonable amount of time.
+   */
+  static GitFileDiff createNegative(
+      AbbreviatedObjectId oldId, AbbreviatedObjectId newId, String newFilePath) {
+    return empty(oldId, newId, newFilePath).toBuilder().negative(Optional.of(true)).build();
+  }
+
   /** An {@link ImmutableList} of the modified regions in the file. */
   public abstract ImmutableList<Edit> edits();
 
@@ -114,7 +121,10 @@
   /** 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. */
+  /**
+   * The 20 bytes SHA-1 object ID of the old git tree of the diff, or {@link ObjectId#zeroId()} if
+   * {@link #newId()} was a root git tree (i.e. has no parents).
+   */
   public abstract AbbreviatedObjectId oldId();
 
   /** The 20 bytes SHA-1 object ID of the new git tree of the diff. */
@@ -133,6 +143,12 @@
   public abstract Optional<PatchType> patchType();
 
   /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
+   * result in this case.
+   */
+  public abstract Optional<Boolean> negative();
+
+  /**
    * Returns true if the object was created using the {@link #empty(AbbreviatedObjectId,
    * AbbreviatedObjectId, String)} method.
    */
@@ -140,6 +156,14 @@
     return edits().isEmpty();
   }
 
+  /**
+   * Returns {@code true} if the diff computation was not able to compute a diff. We cache negative
+   * result in this case.
+   */
+  public boolean isNegative() {
+    return negative().isPresent() && negative().get();
+  }
+
   /** Returns the size of the object in bytes. */
   public int weight() {
     int result = 20 * 2; // oldId and newId
@@ -161,13 +185,22 @@
     if (newMode().isPresent()) {
       result += 4;
     }
+    if (negative().isPresent()) {
+      result += 1;
+    }
     return result;
   }
 
+  public String getDefaultPath() {
+    return oldPath().isPresent() ? oldPath().get() : newPath().get();
+  }
+
   public static Builder builder() {
     return new AutoValue_GitFileDiff.Builder();
   }
 
+  public abstract Builder toBuilder();
+
   @AutoValue.Builder
   public abstract static class Builder {
 
@@ -191,6 +224,8 @@
 
     public abstract Builder patchType(Optional<PatchType> value);
 
+    public abstract Builder negative(Optional<Boolean> value);
+
     public abstract GitFileDiff build();
   }
 
@@ -212,6 +247,9 @@
     private static final FieldDescriptor PATCH_TYPE_DESCRIPTOR =
         GitFileDiffProto.getDescriptor().findFieldByNumber(10);
 
+    private static final FieldDescriptor NEGATIVE_DESCRIPTOR =
+        GitFileDiffProto.getDescriptor().findFieldByNumber(11);
+
     @Override
     public byte[] serialize(GitFileDiff gitFileDiff) {
       ObjectIdConverter idConverter = ObjectIdConverter.create();
@@ -246,6 +284,9 @@
       if (gitFileDiff.patchType().isPresent()) {
         builder.setPatchType(gitFileDiff.patchType().get().name());
       }
+      if (gitFileDiff.negative().isPresent()) {
+        builder.setNegative(gitFileDiff.negative().get());
+      }
       return Protos.toByteArray(builder.build());
     }
 
@@ -279,6 +320,9 @@
       if (proto.hasField(PATCH_TYPE_DESCRIPTOR)) {
         builder.patchType(Optional.of(Patch.PatchType.valueOf(proto.getPatchType())));
       }
+      if (proto.hasField(NEGATIVE_DESCRIPTOR)) {
+        builder.negative(Optional.of(proto.getNegative()));
+      }
       return builder.build();
     }
   }
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
index 97cf37d32..44af22e 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheImpl.java
@@ -17,27 +17,49 @@
 import static java.util.function.Function.identity;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.base.Throwables;
 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.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multimaps;
 import com.google.common.collect.Streams;
+import com.google.gerrit.entities.Patch;
 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.cache.CacheModule;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
+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.patch.DiffExecutor;
 import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.util.git.CloseablePool;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Comparator;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.DiffEntry;
@@ -46,12 +68,14 @@
 import org.eclipse.jgit.diff.HistogramDiff;
 import org.eclipse.jgit.diff.RawTextComparator;
 import org.eclipse.jgit.lib.AbbreviatedObjectId;
+import org.eclipse.jgit.lib.Config;
 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.util.io.DisabledOutputStream;
 
 /** Implementation of the {@link GitFileDiffCache} */
+@Singleton
 public class GitFileDiffCacheImpl implements GitFileDiffCache {
   private static final String GIT_DIFF = "git_file_diff";
 
@@ -65,22 +89,39 @@
             .weigher(GitFileDiffWeigher.class)
             .keySerializer(GitFileDiffCacheKey.Serializer.INSTANCE)
             .valueSerializer(GitFileDiff.Serializer.INSTANCE)
+            .version(3)
             .loader(GitFileDiffCacheImpl.Loader.class);
       }
     };
   }
 
+  @Singleton
+  static class Metrics {
+    final Counter0 timeouts;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      timeouts =
+          metricMaker.newCounter(
+              "caches/diff/timeouts",
+              new Description(
+                      "Total number of git file diff computations that resulted in timeouts.")
+                  .setRate()
+                  .setUnit("count"));
+    }
+  }
+
   /** Enum for the supported diff algorithms for the file diff computation. */
   public enum DiffAlgorithm {
-    HISTOGRAM,
-    HISTOGRAM_WITHOUT_MYERS_FALLBACK
+    HISTOGRAM_WITH_FALLBACK_MYERS,
+    HISTOGRAM_NO_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)) {
+      if (diffAlgorithm.equals(DiffAlgorithm.HISTOGRAM_NO_FALLBACK)) {
         result.setFallbackAlgorithm(null);
       }
       return result;
@@ -126,43 +167,72 @@
                 : entry.getNewPath();
 
     private final GitRepositoryManager repoManager;
+    private final ExecutorService diffExecutor;
+    private final long timeoutMillis;
+    private final Metrics metrics;
 
     @Inject
-    public Loader(GitRepositoryManager repoManager) {
+    public Loader(
+        @GerritServerConfig Config cfg,
+        GitRepositoryManager repoManager,
+        @DiffExecutor ExecutorService de,
+        Metrics metrics) {
       this.repoManager = repoManager;
+      this.diffExecutor = de;
+      this.timeoutMillis =
+          ConfigUtil.getTimeUnit(
+              cfg,
+              "cache",
+              GIT_DIFF,
+              "timeout",
+              TimeUnit.MILLISECONDS.convert(5, TimeUnit.SECONDS),
+              TimeUnit.MILLISECONDS);
+      this.metrics = metrics;
     }
 
     @Override
-    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException {
-      return loadAll(ImmutableList.of(key)).get(key);
+    public GitFileDiff load(GitFileDiffCacheKey key) throws IOException, DiffNotAvailableException {
+      try (TraceTimer timer =
+          TraceContext.newTimer(
+              "Loading a single key from git file diff cache",
+              Metadata.builder()
+                  .diffAlgorithm(key.diffAlgorithm().name())
+                  .filePath(key.newFilePath())
+                  .build())) {
+        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));
+        Iterable<? extends GitFileDiffCacheKey> keys)
+        throws IOException, DiffNotAvailableException {
+      try (TraceTimer timer =
+          TraceContext.newTimer("Loading multiple keys from git file diff cache")) {
+        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));
+        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()) {
+        for (Map.Entry<Project.NameKey, List<GitFileDiffCacheKey>> entry : byProject.entrySet()) {
+          try (Repository repo = repoManager.openRepository(entry.getKey())) {
 
-          // 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));
+            // 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()));
+            for (Map.Entry<DiffOptions, List<GitFileDiffCacheKey>> group :
+                optionsGroups.entrySet()) {
+              result.putAll(loadAllImpl(repo, group.getKey(), group.getValue()));
+            }
           }
         }
+        return result.build();
       }
-      return result.build();
     }
 
     /**
@@ -172,46 +242,69 @@
      * @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 {
+        Repository repo, DiffOptions options, List<GitFileDiffCacheKey> keys)
+        throws IOException, DiffNotAvailableException {
       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;
+      try (CloseablePool<DiffFormatter> diffPool =
+          new CloseablePool<>(() -> createDiffFormatter(options, repo))) {
+        ListMultimap<String, DiffEntry> diffEntries;
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          diffEntries = loadDiffEntries(formatter.get(), options, filePaths.values());
         }
-        result.put(
-            key,
-            GitFileDiff.empty(
-                AbbreviatedObjectId.fromObjectId(key.oldTree()),
-                AbbreviatedObjectId.fromObjectId(key.newTree()),
-                newFilePath));
+        for (GitFileDiffCacheKey key : filePaths.keySet()) {
+          String newFilePath = filePaths.get(key);
+          if (!diffEntries.containsKey(newFilePath)) {
+            result.put(
+                key,
+                GitFileDiff.empty(
+                    AbbreviatedObjectId.fromObjectId(key.oldTree()),
+                    AbbreviatedObjectId.fromObjectId(key.newTree()),
+                    newFilePath));
+            continue;
+          }
+          List<DiffEntry> entries = diffEntries.get(newFilePath);
+          if (entries.size() == 1) {
+            result.put(key, createGitFileDiff(entries.get(0), key, diffPool));
+          } else {
+            // Handle when JGit returns two {Added, Deleted} entries for the same file. This
+            // happens, for example, when a file's mode is changed between patchsets (e.g.
+            // converting a symlink to a regular file). We combine both diff entries into a single
+            // entry with {changeType = Rewrite}.
+            List<GitFileDiff> gitDiffs = new ArrayList<>();
+            for (DiffEntry entry : diffEntries.get(newFilePath)) {
+              gitDiffs.add(createGitFileDiff(entry, key, diffPool));
+            }
+            result.put(key, createRewriteEntry(gitDiffs));
+          }
+        }
+        return result.build();
       }
-      return result.build();
     }
 
-    private static Map<String, DiffEntry> loadDiffEntries(
+    private static ListMultimap<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());
+          diffFormatter.scan(
+              diffOptions.oldTree().equals(ObjectId.zeroId()) ? null : diffOptions.oldTree(),
+              diffOptions.newTree());
 
       return diffEntries.stream()
           .filter(d -> filePathsSet.contains(pathExtractor.apply(d)))
-          .collect(Collectors.toMap(d -> pathExtractor.apply(d), identity()));
+          .collect(
+              Multimaps.toMultimap(
+                  d -> pathExtractor.apply(d),
+                  identity(),
+                  MultimapBuilder.treeKeys().arrayListValues()::build));
     }
 
-    private static DiffFormatter createDiffFormatter(
-        DiffOptions diffOptions, Repository repo, ObjectReader reader) {
+    private static DiffFormatter createDiffFormatter(DiffOptions diffOptions, Repository repo) {
       try (DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
-        diffFormatter.setReader(reader, repo.getConfig());
+        diffFormatter.setRepository(repo);
         RawTextComparator cmp = comparatorFor(diffOptions.whitespace());
         diffFormatter.setDiffComparator(cmp);
         if (diffOptions.renameScore() != -1) {
@@ -219,6 +312,7 @@
           diffFormatter.getRenameDetector().setRenameScore(diffOptions.renameScore());
         }
         diffFormatter.setDiffAlgorithm(DiffAlgorithmFactory.create(diffOptions.diffAlgorithm()));
+        diffFormatter.getRenameDetector().setSkipContentRenamesForBinaryFiles(true);
         return diffFormatter;
       }
     }
@@ -239,6 +333,83 @@
           return RawTextComparator.DEFAULT;
       }
     }
+
+    /**
+     * Create a {@link GitFileDiff}. The result depends on the value of the {@code useTimeout} field
+     * of the {@code key} parameter.
+     *
+     * <ul>
+     *   <li>If {@code useTimeout} is true, the computation is performed with timeout enforcement
+     *       (identified by {@link #timeoutMillis}). If the timeout is exceeded, this method returns
+     *       a negative result using {@link GitFileDiff#createNegative(AbbreviatedObjectId,
+     *       AbbreviatedObjectId, String)}.
+     *   <li>If {@code useTimeouts} is false, the computation is performed synchronously without
+     *       timeout enforcement.
+     */
+    private GitFileDiff createGitFileDiff(
+        DiffEntry diffEntry, GitFileDiffCacheKey key, CloseablePool<DiffFormatter> diffPool)
+        throws IOException {
+      if (!key.useTimeout()) {
+        try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+          FileHeader fileHeader = formatter.get().toFileHeader(diffEntry);
+          return GitFileDiff.create(diffEntry, fileHeader);
+        }
+      }
+      // This submits the DiffFormatter to a different thread. The CloseablePool and our usage of it
+      // ensures that any DiffFormatter instance and the ObjectReader it references internally is
+      // only used by a single thread concurrently. However, ObjectReaders have a reference to
+      // Repository which might not be thread safe (FileRepository is, DfsRepository might not).
+      // This could lead to a race condition.
+      Future<GitFileDiff> fileDiffFuture =
+          diffExecutor.submit(
+              () -> {
+                try (CloseablePool<DiffFormatter>.Handle formatter = diffPool.get()) {
+                  return GitFileDiff.create(diffEntry, formatter.get().toFileHeader(diffEntry));
+                }
+              });
+      try {
+        // We employ the timeout because of a bug in Myers diff in JGit. See
+        // bugs.chromium.org/p/gerrit/issues/detail?id=487 for more details. The bug may happen
+        // if the algorithm used in diffs is HISTOGRAM_WITH_FALLBACK_MYERS.
+        return fileDiffFuture.get(timeoutMillis, TimeUnit.MILLISECONDS);
+      } catch (InterruptedException | TimeoutException e) {
+        // If timeout happens, create a negative result
+        metrics.timeouts.increment();
+        return GitFileDiff.createNegative(
+            AbbreviatedObjectId.fromObjectId(key.oldTree()),
+            AbbreviatedObjectId.fromObjectId(key.newTree()),
+            key.newFilePath());
+      } catch (ExecutionException e) {
+        // If there was an error computing the result, carry it
+        // up to the caller so the cache knows this key is invalid.
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        throw new IOException(e.getMessage(), e.getCause());
+      }
+    }
+  }
+
+  /**
+   * Create a single {@link GitFileDiff} with {@link com.google.gerrit.entities.Patch.ChangeType}
+   * equals {@link com.google.gerrit.entities.Patch.ChangeType#REWRITE}, assuming the input list
+   * contains two entries.
+   *
+   * @param gitDiffs input list of exactly two {@link GitFileDiff} for same file path.
+   * @return a single {@link GitFileDiff} with change type equals {@link
+   *     com.google.gerrit.entities.Patch.ChangeType#REWRITE}.
+   * @throws DiffNotAvailableException if input list contains git diffs with change types other than
+   *     {ADDED, DELETED}. This is a JGit error.
+   */
+  private static GitFileDiff createRewriteEntry(List<GitFileDiff> gitDiffs)
+      throws DiffNotAvailableException {
+    if (gitDiffs.size() != 2) {
+      throw new DiffNotAvailableException(
+          String.format(
+              "JGit error: found %d dff entries for same file path %s",
+              gitDiffs.size(), gitDiffs.get(0).getDefaultPath()));
+    }
+    // Convert the first entry (prioritized according to change type enum order) to REWRITE
+    gitDiffs.sort(Comparator.comparingInt(o -> o.changeType().ordinal()));
+    return gitDiffs.get(0).toBuilder().changeType(Patch.ChangeType.REWRITE).build();
   }
 
   /** An entity representing the options affecting the diff computation. */
diff --git a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
index f104388..2d80614 100644
--- a/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
+++ b/java/com/google/gerrit/server/patch/gitfilediff/GitFileDiffCacheKey.java
@@ -34,7 +34,11 @@
   /** 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 */
+  /**
+   * The old 20 bytes SHA-1 git tree ID used in the git tree diff. If equals to {@link
+   * ObjectId#zeroId()}, a null tree is used for the diff scan, and {@link #newTree()} ()} is
+   * treated as an added tree.
+   */
   public abstract ObjectId oldTree();
 
   /** The new 20 bytes SHA-1 git tree ID used in the git tree diff */
@@ -53,13 +57,17 @@
 
   public abstract DiffPreferencesInfo.Whitespace whitespace();
 
+  /** Employ a timeout on the git computation while formatting the file header. */
+  public abstract boolean useTimeout();
+
   public int weight() {
     return stringSize(project().get())
         + 20 * 2 // oldTree and newTree
         + stringSize(newFilePath())
         + 4 // renameScore
         + 4 // diffAlgorithm
-        + 4; // whitespace
+        + 4 // whitespace
+        + 1; // useTimeout
   }
 
   public static Builder builder() {
@@ -88,6 +96,8 @@
 
     public abstract Builder whitespace(Whitespace value);
 
+    public abstract Builder useTimeout(boolean value);
+
     public abstract GitFileDiffCacheKey build();
   }
 
@@ -106,6 +116,7 @@
               .setRenameScore(key.renameScore())
               .setDiffAlgorithm(key.diffAlgorithm().name())
               .setWhitepsace(key.whitespace().name())
+              .setUseTimeout(key.useTimeout())
               .build());
     }
 
@@ -121,6 +132,7 @@
           .renameScore(proto.getRenameScore())
           .diffAlgorithm(DiffAlgorithm.valueOf(proto.getDiffAlgorithm()))
           .whitespace(Whitespace.valueOf(proto.getWhitepsace()))
+          .useTimeout(proto.getUseTimeout())
           .build();
     }
   }
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
index 66299a8..aa49852 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionBackend.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.server.account.CapabilityCollection;
 import com.google.gerrit.server.cache.PerThreadCache;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -124,11 +123,12 @@
     @Override
     public ForProject project(Project.NameKey project) {
       try {
-        ProjectState state = projectCache.get(project).orElseThrow(illegalState(project));
         ProjectControl control =
             PerThreadCache.getOrCompute(
                 PerThreadCache.Key.create(ProjectControl.class, project, user.getCacheKey()),
-                () -> projectControlFactory.create(user, state));
+                () ->
+                    projectControlFactory.create(
+                        user, projectCache.get(project).orElseThrow(illegalState(project))));
         return control.asForProject();
       } catch (Exception e) {
         Throwable cause = e.getCause() != null ? e.getCause() : e;
diff --git a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
index dcaf485..9d69d9b 100644
--- a/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
+++ b/java/com/google/gerrit/server/permissions/DefaultPermissionMappings.java
@@ -55,12 +55,12 @@
           .put(GlobalPermission.RUN_AS, GlobalCapability.RUN_AS)
           .put(GlobalPermission.RUN_GC, GlobalCapability.RUN_GC)
           .put(GlobalPermission.STREAM_EVENTS, GlobalCapability.STREAM_EVENTS)
+          .put(GlobalPermission.VIEW_ACCESS, GlobalCapability.VIEW_ACCESS)
           .put(GlobalPermission.VIEW_ALL_ACCOUNTS, GlobalCapability.VIEW_ALL_ACCOUNTS)
           .put(GlobalPermission.VIEW_CACHES, GlobalCapability.VIEW_CACHES)
           .put(GlobalPermission.VIEW_CONNECTIONS, GlobalCapability.VIEW_CONNECTIONS)
           .put(GlobalPermission.VIEW_PLUGINS, GlobalCapability.VIEW_PLUGINS)
           .put(GlobalPermission.VIEW_QUEUE, GlobalCapability.VIEW_QUEUE)
-          .put(GlobalPermission.VIEW_ACCESS, GlobalCapability.VIEW_ACCESS)
           .build();
 
   static {
diff --git a/java/com/google/gerrit/server/permissions/GlobalPermission.java b/java/com/google/gerrit/server/permissions/GlobalPermission.java
index d4f22e6..c0b44e5 100644
--- a/java/com/google/gerrit/server/permissions/GlobalPermission.java
+++ b/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -53,12 +53,12 @@
   RUN_AS,
   RUN_GC,
   STREAM_EVENTS,
+  VIEW_ACCESS,
   VIEW_ALL_ACCOUNTS,
   VIEW_CACHES,
   VIEW_CONNECTIONS,
   VIEW_PLUGINS,
-  VIEW_QUEUE,
-  VIEW_ACCESS;
+  VIEW_QUEUE;
 
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
diff --git a/java/com/google/gerrit/server/permissions/LabelPermission.java b/java/com/google/gerrit/server/permissions/LabelPermission.java
index 268570c..c266caa 100644
--- a/java/com/google/gerrit/server/permissions/LabelPermission.java
+++ b/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -71,12 +71,12 @@
     this.name = LabelType.checkName(name);
   }
 
-  /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
   public ForUser forUser() {
     return forUser;
   }
 
-  /** @return name of the label, e.g. {@code "Code-Review"}. */
+  /** Returns name of the label, e.g. {@code "Code-Review"}. */
   public String label() {
     return name;
   }
@@ -199,17 +199,17 @@
       this.label = requireNonNull(label, "LabelVote");
     }
 
-    /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    /** Returns {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
     public ForUser forUser() {
       return forUser;
     }
 
-    /** @return name of the label, e.g. {@code "Code-Review"}. */
+    /** Returns name of the label, e.g. {@code "Code-Review"}. */
     public String label() {
       return label.label();
     }
 
-    /** @return specific value of the label, e.g. 1 or 2. */
+    /** Returns specific value of the label, e.g. 1 or 2. */
     public short value() {
       return label.value();
     }
diff --git a/java/com/google/gerrit/server/permissions/PermissionCollection.java b/java/com/google/gerrit/server/permissions/PermissionCollection.java
index ddba52b..4b8db1c 100644
--- a/java/com/google/gerrit/server/permissions/PermissionCollection.java
+++ b/java/com/google/gerrit/server/permissions/PermissionCollection.java
@@ -277,8 +277,8 @@
   }
 
   /**
-   * @return true if a "${username}" pattern might need to be expanded to build this collection,
-   *     making the results user specific.
+   * Returns true if a "${username}" pattern might need to be expanded to build this collection,
+   * making the results user specific.
    */
   public boolean isUserSpecific() {
     return perUser;
diff --git a/java/com/google/gerrit/server/permissions/ProjectControl.java b/java/com/google/gerrit/server/permissions/ProjectControl.java
index a92fde0..1203049 100644
--- a/java/com/google/gerrit/server/permissions/ProjectControl.java
+++ b/java/com/google/gerrit/server/permissions/ProjectControl.java
@@ -154,8 +154,8 @@
   }
 
   /**
-   * @return {@code Capable.OK} if the user can upload to at least one reference. Does not check
-   *     Contributor Agreements.
+   * Returns {@code Capable.OK} if the user can upload to at least one reference. Does not check
+   * Contributor Agreements.
    */
   boolean canPushToAtLeastOneRef() {
     return canPerformOnAnyRef(Permission.PUSH)
diff --git a/java/com/google/gerrit/server/permissions/RefControl.java b/java/com/google/gerrit/server/permissions/RefControl.java
index dd00dca..6b51335 100644
--- a/java/com/google/gerrit/server/permissions/RefControl.java
+++ b/java/com/google/gerrit/server/permissions/RefControl.java
@@ -135,19 +135,19 @@
     return hasReadPermissionOnRef;
   }
 
-  /** @return true if this user can add a new patch set to this ref */
+  /** Returns true if this user can add a new patch set to this ref */
   boolean canAddPatchSet() {
     return projectControl
         .controlForRef(MagicBranch.NEW_CHANGE + refName)
         .canPerform(Permission.ADD_PATCH_SET);
   }
 
-  /** @return true if this user can rebase changes on this ref */
+  /** Returns true if this user can rebase changes on this ref */
   boolean canRebase() {
     return canPerform(Permission.REBASE);
   }
 
-  /** @return true if this user can submit patch sets to this ref */
+  /** Returns true if this user can submit patch sets to this ref */
   boolean canSubmit(boolean isChangeOwner) {
     if (RefNames.REFS_CONFIG.equals(refName)) {
       // Always allow project owners to submit configuration changes.
@@ -160,12 +160,12 @@
     return canPerform(Permission.SUBMIT, isChangeOwner, false);
   }
 
-  /** @return true if this user can force edit topic names. */
+  /** Returns true if this user can force edit topic names. */
   boolean canForceEditTopicName() {
     return canPerform(Permission.EDIT_TOPIC_NAME, false, true);
   }
 
-  /** @return true if this user can delete changes. */
+  /** Returns true if this user can delete changes. */
   boolean canDeleteChanges(boolean isChangeOwner) {
     return canPerform(Permission.DELETE_CHANGES)
         || (isChangeOwner && canPerform(Permission.DELETE_OWN_CHANGES, isChangeOwner, false));
@@ -201,12 +201,12 @@
     return canPerform(Permission.REVERT);
   }
 
-  /** @return true if this user can submit merge patch sets to this ref */
+  /** Returns true if this user can submit merge patch sets to this ref */
   private boolean canUploadMerges() {
     return projectControl.controlForRef("refs/for/" + refName).canPerform(Permission.PUSH_MERGE);
   }
 
-  /** @return true if the user can update the reference as a fast-forward. */
+  /** Returns true if the user can update the reference as a fast-forward. */
   private boolean canUpdate() {
     if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
       // Pushing requires being at least project owner, in addition to push.
@@ -225,7 +225,7 @@
     return canPerform(Permission.PUSH);
   }
 
-  /** @return true if the user can rewind (force push) the reference. */
+  /** Returns true if the user can rewind (force push) the reference. */
   private boolean canForceUpdate() {
     if (canPushWithForce()) {
       return true;
@@ -281,7 +281,7 @@
     }
   }
 
-  /** @return true if this user can forge the author line in a commit. */
+  /** Returns true if this user can forge the author line in a commit. */
   private boolean canForgeAuthor() {
     if (canForgeAuthor == null) {
       canForgeAuthor = canPerform(Permission.FORGE_AUTHOR);
@@ -289,7 +289,7 @@
     return canForgeAuthor;
   }
 
-  /** @return true if this user can forge the committer line in a commit. */
+  /** Returns true if this user can forge the committer line in a commit. */
   private boolean canForgeCommitter() {
     if (canForgeCommitter == null) {
       canForgeCommitter = canPerform(Permission.FORGE_COMMITTER);
@@ -297,7 +297,7 @@
     return canForgeCommitter;
   }
 
-  /** @return true if this user can forge the server on the committer line. */
+  /** Returns true if this user can forge the server on the committer line. */
   private boolean canForgeGerritServerIdentity() {
     return canPerform(Permission.FORGE_SERVER);
   }
@@ -364,7 +364,9 @@
     }
 
     return new PermissionRange(
-        permissionName, Math.max(voteMin, blockAllowMin), Math.min(voteMax, blockAllowMax));
+        permissionName,
+        /* min= */ Math.max(voteMin, blockAllowMin),
+        /* max= */ Math.min(voteMax, blockAllowMax));
   }
 
   private boolean isBlocked(String permissionName, boolean isChangeOwner, boolean withForce) {
@@ -523,7 +525,10 @@
                       + "who also have 'Push' rights on "
                       + RefNames.REFS_CONFIG);
             } else {
-              pde.setAdvice("To push into this reference you need 'Push' rights.");
+              pde.setAdvice(
+                  "Push to refs/for/"
+                      + RefNames.shortName(refName)
+                      + " to create a review, or get 'Push' rights to update the branch.");
             }
             break;
           case DELETE:
@@ -557,7 +562,8 @@
             break;
           case FORGE_COMMITTER:
             pde.setAdvice(
-                "You need 'Forge Committer' rights to push commits with another user as committer.");
+                "You need 'Forge Committer' rights to push commits with another user as"
+                    + " committer.");
             break;
           case FORGE_SERVER:
             pde.setAdvice(
diff --git a/java/com/google/gerrit/server/permissions/SectionSortCache.java b/java/com/google/gerrit/server/permissions/SectionSortCache.java
index d800782..e64f8b6 100644
--- a/java/com/google/gerrit/server/permissions/SectionSortCache.java
+++ b/java/com/google/gerrit/server/permissions/SectionSortCache.java
@@ -17,6 +17,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 
 import com.google.auto.value.AutoValue;
+import com.google.auto.value.extension.memoized.Memoized;
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
@@ -126,22 +127,22 @@
 
     public abstract List<String> patterns();
 
-    public abstract int cachedHashCode();
-
     static EntryKey create(String refName, List<AccessSection> sections) {
-      int hc = refName.hashCode();
       List<String> patterns = new ArrayList<>(sections.size());
       for (AccessSection s : sections) {
-        String n = s.getName();
-        patterns.add(n);
-        hc = hc * 31 + n.hashCode();
+        patterns.add(s.getName());
       }
-      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns), hc);
+      return new AutoValue_SectionSortCache_EntryKey(refName, ImmutableList.copyOf(patterns));
     }
 
+    @Memoized
     @Override
-    public final int hashCode() {
-      return cachedHashCode();
+    public int hashCode() {
+      int hc = ref().hashCode();
+      for (String n : patterns()) {
+        hc = hc * 31 + n.hashCode();
+      }
+      return hc;
     }
   }
 
diff --git a/java/com/google/gerrit/server/plugincontext/PluginContext.java b/java/com/google/gerrit/server/plugincontext/PluginContext.java
index 90d56c8..a5fad56 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginContext.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.metrics.Field;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer3;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.inject.Inject;
@@ -53,7 +54,7 @@
  * <p>A plugin context can be manually opened by invoking the newTrace methods. This should only be
  * needed if an extension throws multiple exceptions that need to be handled:
  *
- * <pre>
+ * <pre>{@code
  * public interface Foo {
  *   void doFoo() throws Exception1, Exception2, Exception3;
  * }
@@ -65,7 +66,7 @@
  *     fooExtension.get().doFoo();
  *   }
  * }
- * </pre>
+ * }</pre>
  *
  * <p>This class hosts static methods with generic functionality to invoke plugin extensions with a
  * trace context that are commonly used by {@link PluginItemContext}, {@link PluginSetContext} and
@@ -120,11 +121,17 @@
     @Inject
     PluginMetrics(MetricMaker metricMaker) {
       Field<String> pluginNameField =
-          Field.ofString("plugin_name", Metadata.Builder::pluginName).build();
+          Field.ofString("plugin_name", Metadata.Builder::pluginName)
+              .description("The name of the plugin.")
+              .build();
       Field<String> classNameField =
-          Field.ofString("class_name", Metadata.Builder::className).build();
+          Field.ofString("class_name", Metadata.Builder::className)
+              .description("The class of the plugin that was invoked.")
+              .build();
       Field<String> exportValueField =
-          Field.ofString("export_value", Metadata.Builder::exportValue).build();
+          Field.ofString("export_value", Metadata.Builder::exportValue)
+              .description("The export name under which the invoked class is registered.")
+              .build();
 
       this.latency =
           metricMaker.newTimer(
@@ -185,7 +192,8 @@
   }
 
   /**
-   * Runs a plugin extension. All exceptions from the plugin extension are caught and logged.
+   * Runs a plugin extension. All exceptions from the plugin extension are caught and logged (except
+   * {@link RequestCancelledException}.
    *
    * <p>The consumer gets the extension implementation provided that should be invoked.
    *
@@ -204,7 +212,8 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionImplConsumer.run(extensionImpl);
-    } catch (Throwable e) {
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RequestCancelledException.class);
       pluginMetrics.incrementErrorCount(extension);
       logger.atWarning().withCause(e).log(
           "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
@@ -212,7 +221,8 @@
   }
 
   /**
-   * Runs a plugin extension. All exceptions from the plugin extension are caught and logged.
+   * Runs a plugin extension. All exceptions from the plugin extension are caught and logged (except
+   * {@link RequestCancelledException}.
    *
    * <p>The consumer get the {@link Extension} provided that should be invoked. The extension
    * provides access to the plugin name and the export name.
@@ -233,7 +243,8 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionConsumer.run(extension);
-    } catch (Throwable e) {
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RequestCancelledException.class);
       pluginMetrics.incrementErrorCount(extension);
       logger.atWarning().withCause(e).log(
           "Failure in %s of plugin %s", extensionImpl.getClass(), extension.getPluginName());
@@ -267,7 +278,7 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionImplConsumer.run(extensionImpl);
-    } catch (Throwable e) {
+    } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, exceptionClass);
       Throwables.throwIfUnchecked(e);
       pluginMetrics.incrementErrorCount(extension);
@@ -304,7 +315,7 @@
     try (TraceContext traceContext = newTrace(extension);
         Timer3.Context<String, String, String> ctx = pluginMetrics.startLatency(extension)) {
       extensionConsumer.run(extension);
-    } catch (Throwable e) {
+    } catch (Exception e) {
       Throwables.throwIfInstanceOf(e, exceptionClass);
       Throwables.throwIfUnchecked(e);
       pluginMetrics.incrementErrorCount(extension);
diff --git a/java/com/google/gerrit/server/plugincontext/PluginItemContext.java b/java/com/google/gerrit/server/plugincontext/PluginItemContext.java
index 421b3ad..e88a6fe 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginItemContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginItemContext.java
@@ -40,46 +40,46 @@
  *
  * <p>Example if all exceptions should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * fooPluginItemContext.run(foo -> foo.doFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if all exceptions, but one, should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * try {
  *   fooPluginItemContext.run(foo -> foo.doFoo(), MyException.class);
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Object result = fooPluginItemContext.call(foo -> foo.getFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values and a single exception should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Object result;
  * try {
  *   result = fooPluginItemContext.call(foo -> foo.getFoo(), MyException.class);
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if several exceptions should be handled:
  *
- * <pre>
+ * <pre>{@code
  * try (TraceContext traceContext = PluginContext.newTrace(fooDynamicItem.getEntry())) {
  *   fooDynamicItem.get().doFoo();
  * } catch (MyException1 | MyException2 | MyException3 e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  */
 public class PluginItemContext<T> {
   @Nullable private final DynamicItem<T> dynamicItem;
diff --git a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
index b02ad27..fb50cd5 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapContext.java
@@ -33,15 +33,15 @@
  *
  * <p>Example if all exceptions should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * Map<String, Object> results = new HashMap<>();
  * fooPluginMapContext.runEach(
  *     extension -> results.put(extension.getExportName(), extension.get().getFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if all exceptions, but one, should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * Map<String, Object> results = new HashMap<>();
  * try {
  *   fooPluginMapContext.runEach(
@@ -50,22 +50,22 @@
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Map<String, Object> results = new HashMap<>();
  * for (PluginMapEntryContext<Foo> c : fooPluginMapContext) {
  *   if (c.call(extension -> extension.get().handles(x))) {
  *     c.run(extension -> results.put(extension.getExportName(), extension.get().getFoo());
  *   }
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values and a single exception should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Map<String, Object> results = new HashMap<>();
  * try {
  *   for (PluginMapEntryContext<Foo> c : fooPluginMapContext) {
@@ -77,11 +77,11 @@
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if several exceptions should be handled:
  *
- * <pre>
+ * <pre>{@code
  * for (Extension<Foo> fooExtension : fooDynamicMap) {
  *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
  *     fooExtension.get().doFoo();
@@ -89,7 +89,7 @@
  *     // handle the exception
  *   }
  * }
- * </pre>
+ * }</pre>
  */
 public class PluginMapContext<T> implements Iterable<PluginMapEntryContext<T>> {
   private final DynamicMap<T> dynamicMap;
diff --git a/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java b/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java
index 68589cf..27181cb 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginMapEntryContext.java
@@ -35,15 +35,15 @@
  *
  * <p>The call* methods execute the extension and deliver a result back to the caller.
  *
- * <pre>
+ * <pre>{@code
  * Map<String, Object> results = new HashMap<>();
  * fooPluginMapEntryContext.run(
  *     extension -> results.put(extension.getExportName(), extension.get().getFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if all exceptions, but one, should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * Map<String, Object> results = new HashMap<>();
  * try {
  *   fooPluginMapEntryContext.run(
@@ -52,28 +52,28 @@
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Object result = fooPluginMapEntryContext.call(extension -> extension.get().getFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values and a single exception should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Object result;
  * try {
  *   result = fooPluginMapEntryContext.call(extension -> extension.get().getFoo(), MyException.class);
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if several exceptions should be handled:
  *
- * <pre>
+ * <pre>{@code
  * for (Extension<Foo> fooExtension : fooDynamicMap) {
  *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
  *     fooExtension.get().doFoo();
@@ -81,7 +81,7 @@
  *     // handle the exception
  *   }
  * }
- * </pre>
+ * }</pre>
  */
 public class PluginMapEntryContext<T> {
   private final Extension<T> extension;
diff --git a/java/com/google/gerrit/server/plugincontext/PluginSetContext.java b/java/com/google/gerrit/server/plugincontext/PluginSetContext.java
index b64cfeb..43c9552 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginSetContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginSetContext.java
@@ -34,33 +34,33 @@
  *
  * <p>Example if all exceptions should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * fooPluginSetContext.runEach(foo -> foo.doFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if all exceptions, but one, should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * try {
  *   fooPluginSetContext.runEach(foo -> foo.doFoo(), MyException.class);
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values should be handled:
  *
- * <pre>
+ * <pre>{@code
  * for (PluginSetEntryContext<Foo> c : fooPluginSetContext) {
  *   if (c.call(foo -> foo.handles(x))) {
  *     c.run(foo -> foo.doFoo());
  *   }
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values and a single exception should be handled:
  *
- * <pre>
+ * <pre>{@code
  * try {
  *   for (PluginSetEntryContext<Foo> c : fooPluginSetContext) {
  *     if (c.call(foo -> foo.handles(x), MyException.class)) {
@@ -70,11 +70,11 @@
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if several exceptions should be handled:
  *
- * <pre>
+ * <pre>{@code
  * for (Extension<Foo> fooExtension : fooDynamicSet.entries()) {
  *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
  *     fooExtension.get().doFoo();
@@ -82,7 +82,7 @@
  *     // handle the exception
  *   }
  * }
- * </pre>
+ * }</pre>
  */
 public class PluginSetContext<T> implements Iterable<PluginSetEntryContext<T>> {
   private final DynamicSet<T> dynamicSet;
diff --git a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
index 2268c07..be97b52 100644
--- a/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
+++ b/java/com/google/gerrit/server/plugincontext/PluginSetEntryContext.java
@@ -37,40 +37,40 @@
  *
  * <p>Example if all exceptions should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * fooPluginSetEntryContext.run(foo -> foo.doFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if all exceptions, but one, should be caught and logged:
  *
- * <pre>
+ * <pre>{@code
  * try {
  *   fooPluginSetEntryContext.run(foo -> foo.doFoo(), MyException.class);
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Object result = fooPluginSetEntryContext.call(foo -> foo.getFoo());
- * </pre>
+ * }</pre>
  *
  * <p>Example if return values and a single exception should be handled:
  *
- * <pre>
+ * <pre>{@code
  * Object result;
  * try {
  *   result = fooPluginSetEntryContext.call(foo -> foo.getFoo(), MyException.class);
  * } catch (MyException e) {
  *   // handle the exception
  * }
- * </pre>
+ * }</pre>
  *
  * <p>Example if several exceptions should be handled:
  *
- * <pre>
+ * <pre>{@code
  * for (Extension<Foo> fooExtension : fooDynamicSet.entries()) {
  *   try (TraceContext traceContext = PluginContext.newTrace(fooExtension)) {
  *     fooExtension.get().doFoo();
@@ -78,7 +78,7 @@
  *     // handle the exception
  *   }
  * }
- * </pre>
+ * }</pre>
  */
 public class PluginSetEntryContext<T> {
   private final Extension<T> extension;
diff --git a/java/com/google/gerrit/server/plugins/PluginLoader.java b/java/com/google/gerrit/server/plugins/PluginLoader.java
index 0a06081..8d17d85 100644
--- a/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -253,7 +253,7 @@
           FileSnapshot snapshot = FileSnapshot.save(off.toFile());
           Plugin offPlugin = loadPlugin(name, off, snapshot);
           disabled.put(name, offPlugin);
-        } catch (Throwable e) {
+        } catch (Exception e) {
           // This shouldn't happen, as the plugin was loaded earlier.
           logger.atWarning().withCause(e.getCause()).log(
               "Cannot load disabled plugin %s", active.getName());
@@ -510,7 +510,7 @@
       if (!newPlugin.isDisabled()) {
         try {
           newPlugin.start(env);
-        } catch (Throwable e) {
+        } catch (Exception e) {
           newPlugin.stop(env);
           throw e;
         }
@@ -528,7 +528,7 @@
       }
       broken.remove(name);
       return newPlugin;
-    } catch (Throwable err) {
+    } catch (Exception err) {
       broken.put(name, snapshot);
       throw new PluginInstallException(err);
     }
diff --git a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
index ab347e5..762e244 100644
--- a/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
+++ b/java/com/google/gerrit/server/project/DefaultProjectNameLockManager.java
@@ -25,7 +25,7 @@
 @Singleton
 public class DefaultProjectNameLockManager implements ProjectNameLockManager {
 
-  public static class Module extends AbstractModule {
+  public static class DefaultProjectNameLockManagerModule extends AbstractModule {
     @Override
     protected void configure() {
       DynamicItem.bind(binder(), ProjectNameLockManager.class)
diff --git a/java/com/google/gerrit/server/project/LabelDefinitionJson.java b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
index 730162f..63c9d22 100644
--- a/java/com/google/gerrit/server/project/LabelDefinitionJson.java
+++ b/java/com/google/gerrit/server/project/LabelDefinitionJson.java
@@ -32,6 +32,7 @@
     label.defaultValue = labelType.getDefaultValue();
     label.branches = labelType.getRefPatterns() != null ? labelType.getRefPatterns() : null;
     label.canOverride = toBoolean(labelType.isCanOverride());
+    label.copyCondition = labelType.getCopyCondition().orElse(null);
     label.copyAnyScore = toBoolean(labelType.isCopyAnyScore());
     label.copyMinScore = toBoolean(labelType.isCopyMinScore());
     label.copyMaxScore = toBoolean(labelType.isCopyMaxScore());
diff --git a/java/com/google/gerrit/server/project/NullProjectCache.java b/java/com/google/gerrit/server/project/NullProjectCache.java
new file mode 100644
index 0000000..d19a726
--- /dev/null
+++ b/java/com/google/gerrit/server/project/NullProjectCache.java
@@ -0,0 +1,88 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.gerrit.entities.AccountGroup.UUID;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.exceptions.StorageException;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.Set;
+
+/** An implementation of {@link ProjectCache} with no operations implemented. */
+public class NullProjectCache implements ProjectCache {
+
+  @Override
+  public ProjectState getAllProjects() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ProjectState getAllUsers() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Optional<ProjectState> get(NameKey projectName) throws StorageException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void evict(NameKey p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void remove(Project p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void remove(NameKey name) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSortedSet<NameKey> all() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public Set<UUID> guessRelevantGroupUUIDs() {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSortedSet<NameKey> byName(String prefix) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void onCreateProject(NameKey newProjectName) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void evictAndReindex(Project p) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public void evictAndReindex(NameKey p) {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/ProjectCache.java b/java/com/google/gerrit/server/project/ProjectCache.java
index 5124e0e..fb0a4ec 100644
--- a/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/java/com/google/gerrit/server/project/ProjectCache.java
@@ -42,10 +42,10 @@
     return () -> new NoSuchProjectException(nameKey);
   }
 
-  /** @return the parent state for all projects on this server. */
+  /** Returns the parent state for all projects on this server. */
   ProjectState getAllProjects();
 
-  /** @return the project state of the project storing meta data for all users. */
+  /** Returns the project state of the project storing meta data for all users. */
   ProjectState getAllUsers();
 
   /**
@@ -91,12 +91,12 @@
    */
   void remove(Project.NameKey name);
 
-  /** @return sorted iteration of projects. */
+  /** Returns sorted iteration of projects. */
   ImmutableSortedSet<Project.NameKey> all();
 
   /**
-   * @return estimated set of relevant groups extracted from hot project access rules. If the cache
-   *     is cold or too small for the entire project set of the server, this set may be incomplete.
+   * Returns estimated set of relevant groups extracted from hot project access rules. If the cache
+   * is cold or too small for the entire project set of the server, this set may be incomplete.
    */
   Set<AccountGroup.UUID> guessRelevantGroupUUIDs();
 
diff --git a/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index 5ea95fb..7e7c7be 100644
--- a/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -51,9 +51,9 @@
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.cache.serialize.ProtobufSerializer;
 import com.google.gerrit.server.cache.serialize.entities.CachedProjectConfigSerializer;
+import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
@@ -78,10 +78,15 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.lib.StoredConfig;
 
-/** Cache of project information, including access rights. */
+/**
+ * Cache of project information, including access rights.
+ *
+ * <p>The data of a project is the project's project.config in refs/meta/config parsed out as an
+ * immutable value. It's keyed purely by the refs/meta/config SHA-1. We also cache the same value
+ * keyed by name. The latter mapping can become outdated, so data must be evicted explicitly.
+ */
 @Singleton
 public class ProjectCacheImpl implements ProjectCache {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -303,27 +308,22 @@
   /**
    * Returns a {@code MurMur128} hash of the contents of {@code etc/All-Projects-project.config}.
    */
-  public static byte[] allProjectsFileProjectConfigHash(
-      AllProjectsName allProjectsName, SitePaths sitePaths) {
+  public static byte[] allProjectsFileProjectConfigHash(Optional<StoredConfig> allProjectsConfig) {
     // Hash the contents of All-Projects-project.config
     // This is a way for administrators to orchestrate project.config changes across many Gerrit
     // instances.
     // When this file changes, we need to make sure we disregard persistently cached project
     // state.
-    FileBasedConfig fileBasedConfig =
-        new FileBasedConfig(
-            sitePaths
-                .etc_dir
-                .resolve(allProjectsName.get())
-                .resolve(ProjectConfig.PROJECT_CONFIG)
-                .toFile(),
-            FS.DETECTED);
+    if (!allProjectsConfig.isPresent()) {
+      // If the project.config file is not present, this is equal to an empty config file:
+      return Hashing.murmur3_128().hashString("", UTF_8).asBytes();
+    }
     try {
-      fileBasedConfig.load();
+      allProjectsConfig.get().load();
     } catch (IOException | ConfigInvalidException e) {
       throw new IllegalStateException(e);
     }
-    return Hashing.murmur3_128().hashString(fileBasedConfig.toText(), UTF_8).asBytes();
+    return Hashing.murmur3_128().hashString(allProjectsConfig.get().toText(), UTF_8).asBytes();
   }
 
   @Singleton
@@ -333,7 +333,7 @@
     private final ListeningExecutorService cacheRefreshExecutor;
     private final Counter2<String, Boolean> refreshCounter;
     private final AllProjectsName allProjectsName;
-    private final SitePaths sitePaths;
+    private final AllProjectsConfigProvider allProjectsConfigProvider;
 
     @Inject
     InMemoryLoader(
@@ -343,18 +343,25 @@
         @CacheRefreshExecutor ListeningExecutorService cacheRefreshExecutor,
         MetricMaker metricMaker,
         AllProjectsName allProjectsName,
-        SitePaths sitePaths) {
+        AllProjectsConfigProvider allProjectsConfigProvider) {
       this.persistedCache = persistedCache;
       this.repoManager = repoManager;
       this.cacheRefreshExecutor = cacheRefreshExecutor;
       refreshCounter =
           metricMaker.newCounter(
               "caches/refresh_count",
-              new Description("count").setRate(),
-              Field.ofString("cache", Metadata.Builder::className).build(),
-              Field.ofBoolean("outdated", Metadata.Builder::outdated).build());
+              new Description(
+                      "The number of refreshes per cache with an indicator if a reload was"
+                          + " necessary.")
+                  .setRate(),
+              Field.ofString("cache", Metadata.Builder::className)
+                  .description("The name of the cache.")
+                  .build(),
+              Field.ofBoolean("outdated", Metadata.Builder::outdated)
+                  .description("Whether the cache entry was outdated on reload.")
+                  .build());
       this.allProjectsName = allProjectsName;
-      this.sitePaths = sitePaths;
+      this.allProjectsConfigProvider = allProjectsConfigProvider;
     }
 
     @Override
@@ -368,7 +375,8 @@
             Cache.ProjectCacheKeyProto.newBuilder().setProject(key.get());
         Ref configRef = git.exactRef(RefNames.REFS_CONFIG);
         if (key.get().equals(allProjectsName.get())) {
-          byte[] fileHash = allProjectsFileProjectConfigHash(allProjectsName, sitePaths);
+          Optional<StoredConfig> allProjectsConfig = allProjectsConfigProvider.get(allProjectsName);
+          byte[] fileHash = allProjectsFileProjectConfigHash(allProjectsConfig);
           keyProto.setGlobalConfigRevision(ByteString.copyFrom(fileHash));
         }
         if (configRef != null) {
diff --git a/java/com/google/gerrit/server/project/ProjectConfig.java b/java/com/google/gerrit/server/project/ProjectConfig.java
index 4a063a3..c101ddf 100644
--- a/java/com/google/gerrit/server/project/ProjectConfig.java
+++ b/java/com/google/gerrit/server/project/ProjectConfig.java
@@ -57,15 +57,17 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.exceptions.InvalidNameException;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.server.account.GroupBackend;
+import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.ValidationError;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
@@ -96,8 +98,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.StoredConfig;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
-import org.eclipse.jgit.util.FS;
 
 public class ProjectConfig extends VersionedMetaData implements ValidationError.Sink {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -110,6 +110,7 @@
   public static final String KEY_ALLOW_POST_SUBMIT = "allowPostSubmit";
   public static final String KEY_IGNORE_SELF_APPROVAL = "ignoreSelfApproval";
   public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
+  public static final String KEY_COPY_CONDITION = "copyCondition";
   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";
@@ -123,6 +124,13 @@
   public static final String KEY_CAN_OVERRIDE = "canOverride";
   public static final String KEY_BRANCH = "branch";
 
+  public static final String SUBMIT_REQUIREMENT = "submit-requirement";
+  public static final String KEY_SR_DESCRIPTION = "description";
+  public static final String KEY_SR_APPLICABILITY_EXPRESSION = "applicableIf";
+  public static final String KEY_SR_SUBMITTABILITY_EXPRESSION = "submittableIf";
+  public static final String KEY_SR_OVERRIDE_EXPRESSION = "overrideIf";
+  public static final String KEY_SR_OVERRIDE_IN_CHILD_PROJECTS = "canOverrideInChildProjects";
+
   public static final String KEY_MATCH = "match";
   private static final String KEY_HTML = "html";
   public static final String KEY_LINK = "link";
@@ -192,28 +200,22 @@
   // ProjectCache, so this would retain lots more memory.
   @Singleton
   public static class Factory {
-    @Nullable
-    public static StoredConfig getBaseConfig(
-        SitePaths sitePaths, AllProjectsName allProjects, Project.NameKey projectName) {
-      return projectName.equals(allProjects)
-          // Delay loading till onLoad method.
-          ? new FileBasedConfig(
-              sitePaths.etc_dir.resolve(allProjects.get()).resolve(PROJECT_CONFIG).toFile(),
-              FS.DETECTED)
-          : null;
-    }
-
-    private final SitePaths sitePaths;
-    private final AllProjectsName allProjects;
+    private final AllProjectsName allProjectsName;
+    private final AllProjectsConfigProvider allProjectsConfigProvider;
 
     @Inject
-    Factory(SitePaths sitePaths, AllProjectsName allProjects) {
-      this.sitePaths = sitePaths;
-      this.allProjects = allProjects;
+    Factory(AllProjectsName allProjectsName, AllProjectsConfigProvider allProjectsConfigProvider) {
+      this.allProjectsName = allProjectsName;
+      this.allProjectsConfigProvider = allProjectsConfigProvider;
     }
 
     public ProjectConfig create(Project.NameKey projectName) {
-      return new ProjectConfig(projectName, getBaseConfig(sitePaths, allProjects, projectName));
+      return new ProjectConfig(
+          projectName,
+          projectName.equals(allProjectsName)
+              ? allProjectsConfigProvider.get(allProjectsName)
+              : Optional.empty(),
+          allProjectsName);
     }
 
     public ProjectConfig read(MetaDataUpdate update) throws IOException, ConfigInvalidException {
@@ -238,7 +240,8 @@
     }
   }
 
-  private final StoredConfig baseConfig;
+  private final Optional<StoredConfig> baseConfig;
+  private final AllProjectsName allProjectsName;
 
   private Project project;
   private AccountsSection accountsSection;
@@ -248,6 +251,7 @@
   private Map<String, ContributorAgreement> contributorAgreements;
   private Map<String, NotifyConfig> notifySections;
   private Map<String, LabelType> labelSections;
+  private Map<String, SubmitRequirement> submitRequirementSections;
   private ConfiguredMimeTypes mimeTypes;
   private Map<Project.NameKey, SubscribeSection> subscribeSections;
   private Map<String, StoredCommentLinkInfo> commentLinkSections;
@@ -275,18 +279,40 @@
             .setCheckReceivedObjects(checkReceivedObjects)
             .setExtensionPanelSections(extensionPanelSections);
     groupList.byUUID().values().forEach(g -> builder.addGroup(g));
-    accessSections.values().forEach(a -> builder.addAccessSection(a));
     contributorAgreements.values().forEach(c -> builder.addContributorAgreement(c));
     notifySections.values().forEach(n -> builder.addNotifySection(n));
     subscribeSections.values().forEach(s -> builder.addSubscribeSection(s));
     commentLinkSections.values().forEach(c -> builder.addCommentLinkSection(c));
     labelSections.values().forEach(l -> builder.addLabelSection(l));
+    submitRequirementSections.values().forEach(sr -> builder.addSubmitRequirementSection(sr));
     pluginConfigs
         .entrySet()
         .forEach(c -> builder.addPluginConfig(c.getKey(), c.getValue().toText()));
     projectLevelConfigs
         .entrySet()
         .forEach(c -> builder.addProjectLevelConfig(c.getKey(), c.getValue().toText()));
+
+    if (projectName.equals(allProjectsName)) {
+      // Filter out permissions that aren't allowed to be set on All-Projects
+      accessSections
+          .values()
+          .forEach(
+              a -> {
+                List<Permission.Builder> copy = new ArrayList<>();
+                for (Permission p : a.getPermissions()) {
+                  if (Permission.canBeOnAllProjects(a.getName(), p.getName())) {
+                    copy.add(p.toBuilder());
+                  }
+                }
+                AccessSection section =
+                    AccessSection.builder(a.getName())
+                        .modifyPermissions(permissions -> permissions.addAll(copy))
+                        .build();
+                builder.addAccessSection(section);
+              });
+    } else {
+      accessSections.values().forEach(a -> builder.addAccessSection(a));
+    }
     return builder.build();
   }
 
@@ -342,9 +368,13 @@
     requireNonNull(commentLinkSections.remove(name));
   }
 
-  private ProjectConfig(Project.NameKey projectName, @Nullable StoredConfig baseConfig) {
+  private ProjectConfig(
+      Project.NameKey projectName,
+      Optional<StoredConfig> baseConfig,
+      AllProjectsName allProjectsName) {
     this.projectName = projectName;
     this.baseConfig = baseConfig;
+    this.allProjectsName = allProjectsName;
   }
 
   public void load(Repository repo) throws IOException, ConfigInvalidException {
@@ -512,6 +542,14 @@
     return labelSections;
   }
 
+  public Map<String, SubmitRequirement> getSubmitRequirementSections() {
+    return submitRequirementSections;
+  }
+
+  public void upsertSubmitRequirement(SubmitRequirement requirement) {
+    submitRequirementSections.put(requirement.name(), requirement);
+  }
+
   /** Adds or replaces the given {@link LabelType} in this config. */
   public void upsertLabelType(LabelType labelType) {
     labelSections.put(labelType.getName(), labelType);
@@ -548,32 +586,32 @@
     groupList.renameGroup(uuid, newName);
   }
 
-  /** @return the group reference, if the group is used by at least one rule. */
+  /** Returns the group reference, if the group is used by at least one rule. */
   public GroupReference getGroup(AccountGroup.UUID uuid) {
     return groupList.byUUID(uuid);
   }
 
   /**
-   * @return the group reference corresponding to the specified group name if the group is used by
-   *     at least one rule or plugin value.
+   * Returns the group reference corresponding to the specified group name if the group is used by
+   * at least one rule or plugin value.
    */
   public GroupReference getGroup(String groupName) {
     return groupList.byName(groupName);
   }
 
   /**
-   * @return the project's rules.pl ObjectId, if present in the branch. Null if it doesn't exist.
+   * Returns the project's rules.pl ObjectId, if present in the branch. Null if it doesn't exist.
    */
   public ObjectId getRulesId() {
     return rulesId;
   }
 
-  /** @return the maxObjectSizeLimit configured on this project, or zero if not configured. */
+  /** Returns the maxObjectSizeLimit configured on this project, or zero if not configured. */
   public long getMaxObjectSizeLimit() {
     return maxObjectSizeLimit;
   }
 
-  /** @return the checkReceivedObjects for this project, default is true. */
+  /** Returns the checkReceivedObjects for this project, default is true. */
   public boolean getCheckReceivedObjects() {
     return checkReceivedObjects;
   }
@@ -615,8 +653,8 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    if (baseConfig != null) {
-      baseConfig.load();
+    if (baseConfig.isPresent()) {
+      baseConfig.get().load();
     }
     readGroupList();
 
@@ -631,7 +669,7 @@
     if (rc.getStringList(ACCESS, null, KEY_INHERIT_FROM).length > 1) {
       // The config must not contain more than one parent to inherit from
       // as there is no guarantee which of the parents would be used then.
-      error(ValidationError.create(PROJECT_CONFIG, "Cannot inherit from multiple projects"));
+      error("Cannot inherit from multiple projects");
     }
     p.setParent(rc.getString(ACCESS, null, KEY_INHERIT_FROM));
 
@@ -661,6 +699,7 @@
     loadBranchOrderSection(rc);
     loadNotifySections(rc);
     loadLabelSections(rc);
+    loadSubmitRequirementSections(rc);
     loadCommentLinkSections(rc);
     loadSubscribeSections(rc);
     mimeTypes = ConfiguredMimeTypes.create(projectName.get(), rc);
@@ -683,10 +722,8 @@
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+            String.format(
+                "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
       }
       lowerNames.put(lower, name);
       extensionPanelSections.put(
@@ -712,26 +749,14 @@
         ca.setAutoVerify(null);
       } else if (rules.size() > 1) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + CONTRIBUTOR_AGREEMENT
-                    + "."
-                    + name
-                    + "."
-                    + KEY_AUTO_VERIFY
-                    + ": at most one group may be set"));
+            String.format(
+                "Invalid rule in %s.%s.%s: at most one group may be set",
+                CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY));
       } else if (rules.get(0).getAction() != Action.ALLOW) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + CONTRIBUTOR_AGREEMENT
-                    + "."
-                    + name
-                    + "."
-                    + KEY_AUTO_VERIFY
-                    + ": the group must be allowed"));
+            String.format(
+                "Invalid rule in %s.%s.%s: the group must be allowed",
+                CONTRIBUTOR_AGREEMENT, name, KEY_AUTO_VERIFY));
       } else {
         ca.setAutoVerify(rules.get(0).getGroup());
       }
@@ -779,21 +804,16 @@
           if (ref.getUUID() != null) {
             n.addGroup(ref);
           } else {
-            error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+            error(String.format("group \"%s\" not in %s", ref.getName(), GroupList.FILE_NAME));
           }
         } else if (dst.startsWith("user ")) {
-          error(ValidationError.create(PROJECT_CONFIG, dst + " not supported"));
+          error(String.format("%s not supported", dst));
         } else {
           try {
             n.addAddress(Address.parse(dst));
           } catch (IllegalArgumentException err) {
             error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    "notify section \"" + sectionName + "\" has invalid email \"" + dst + "\""));
+                String.format("notify section \"%s\" has invalid email \"%s\"", sectionName, dst));
           }
         }
       }
@@ -854,7 +874,7 @@
     try {
       RefPattern.validateRegExp(refPattern);
     } catch (InvalidNameException e) {
-      error(ValidationError.create(PROJECT_CONFIG, "Invalid ref name: " + e.getMessage()));
+      error(String.format("Invalid ref name: %s", e.getMessage()));
       return false;
     }
     return true;
@@ -882,9 +902,7 @@
         // to fail fast if any of the patterns are invalid.
         patterns.add(Pattern.compile(patternString).pattern());
       } catch (PatternSyntaxException e) {
-        error(
-            ValidationError.create(
-                PROJECT_CONFIG, "Invalid regular expression: " + e.getMessage()));
+        error(String.format("Invalid regular expression: %s", e.getMessage()));
         continue;
       }
     }
@@ -911,15 +929,11 @@
         rule = PermissionRule.fromString(ruleString, useRange);
       } catch (IllegalArgumentException notRule) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                "Invalid rule in "
-                    + section
-                    + (subsection != null ? "." + subsection : "")
-                    + "."
-                    + varName
-                    + ": "
-                    + notRule.getMessage()));
+            String.format(
+                "Invalid rule in %s.%s: %s",
+                section + (subsection != null ? "." + subsection : ""),
+                varName,
+                notRule.getMessage()));
         continue;
       }
 
@@ -930,9 +944,7 @@
         // all rules in the same file share the same GroupReference.
         //
         ref = groupList.resolve(rule.getGroup());
-        error(
-            ValidationError.create(
-                PROJECT_CONFIG, "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
+        error(String.format("group \"%s\" not in %s", ref.getName(), GroupList.FILE_NAME));
       }
 
       perm.add(rule.toBuilder().setGroup(ref));
@@ -950,16 +962,56 @@
     return LabelValue.create(Shorts.checkedCast(PermissionRule.parseInt(parts.get(0))), valueText);
   }
 
+  private void loadSubmitRequirementSections(Config rc) {
+    Map<String, String> lowerNames = new HashMap<>();
+    submitRequirementSections = new LinkedHashMap<>();
+    for (String name : rc.getSubsections(SUBMIT_REQUIREMENT)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            String.format(
+                "Submit requirement '%s' conflicts with '%s'.", name, lowerNames.get(lower)));
+        continue;
+      }
+      lowerNames.put(lower, name);
+      String description = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION);
+      String appExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_APPLICABILITY_EXPRESSION);
+      String blockExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_SUBMITTABILITY_EXPRESSION);
+      String overrideExpr = rc.getString(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_EXPRESSION);
+      boolean canInherit =
+          rc.getBoolean(SUBMIT_REQUIREMENT, name, KEY_SR_OVERRIDE_IN_CHILD_PROJECTS, false);
+
+      if (blockExpr == null) {
+        error(
+            String.format(
+                "Submit requirement '%s' does not define a submittability expression.", name));
+        continue;
+      }
+
+      // TODO(SR): add expressions validation. Expressions are stored as strings so we need to
+      // validate their syntax.
+
+      SubmitRequirement submitRequirement =
+          SubmitRequirement.builder()
+              .setName(name)
+              .setDescription(Optional.ofNullable(description))
+              .setApplicabilityExpression(SubmitRequirementExpression.of(appExpr))
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(blockExpr))
+              .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+              .setAllowOverrideInChildProjects(canInherit)
+              .build();
+
+      submitRequirementSections.put(name, submitRequirement);
+    }
+  }
+
   private void loadLabelSections(Config rc) {
     Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
     labelSections = new LinkedHashMap<>();
     for (String name : rc.getSubsections(LABEL)) {
       String lower = name.toLowerCase();
       if (lowerNames.containsKey(lower)) {
-        error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+        error(String.format("Label \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower)));
       }
       lowerNames.put(lower, name);
 
@@ -971,18 +1023,13 @@
           if (allValues.add(labelValue.getValue())) {
             values.add(labelValue);
           } else {
-            error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name)));
+            error(String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_VALUE, value, name));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              ValidationError.create(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\": %s",
-                      KEY_VALUE, value, name, notValue.getMessage())));
+              String.format(
+                  "Invalid %s \"%s\" for label \"%s\": %s",
+                  KEY_VALUE, value, name, notValue.getMessage()));
         }
       }
 
@@ -990,7 +1037,7 @@
       try {
         label = LabelType.builder(name, values);
       } catch (IllegalArgumentException badName) {
-        error(ValidationError.create(PROJECT_CONFIG, String.format("Invalid label \"%s\"", name)));
+        error(String.format("Invalid label \"%s\"", name));
         continue;
       }
 
@@ -1001,24 +1048,19 @@
               : Optional.of(LabelFunction.MAX_WITH_BLOCK);
       if (!function.isPresent()) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Invalid %s for label \"%s\". Valid names are: %s",
-                    KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet()))));
+            String.format(
+                "Invalid %s for label \"%s\". Valid names are: %s",
+                KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet())));
       }
       label.setFunction(function.orElse(null));
+      label.setCopyCondition(rc.getString(LABEL, name, KEY_COPY_CONDITION));
 
       if (!values.isEmpty()) {
         short dv = (short) rc.getInt(LABEL, name, KEY_DEFAULT_VALUE, 0);
         if (isInRange(dv, values)) {
           label.setDefaultValue(dv);
         } else {
-          error(
-              ValidationError.create(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name)));
+          error(String.format("Invalid %s \"%s\" for label \"%s\"", KEY_DEFAULT_VALUE, dv, name));
         }
       }
       label.setAllowPostSubmit(
@@ -1067,18 +1109,13 @@
           short copyValue = Shorts.checkedCast(PermissionRule.parseInt(value));
           if (!copyValues.add(copyValue)) {
             error(
-                ValidationError.create(
-                    PROJECT_CONFIG,
-                    String.format(
-                        "Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name)));
+                String.format("Duplicate %s \"%s\" for label \"%s\"", KEY_COPY_VALUE, value, name));
           }
         } catch (IllegalArgumentException notValue) {
           error(
-              ValidationError.create(
-                  PROJECT_CONFIG,
-                  String.format(
-                      "Invalid %s \"%s\" for label \"%s\": %s",
-                      KEY_COPY_VALUE, value, name, notValue.getMessage())));
+              String.format(
+                  "Invalid %s \"%s\" for label \"%s\": %s",
+                  KEY_COPY_VALUE, value, name, notValue.getMessage()));
         }
       }
       label.setCopyValues(copyValues);
@@ -1113,18 +1150,14 @@
         commentLinkSections.put(name, buildCommentLink(rc, name, false));
       } catch (PatternSyntaxException e) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Invalid pattern \"%s\" in commentlink.%s.match: %s",
-                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+            String.format(
+                "Invalid pattern \"%s\" in commentlink.%s.match: %s",
+                rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage()));
       } catch (IllegalArgumentException e) {
         error(
-            ValidationError.create(
-                PROJECT_CONFIG,
-                String.format(
-                    "Error in pattern \"%s\" in commentlink.%s.match: %s",
-                    rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage())));
+            String.format(
+                "Error in pattern \"%s\" in commentlink.%s.match: %s",
+                rc.getString(COMMENTLINK, name, KEY_MATCH), name, e.getMessage()));
       }
     }
   }
@@ -1166,9 +1199,7 @@
         if (groupName != null) {
           GroupReference ref = groupList.byName(groupName);
           if (ref == null) {
-            error(
-                ValidationError.create(
-                    PROJECT_CONFIG, "group \"" + groupName + "\" not in " + GroupList.FILE_NAME));
+            error(String.format("group \"%s\" not in %s", groupName, GroupList.FILE_NAME));
           }
           rc.setString(PLUGIN, plugin, name, value);
         }
@@ -1264,6 +1295,7 @@
     savePluginSections(rc, keepGroups);
     groupList.retainUUIDs(keepGroups);
     saveLabelSections(rc);
+    saveSubmitRequirementSections(rc);
     saveCommentLinkSections(rc);
     saveSubscribeSections(rc);
     saveBranchOrderSection(rc);
@@ -1596,6 +1628,11 @@
         values.add(value.format().trim());
       }
       rc.setStringList(LABEL, name, KEY_VALUE, values);
+      if (label.getCopyCondition().isPresent()) {
+        rc.setString(LABEL, name, KEY_COPY_CONDITION, label.getCopyCondition().get());
+      } else {
+        rc.unset(LABEL, name, KEY_COPY_CONDITION);
+      }
 
       List<String> refPatterns = label.getRefPatterns();
       if (refPatterns != null && !refPatterns.isEmpty()) {
@@ -1610,6 +1647,45 @@
     }
   }
 
+  private void saveSubmitRequirementSections(Config rc) {
+    unsetSection(rc, SUBMIT_REQUIREMENT);
+
+    if (submitRequirementSections != null) {
+      for (Map.Entry<String, SubmitRequirement> entry : submitRequirementSections.entrySet()) {
+        String name = entry.getKey();
+        SubmitRequirement sr = entry.getValue();
+
+        if (sr.description().isPresent()) {
+          rc.setString(SUBMIT_REQUIREMENT, name, KEY_SR_DESCRIPTION, sr.description().get());
+        }
+        if (sr.applicabilityExpression().isPresent()) {
+          rc.setString(
+              SUBMIT_REQUIREMENT,
+              name,
+              KEY_SR_APPLICABILITY_EXPRESSION,
+              sr.applicabilityExpression().get().expressionString());
+        }
+        rc.setString(
+            SUBMIT_REQUIREMENT,
+            name,
+            KEY_SR_SUBMITTABILITY_EXPRESSION,
+            sr.submittabilityExpression().expressionString());
+        if (sr.overrideExpression().isPresent()) {
+          rc.setString(
+              SUBMIT_REQUIREMENT,
+              name,
+              KEY_SR_OVERRIDE_EXPRESSION,
+              sr.overrideExpression().get().expressionString());
+        }
+        rc.setBoolean(
+            SUBMIT_REQUIREMENT,
+            name,
+            KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+            sr.allowOverrideInChildProjects());
+      }
+    }
+  }
+
   private static void setBooleanConfigKey(
       Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
@@ -1673,11 +1749,15 @@
     try {
       return rc.getEnum(section, subsection, name, defaultValue);
     } catch (IllegalArgumentException err) {
-      error(ValidationError.create(PROJECT_CONFIG, err.getMessage()));
+      error(err.getMessage());
       return defaultValue;
     }
   }
 
+  private void error(String errorMessage) {
+    error(ValidationError.create(PROJECT_CONFIG, errorMessage));
+  }
+
   @Override
   public void error(ValidationError error) {
     if (validationErrors == null) {
diff --git a/java/com/google/gerrit/server/project/ProjectCreator.java b/java/com/google/gerrit/server/project/ProjectCreator.java
index c382f04..4e778a4 100644
--- a/java/com/google/gerrit/server/project/ProjectCreator.java
+++ b/java/com/google/gerrit/server/project/ProjectCreator.java
@@ -39,7 +39,8 @@
 import com.google.gerrit.server.extensions.events.AbstractNoNotifyEvent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.gerrit.server.git.GitRepositoryManager.Status;
+import com.google.gerrit.server.git.RepositoryExistsException;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.inject.Inject;
@@ -107,12 +108,9 @@
     final Project.NameKey nameKey = args.getProject();
     try {
       final String head = args.permissionsOnly ? RefNames.REFS_CONFIG : args.branch.get(0);
-      try (Repository repo = repoManager.openRepository(nameKey)) {
-        if (repo.getObjectDatabase().exists()) {
-          throw new ResourceConflictException("project \"" + nameKey + "\" exists");
-        }
-      } catch (RepositoryNotFoundException e) {
-        // It does not exist, safe to ignore.
+      Status status = repoManager.getRepositoryStatus(nameKey);
+      if (!status.equals(Status.NON_EXISTENT)) {
+        throw new RepositoryExistsException(nameKey, "Repository status: " + status);
       }
       try (Repository repo = repoManager.createRepository(nameKey)) {
         RefUpdate u = repo.updateRef(Constants.HEAD);
@@ -129,13 +127,11 @@
 
         return projectCache.get(nameKey).orElseThrow(illegalState(nameKey));
       }
-    } catch (RepositoryCaseMismatchException e) {
+    } catch (RepositoryExistsException e) {
       throw new ResourceConflictException(
           "Cannot create "
               + nameKey.get()
-              + " because the name is already occupied by another project."
-              + " The other project has the same name, only spelled in a"
-              + " different case.",
+              + " because the name is already occupied by another project.",
           e);
     } catch (RepositoryNotFoundException badName) {
       throw new BadRequestException("invalid project name: " + nameKey, badName);
diff --git a/java/com/google/gerrit/server/project/ProjectState.java b/java/com/google/gerrit/server/project/ProjectState.java
index 249eb35..6352f66 100644
--- a/java/com/google/gerrit/server/project/ProjectState.java
+++ b/java/com/google/gerrit/server/project/ProjectState.java
@@ -15,13 +15,12 @@
 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;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.AccessSection;
@@ -37,6 +36,7 @@
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubmitRequirement;
 import com.google.gerrit.entities.SubscribeSection;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -63,8 +63,8 @@
 import org.eclipse.jgit.lib.Config;
 
 /**
- * Cached information on a project. Must not contain any data derived from parents other than it's
- * immediate parent's {@link com.google.gerrit.entities.Project.NameKey}.
+ * State of a project, aggregated from the project and its parents. This is obtained from the {@link
+ * ProjectCache}. It should not be persisted across requests
  */
 public class ProjectState {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
@@ -136,8 +136,8 @@
   }
 
   /**
-   * @return cached computation of all global capabilities. This should only be invoked on the state
-   *     from {@link ProjectCache#getAllProjects()}. Null on any other project.
+   * Returns cached computation of all global capabilities. This should only be invoked on the state
+   * from {@link ProjectCache#getAllProjects()}. Null on any other project.
    */
   public CapabilityCollection getCapabilityCollection() {
     return capabilities;
@@ -266,35 +266,18 @@
 
   /** Get the sections that pertain only to this project. */
   List<SectionMatcher> getLocalAccessSections() {
-    List<SectionMatcher> sm = localAccessSections;
-    if (sm == null) {
-      ImmutableList<AccessSection> fromConfig =
-          cachedConfig.getAccessSections().values().stream()
-              .sorted(comparing(AccessSection::getName))
-              .collect(toImmutableList());
-      sm = new ArrayList<>(fromConfig.size());
-      for (AccessSection section : fromConfig) {
-        if (isAllProjects) {
-          List<Permission.Builder> copy = new ArrayList<>();
-          for (Permission p : section.getPermissions()) {
-            if (Permission.canBeOnAllProjects(section.getName(), p.getName())) {
-              copy.add(p.toBuilder());
-            }
-          }
-          section =
-              AccessSection.builder(section.getName())
-                  .modifyPermissions(permissions -> permissions.addAll(copy))
-                  .build();
-        }
-
-        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
-        if (matcher != null) {
-          sm.add(matcher);
-        }
-      }
-      localAccessSections = sm;
+    if (localAccessSections != null) {
+      return localAccessSections;
     }
-    return sm;
+    List<SectionMatcher> sm = new ArrayList<>(cachedConfig.getAccessSections().values().size());
+    for (AccessSection section : cachedConfig.getAccessSections().values()) {
+      SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
+      if (matcher != null) {
+        sm.add(matcher);
+      }
+    }
+    localAccessSections = sm;
+    return localAccessSections;
   }
 
   /**
@@ -314,9 +297,9 @@
   }
 
   /**
-   * @return all {@link AccountGroup}'s to which the owner privilege for 'refs/*' is assigned for
-   *     this project (the local owners), if there are no local owners the local owners of the
-   *     nearest parent project that has local owners are returned
+   * Returns all {@link AccountGroup}'s to which the owner privilege for 'refs/*' is assigned for
+   * this project (the local owners), if there are no local owners the local owners of the nearest
+   * parent project that has local owners are returned
    */
   public Set<AccountGroup.UUID> getOwners() {
     for (ProjectState p : tree()) {
@@ -328,10 +311,10 @@
   }
 
   /**
-   * @return all {@link AccountGroup}'s that are allowed to administrate the complete project. This
-   *     includes all groups to which the owner privilege for 'refs/*' is assigned for this project
-   *     (the local owners) and all groups to which the owner privilege for 'refs/*' is assigned for
-   *     one of the parent projects (the inherited owners).
+   * Returns all {@link AccountGroup}'s that are allowed to administrate the complete project. This
+   * includes all groups to which the owner privilege for 'refs/*' is assigned for this project (the
+   * local owners) and all groups to which the owner privilege for 'refs/*' is assigned for one of
+   * the parent projects (the inherited owners).
    */
   public Set<AccountGroup.UUID> getAllOwners() {
     Set<AccountGroup.UUID> result = new HashSet<>();
@@ -344,16 +327,16 @@
   }
 
   /**
-   * @return an iterable that walks through this project and then the parents of this project.
-   *     Starts from this project and progresses up the hierarchy to All-Projects.
+   * Returns an iterable that walks through this project and then the parents of this project.
+   * Starts from this project and progresses up the hierarchy to All-Projects.
    */
   public Iterable<ProjectState> tree() {
     return () -> new ProjectHierarchyIterator(projectCache, allProjectsName, ProjectState.this);
   }
 
   /**
-   * @return an iterable that walks in-order from All-Projects through the project hierarchy to this
-   *     project.
+   * Returns an iterable that walks in-order from All-Projects through the project hierarchy to this
+   * project.
    */
   public Iterable<ProjectState> treeInOrder() {
     List<ProjectState> projects = Lists.newArrayList(tree());
@@ -362,8 +345,8 @@
   }
 
   /**
-   * @return an iterable that walks through the parents of this project. Starts from the immediate
-   *     parent of this project and progresses up the hierarchy to All-Projects.
+   * Returns an iterable that walks through the parents of this project. Starts from the immediate
+   * parent of this project and progresses up the hierarchy to All-Projects.
    */
   public FluentIterable<ProjectState> parents() {
     return FluentIterable.from(tree()).skip(1);
@@ -392,6 +375,21 @@
     return false;
   }
 
+  /** Get all submit requirements for a project, including those from parent projects. */
+  public Map<String, SubmitRequirement> getSubmitRequirements() {
+    Map<String, SubmitRequirement> requirements = new LinkedHashMap<>();
+    for (ProjectState s : treeInOrder()) {
+      for (SubmitRequirement requirement : s.getConfig().getSubmitRequirementSections().values()) {
+        String lowerName = requirement.name().toLowerCase();
+        SubmitRequirement old = requirements.get(lowerName);
+        if (old == null || old.allowOverrideInChildProjects()) {
+          requirements.put(lowerName, requirement);
+        }
+      }
+    }
+    return ImmutableMap.copyOf(requirements);
+  }
+
   /** All available label types. */
   public LabelTypes getLabelTypes() {
     Map<String, LabelType> types = new LinkedHashMap<>();
diff --git a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
index 812d98d..fca1b36 100644
--- a/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
+++ b/java/com/google/gerrit/server/project/ProjectsConsistencyChecker.java
@@ -48,10 +48,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.ChangeIdPredicate;
-import com.google.gerrit.server.query.change.CommitPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
-import com.google.gerrit.server.query.change.RefPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -151,7 +148,7 @@
 
       // Base predicate which is fixed for every change query.
       Predicate<ChangeData> basePredicate =
-          and(new ProjectPredicate(projectName.get()), new RefPredicate(branch), open());
+          and(ChangePredicates.project(projectName), ChangePredicates.ref(branch), open());
 
       int maxLeafPredicates = indexConfig.maxTerms() - basePredicate.getLeafCount();
 
@@ -231,11 +228,11 @@
               }
 
               // Find changes that have a matching Change-Id.
-              predicates.add(new ChangeIdPredicate(changeId));
+              predicates.add(ChangePredicates.idPrefix(changeId));
             });
 
         // Find changes that have a matching commit.
-        predicates.add(new CommitPredicate(commit.name()));
+        predicates.add(ChangePredicates.commitPrefix(commit.name()));
       }
 
       if (!predicates.isEmpty()) {
diff --git a/java/com/google/gerrit/server/project/Reachable.java b/java/com/google/gerrit/server/project/Reachable.java
index 331b7da..342c2bc 100644
--- a/java/com/google/gerrit/server/project/Reachable.java
+++ b/java/com/google/gerrit/server/project/Reachable.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.change.IncludedInResolver;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.logging.TraceContext.TraceTimer;
@@ -27,11 +27,15 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.ReachabilityChecker;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -51,9 +55,9 @@
   }
 
   /**
-   * @return true if a commit is reachable from a given set of refs. This method enforces
-   *     permissions on the given set of refs and performs a reachability check. Tags are not
-   *     filtered separately and will only be returned if reachable by a provided ref.
+   * Returns true if a commit is reachable from a given set of refs. This method enforces
+   * permissions on the given set of refs and performs a reachability check. Tags are not filtered
+   * separately and will only be returned if reachable by a provided ref.
    */
   public boolean fromRefs(
       Project.NameKey project, Repository repo, RevCommit commit, List<Ref> refs) {
@@ -73,14 +77,33 @@
               .orElse(permissionBackend.currentUser())
               .project(project)
               .filter(refs, repo, RefFilterOptions.defaults());
+      Collection<RevCommit> visible = new ArrayList<>();
+      for (Ref r : filtered) {
+        try {
+          visible.add(rw.parseCommit(r.getObjectId()));
+        } catch (IncorrectObjectTypeException notCommit) {
+          // Its OK for a tag reference to point to a blob or a tree, this
+          // is common in the Linux kernel or git.git repository.
+          continue;
+        } catch (MissingObjectException notHere) {
+          // Log the problem with this branch, but keep processing.
+          logger.atWarning().log(
+              "Reference %s in %s points to dangling object %s",
+              r.getName(), repo.getDirectory(), r.getObjectId());
+          continue;
+        }
+      }
 
       // The filtering above already produces a voluminous trace. To separate the permission check
       // from the reachability check, do the trace here:
       try (TraceTimer timer =
           TraceContext.newTimer(
-              "IncludedInResolver.includedInAny",
+              "ReachabilityChecker.areAllReachable",
               Metadata.builder().projectName(project.get()).resourceCount(refs.size()).build())) {
-        return IncludedInResolver.includedInAny(repo, rw, commit, filtered);
+        ReachabilityChecker checker = rw.getObjectReader().createReachabilityChecker(rw);
+        Optional<RevCommit> unreachable =
+            checker.areAllReachable(ImmutableList.of(rw.parseCommit(commit)), visible.stream());
+        return !unreachable.isPresent();
       }
     } catch (IOException | PermissionBackendException e) {
       logger.atSevere().withCause(e).log(
diff --git a/java/com/google/gerrit/server/project/RefPatternMatcher.java b/java/com/google/gerrit/server/project/RefPatternMatcher.java
index b9076b3..be840b5 100644
--- a/java/com/google/gerrit/server/project/RefPatternMatcher.java
+++ b/java/com/google/gerrit/server/project/RefPatternMatcher.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.ParameterizedString;
+import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.CurrentUser;
@@ -32,6 +33,13 @@
 import java.util.stream.Stream;
 
 public abstract class RefPatternMatcher {
+  public static RefPatternMatcher getMatcher(AccessSection section) {
+    if (section.getNamePattern().isPresent()) {
+      return new Regexp(section.getNamePattern().get());
+    }
+    return getMatcher(section.getName());
+  }
+
   public static RefPatternMatcher getMatcher(String pattern) {
     if (containsParameters(pattern)) {
       return new ExpandParameters(pattern);
@@ -79,6 +87,10 @@
       pattern = Pattern.compile(re);
     }
 
+    Regexp(Pattern re) {
+      pattern = re;
+    }
+
     @Override
     public boolean match(String ref, CurrentUser user) {
       return pattern.matcher(ref).matches() || (isRE(ref) && pattern.pattern().equals(ref));
diff --git a/java/com/google/gerrit/server/project/RefResource.java b/java/com/google/gerrit/server/project/RefResource.java
index ac2735d..fcf6048 100644
--- a/java/com/google/gerrit/server/project/RefResource.java
+++ b/java/com/google/gerrit/server/project/RefResource.java
@@ -22,9 +22,9 @@
     super(projectState, user);
   }
 
-  /** @return the ref's name */
+  /** Returns the ref's name */
   public abstract String getRef();
 
-  /** @return the ref's revision */
+  /** Returns the ref's revision */
   public abstract String getRevision();
 }
diff --git a/java/com/google/gerrit/server/project/RefValidationHelper.java b/java/com/google/gerrit/server/project/RefValidationHelper.java
index 1912660..a6020a3 100644
--- a/java/com/google/gerrit/server/project/RefValidationHelper.java
+++ b/java/com/google/gerrit/server/project/RefValidationHelper.java
@@ -22,19 +22,20 @@
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.transport.ReceiveCommand.Type;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class RefValidationHelper {
   public interface Factory {
-    RefValidationHelper create(Type operationType);
+    RefValidationHelper create(ReceiveCommand.Type operationType);
   }
 
   private final RefOperationValidators.Factory refValidatorsFactory;
-  private final Type operationType;
+  private final ReceiveCommand.Type operationType;
 
   @Inject
   RefValidationHelper(
-      RefOperationValidators.Factory refValidatorsFactory, @Assisted Type operationType) {
+      RefOperationValidators.Factory refValidatorsFactory,
+      @Assisted ReceiveCommand.Type operationType) {
     this.refValidatorsFactory = refValidatorsFactory;
     this.operationType = operationType;
   }
diff --git a/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
index 652c49f..0336e8e 100644
--- a/java/com/google/gerrit/server/project/RemoveReviewerControl.java
+++ b/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -62,7 +62,7 @@
     checkRemoveReviewer(notes, currentUser, reviewer, 0);
   }
 
-  /** @return true if the user is allowed to remove this reviewer. */
+  /** Returns true if the user is allowed to remove this reviewer. */
   public boolean testRemoveReviewer(
       ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
       throws PermissionBackendException {
diff --git a/java/com/google/gerrit/server/project/SectionMatcher.java b/java/com/google/gerrit/server/project/SectionMatcher.java
index 763957e..3d7175f 100644
--- a/java/com/google/gerrit/server/project/SectionMatcher.java
+++ b/java/com/google/gerrit/server/project/SectionMatcher.java
@@ -28,7 +28,7 @@
   static SectionMatcher wrap(Project.NameKey project, AccessSection section) {
     String ref = section.getName();
     if (AccessSection.isValidRefSectionName(ref)) {
-      return new SectionMatcher(project, section, getMatcher(ref));
+      return new SectionMatcher(project, section, getMatcher(section));
     }
     return null;
   }
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
new file mode 100644
index 0000000..539edc1
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsAdapter.java
@@ -0,0 +1,220 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.MoreCollectors;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+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;
+
+/**
+ * Convert {@link com.google.gerrit.entities.SubmitRecord} entities to {@link
+ * com.google.gerrit.entities.SubmitRequirementResult}s.
+ */
+public class SubmitRequirementsAdapter {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private SubmitRequirementsAdapter() {}
+
+  /**
+   * Retrieve legacy submit records (created by label functions and other {@link
+   * com.google.gerrit.server.rules.SubmitRule}s) and convert them to submit requirement results.
+   */
+  public static Map<SubmitRequirement, SubmitRequirementResult> getLegacyRequirements(
+      SubmitRuleEvaluator.Factory evaluator, ChangeData cd) {
+    // We use SubmitRuleOptions.defaults() which does not recompute submit rules for closed changes.
+    // This doesn't have an effect since we never call this class (i.e. to evaluate submit
+    // requirements) for closed changes.
+    List<SubmitRecord> records = evaluator.create(SubmitRuleOptions.defaults()).evaluate(cd);
+    List<LabelType> labelTypes = cd.getLabelTypes().getLabelTypes();
+    ObjectId commitId = cd.currentPatchSet().commitId();
+    return records.stream()
+        .map(r -> createResult(r, labelTypes, commitId))
+        .flatMap(List::stream)
+        .collect(Collectors.toMap(sr -> sr.submitRequirement(), Function.identity()));
+  }
+
+  static List<SubmitRequirementResult> createResult(
+      SubmitRecord record, List<LabelType> labelTypes, ObjectId psCommitId) {
+    List<SubmitRequirementResult> results;
+    if (record.ruleName != null && record.ruleName.equals("gerrit~DefaultSubmitRule")) {
+      results = createFromDefaultSubmitRecord(record.labels, labelTypes, psCommitId);
+    } else {
+      results = createFromCustomSubmitRecord(record, psCommitId);
+    }
+    logger.atFine().log("Converted submit record %s to submit requirements %s", record, results);
+    return results;
+  }
+
+  private static List<SubmitRequirementResult> createFromDefaultSubmitRecord(
+      List<Label> labels, List<LabelType> labelTypes, ObjectId psCommitId) {
+    ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
+    for (Label label : labels) {
+      LabelType labelType = getLabelType(labelTypes, label.label);
+      if (!isBlocking(labelType)) {
+        continue;
+      }
+      ImmutableList<String> atoms = toExpressionAtomList(labelType);
+      SubmitRequirement.Builder req =
+          SubmitRequirement.builder()
+              .setName(label.label)
+              .setSubmittabilityExpression(toExpression(atoms))
+              .setAllowOverrideInChildProjects(labelType.isCanOverride());
+      result.add(
+          SubmitRequirementResult.builder()
+              .legacy(Optional.of(true))
+              .submitRequirement(req.build())
+              .submittabilityExpressionResult(
+                  createExpressionResult(toExpression(atoms), mapStatus(label), atoms))
+              .patchSetCommitId(psCommitId)
+              .build());
+    }
+    return result.build();
+  }
+
+  private static List<SubmitRequirementResult> createFromCustomSubmitRecord(
+      SubmitRecord record, ObjectId psCommitId) {
+    String ruleName = record.ruleName != null ? record.ruleName : "Custom-Rule";
+    if (record.labels == null || record.labels.isEmpty()) {
+      SubmitRequirement sr =
+          SubmitRequirement.builder()
+              .setName(ruleName)
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create(String.format("rule:%s", ruleName)))
+              .setAllowOverrideInChildProjects(false)
+              .build();
+      return ImmutableList.of(
+          SubmitRequirementResult.builder()
+              .legacy(Optional.of(true))
+              .submitRequirement(sr)
+              .submittabilityExpressionResult(
+                  createExpressionResult(
+                      sr.submittabilityExpression(), mapStatus(record), ImmutableList.of(ruleName)))
+              .patchSetCommitId(psCommitId)
+              .build());
+    }
+    ImmutableList.Builder<SubmitRequirementResult> result = ImmutableList.builder();
+    for (Label label : record.labels) {
+      String expressionString = String.format("label:%s=%s", label.label, ruleName);
+      SubmitRequirement sr =
+          SubmitRequirement.builder()
+              .setName(label.label)
+              .setSubmittabilityExpression(SubmitRequirementExpression.create(expressionString))
+              .setAllowOverrideInChildProjects(false)
+              .build();
+      result.add(
+          SubmitRequirementResult.builder()
+              .legacy(Optional.of(true))
+              .submitRequirement(sr)
+              .submittabilityExpressionResult(
+                  createExpressionResult(
+                      sr.submittabilityExpression(),
+                      mapStatus(label),
+                      ImmutableList.of(expressionString)))
+              .patchSetCommitId(psCommitId)
+              .build());
+    }
+    return result.build();
+  }
+
+  private static boolean isBlocking(LabelType labelType) {
+    return labelType.getFunction().isBlock() || labelType.getFunction().isRequired();
+  }
+
+  private static SubmitRequirementExpression toExpression(List<String> atoms) {
+    return SubmitRequirementExpression.create(String.join(" ", atoms));
+  }
+
+  private static ImmutableList<String> toExpressionAtomList(LabelType lt) {
+    String ignoreSelfApproval =
+        lt.isIgnoreSelfApproval() ? ",user=" + ChangeQueryBuilder.ARG_ID_NON_UPLOADER : "";
+    switch (lt.getFunction()) {
+      case MAX_WITH_BLOCK:
+        return ImmutableList.of(
+            String.format("label:%s=MAX", lt.getName()) + ignoreSelfApproval,
+            String.format("-label:%s=MIN", lt.getName()));
+      case ANY_WITH_BLOCK:
+        return ImmutableList.of(String.format(String.format("-label:%s=MIN", lt.getName())));
+      case MAX_NO_BLOCK:
+        return ImmutableList.of(
+            String.format(String.format("label:%s=MAX", lt.getName())) + ignoreSelfApproval);
+      case NO_BLOCK:
+      case NO_OP:
+      case PATCH_SET_LOCK:
+      default:
+        return ImmutableList.of();
+    }
+  }
+
+  private static Status mapStatus(Label label) {
+    SubmitRequirementExpressionResult.Status status = Status.PASS;
+    switch (label.status) {
+      case OK:
+      case MAY:
+        status = Status.PASS;
+        break;
+      case REJECT:
+      case NEED:
+      case IMPOSSIBLE:
+        status = Status.FAIL;
+        break;
+    }
+    return status;
+  }
+
+  private static Status mapStatus(SubmitRecord submitRecord) {
+    switch (submitRecord.status) {
+      case OK:
+      case CLOSED:
+      case FORCED:
+        return Status.PASS;
+      case NOT_READY:
+        return Status.FAIL;
+      case RULE_ERROR:
+      default:
+        return Status.ERROR;
+    }
+  }
+
+  private static SubmitRequirementExpressionResult createExpressionResult(
+      SubmitRequirementExpression expression, Status status, ImmutableList<String> atoms) {
+    return SubmitRequirementExpressionResult.create(
+        expression,
+        status,
+        status == Status.PASS ? atoms : ImmutableList.of(),
+        status == Status.FAIL ? atoms : ImmutableList.of());
+  }
+
+  private static LabelType getLabelType(List<LabelType> labelTypes, String labelName) {
+    return labelTypes.stream()
+        .filter(lt -> lt.getName().equals(labelName))
+        .collect(MoreCollectors.onlyElement());
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
new file mode 100644
index 0000000..402bb51
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluator.java
@@ -0,0 +1,52 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import java.util.Map;
+
+public interface SubmitRequirementsEvaluator {
+  /**
+   * Evaluate and return all submit requirement results for a change. Submit requirements are read
+   * from the project config of the project containing the change as well as parent projects.
+   *
+   * @param cd change data corresponding to a specific gerrit change
+   * @param includeLegacy if set to true, evaluate legacy {@link
+   *     com.google.gerrit.entities.SubmitRecord}s and convert them to submit requirements.
+   */
+  Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+      ChangeData cd, boolean includeLegacy);
+
+  /** Evaluate a single {@link SubmitRequirement} using change data. */
+  SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd);
+
+  /** Evaluate a {@link SubmitRequirementExpression} using change data. */
+  SubmitRequirementExpressionResult evaluateExpression(
+      SubmitRequirementExpression expression, ChangeData changeData);
+
+  /**
+   * Validate a {@link SubmitRequirementExpression}. Callers who wish to validate submit
+   * requirements upon creation or update should use this method.
+   *
+   * @param expression entity containing the expression string.
+   * @throws QueryParseException the expression string contains invalid syntax and can't be parsed.
+   */
+  void validateExpression(SubmitRequirementExpression expression) throws QueryParseException;
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
new file mode 100644
index 0000000..cc2c805
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsEvaluatorImpl.java
@@ -0,0 +1,148 @@
+// 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.project;
+
+import static com.google.gerrit.server.project.ProjectCache.illegalState;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.PredicateResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.SubmitRequirementChangeQueryBuilder;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import com.google.inject.Scopes;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/** Evaluates submit requirements for different change data. */
+public class SubmitRequirementsEvaluatorImpl implements SubmitRequirementsEvaluator {
+
+  private final Provider<SubmitRequirementChangeQueryBuilder> queryBuilder;
+  private final ProjectCache projectCache;
+  private final SubmitRuleEvaluator.Factory legacyEvaluator;
+
+  public static Module module() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        bind(SubmitRequirementsEvaluator.class)
+            .to(SubmitRequirementsEvaluatorImpl.class)
+            .in(Scopes.SINGLETON);
+      }
+    };
+  }
+
+  @Inject
+  private SubmitRequirementsEvaluatorImpl(
+      Provider<SubmitRequirementChangeQueryBuilder> queryBuilder,
+      ProjectCache projectCache,
+      SubmitRuleEvaluator.Factory legacyEvaluator) {
+    this.queryBuilder = queryBuilder;
+    this.projectCache = projectCache;
+    this.legacyEvaluator = legacyEvaluator;
+  }
+
+  @Override
+  public void validateExpression(SubmitRequirementExpression expression)
+      throws QueryParseException {
+    queryBuilder.get().parse(expression.expressionString());
+  }
+
+  @Override
+  public Map<SubmitRequirement, SubmitRequirementResult> evaluateAllRequirements(
+      ChangeData cd, boolean includeLegacy) {
+    Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements = getRequirements(cd);
+    Map<SubmitRequirement, SubmitRequirementResult> result = projectConfigRequirements;
+    if (includeLegacy) {
+      Map<SubmitRequirement, SubmitRequirementResult> legacyReqs =
+          SubmitRequirementsAdapter.getLegacyRequirements(legacyEvaluator, cd);
+      result =
+          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+              projectConfigRequirements, legacyReqs);
+    }
+    return ImmutableMap.copyOf(result);
+  }
+
+  @Override
+  public SubmitRequirementResult evaluateRequirement(SubmitRequirement sr, ChangeData cd) {
+    SubmitRequirementExpressionResult blockingResult =
+        evaluateExpression(sr.submittabilityExpression(), cd);
+
+    Optional<SubmitRequirementExpressionResult> applicabilityResult =
+        sr.applicabilityExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.applicabilityExpression().get(), cd))
+            : Optional.empty();
+
+    Optional<SubmitRequirementExpressionResult> overrideResult =
+        sr.overrideExpression().isPresent()
+            ? Optional.of(evaluateExpression(sr.overrideExpression().get(), cd))
+            : Optional.empty();
+
+    return SubmitRequirementResult.builder()
+        .legacy(Optional.of(false))
+        .submitRequirement(sr)
+        .patchSetCommitId(cd.currentPatchSet().commitId())
+        .submittabilityExpressionResult(blockingResult)
+        .applicabilityExpressionResult(applicabilityResult)
+        .overrideExpressionResult(overrideResult)
+        .build();
+  }
+
+  @Override
+  public SubmitRequirementExpressionResult evaluateExpression(
+      SubmitRequirementExpression expression, ChangeData changeData) {
+    try {
+      Predicate<ChangeData> predicate = queryBuilder.get().parse(expression.expressionString());
+      PredicateResult predicateResult = evaluatePredicateTree(predicate, changeData);
+      return SubmitRequirementExpressionResult.create(expression, predicateResult);
+    } catch (QueryParseException e) {
+      return SubmitRequirementExpressionResult.error(expression, e.getMessage());
+    }
+  }
+
+  /** Evaluate and return submit requirements stored in this project's config and its parents. */
+  private Map<SubmitRequirement, SubmitRequirementResult> getRequirements(ChangeData cd) {
+    ProjectState state = projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
+    Map<String, SubmitRequirement> requirements = state.getSubmitRequirements();
+    Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+    for (SubmitRequirement requirement : requirements.values()) {
+      result.put(requirement, evaluateRequirement(requirement, cd));
+    }
+    return result;
+  }
+
+  /** Evaluate the predicate recursively using change data. */
+  private PredicateResult evaluatePredicateTree(
+      Predicate<ChangeData> predicate, ChangeData changeData) {
+    PredicateResult.Builder predicateResult =
+        PredicateResult.builder()
+            .predicateString(predicate.isLeaf() ? predicate.getPredicateString() : "")
+            .status(predicate.asMatchable().match(changeData));
+    predicate
+        .getChildren()
+        .forEach(
+            c -> predicateResult.addChildPredicateResult(evaluatePredicateTree(c, changeData)));
+    return predicateResult.build();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
new file mode 100644
index 0000000..102d3f2
--- /dev/null
+++ b/java/com/google/gerrit/server/project/SubmitRequirementsUtil.java
@@ -0,0 +1,71 @@
+// 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.project;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * A utility class for different operations related to {@link
+ * com.google.gerrit.entities.SubmitRequirement}s.
+ */
+public class SubmitRequirementsUtil {
+
+  private SubmitRequirementsUtil() {}
+
+  /**
+   * Merge legacy and non-legacy submit requirement results. If both input maps have submit
+   * requirements with the same name and fulfillment status (according to {@link
+   * SubmitRequirementResult#fulfilled()}), we eliminate the entry from the {@code
+   * legacyRequirements} input map and only include the one from the {@code
+   * projectConfigRequirements} in the result.
+   *
+   * @param projectConfigRequirements map of {@link SubmitRequirement} to {@link
+   *     SubmitRequirementResult} containing results for submit requirements stored in the
+   *     project.config.
+   * @param legacyRequirements map of {@link SubmitRequirement} to {@link SubmitRequirementResult}
+   *     containing the results of converting legacy submit records to submit requirements.
+   * @return a map that is the result of merging both input maps, while eliminating requirements
+   *     with the same name and status.
+   */
+  public static Map<SubmitRequirement, SubmitRequirementResult> mergeLegacyAndNonLegacyRequirements(
+      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements,
+      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements) {
+    Map<SubmitRequirement, SubmitRequirementResult> result = new HashMap<>();
+    result.putAll(projectConfigRequirements);
+    Map<String, SubmitRequirementResult> requirementsByName =
+        projectConfigRequirements.entrySet().stream()
+            .collect(Collectors.toMap(sr -> sr.getKey().name().toLowerCase(), sr -> sr.getValue()));
+    for (Map.Entry<SubmitRequirement, SubmitRequirementResult> legacy :
+        legacyRequirements.entrySet()) {
+      String name = legacy.getKey().name().toLowerCase();
+      SubmitRequirementResult projectConfigResult = requirementsByName.get(name);
+      SubmitRequirementResult legacyResult = legacy.getValue();
+      if (projectConfigResult != null && matchByStatus(projectConfigResult, legacyResult)) {
+        continue;
+      }
+      result.put(legacy.getKey(), legacy.getValue());
+    }
+    return result;
+  }
+
+  /** Returns true if both input results are equal in allowing/disallowing change submission. */
+  private static boolean matchByStatus(SubmitRequirementResult r1, SubmitRequirementResult r2) {
+    return r1.fulfilled() == r2.fulfilled();
+  }
+}
diff --git a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index a7659d4..6c5559c 100644
--- a/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -18,17 +18,22 @@
 import static com.google.gerrit.server.project.ProjectCache.noSuchProject;
 
 import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.index.OnlineReindexMode;
+import com.google.gerrit.server.logging.CallerFinder;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.rules.DefaultSubmitRule;
 import com.google.gerrit.server.rules.PrologRule;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
@@ -41,12 +46,15 @@
  * the results through rules found in the parent projects, all the way up to All-Projects.
  */
 public class SubmitRuleEvaluator {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
   private final ProjectCache projectCache;
   private final PrologRule prologRule;
   private final PluginSetContext<SubmitRule> submitRules;
   private final Timer0 submitRuleEvaluationLatency;
   private final Timer0 submitTypeEvaluationLatency;
   private final SubmitRuleOptions opts;
+  private final CallerFinder callerFinder;
 
   public interface Factory {
     /** Returns a new {@link SubmitRuleEvaluator} with the specified options */
@@ -77,6 +85,14 @@
                 .setUnit(Units.MILLISECONDS));
 
     this.opts = options;
+
+    this.callerFinder =
+        CallerFinder.builder()
+            .addTarget(ChangeApi.class)
+            .addTarget(ChangeJson.class)
+            .addTarget(ChangeData.class)
+            .addTarget(SubmitRequirementsEvaluatorImpl.class)
+            .build();
   }
 
   /**
@@ -87,15 +103,19 @@
    * @param cd ChangeData to evaluate
    */
   public List<SubmitRecord> evaluate(ChangeData cd) {
+    logger.atFine().log(
+        "Evaluate submit rules for change %d (caller: %s)",
+        cd.change().getId().get(), callerFinder.findCallerLazy());
     try (Timer0.Context ignored = submitRuleEvaluationLatency.start()) {
       Change change;
+      ProjectState projectState;
       try {
         change = cd.change();
         if (change == null) {
           throw new StorageException("Change not found");
         }
 
-        projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
+        projectState = projectCache.get(cd.project()).orElseThrow(noSuchProject(cd.project()));
       } catch (NoSuchProjectException e) {
         throw new IllegalStateException("Unable to find project while evaluating submit rule", e);
       }
@@ -117,7 +137,23 @@
       // We evaluate all the plugin-defined evaluators,
       // and then we collect the results in one list.
       return Streams.stream(submitRules)
-          .map(c -> c.call(s -> s.evaluate(cd)))
+          // Skip evaluating the default submit rule if the project has prolog rules.
+          // Note that in this case, the prolog submit rule will handle labels for us
+          .filter(
+              projectState.hasPrologRules()
+                  ? rule -> !(rule.get() instanceof DefaultSubmitRule)
+                  : rule -> true)
+          .map(
+              c ->
+                  c.call(
+                      s -> {
+                        Optional<SubmitRecord> evaluate = s.evaluate(cd);
+                        if (evaluate.isPresent()) {
+                          evaluate.get().ruleName =
+                              c.getPluginName() + "~" + s.getClass().getSimpleName();
+                        }
+                        return evaluate;
+                      }))
           .filter(Optional::isPresent)
           .map(Optional::get)
           .collect(toImmutableList());
@@ -128,7 +164,6 @@
    * Evaluate the submit type rules to get the submit type.
    *
    * @return record from the evaluated rules.
-   * @param cd
    */
   public SubmitTypeRecord getSubmitType(ChangeData cd) {
     try (Timer0.Context ignored = submitTypeEvaluationLatency.start()) {
diff --git a/java/com/google/gerrit/server/query/account/AccountPredicates.java b/java/com/google/gerrit/server/query/account/AccountPredicates.java
index e4da946..8f94089 100644
--- a/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.Schema;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.server.account.AccountState;
@@ -132,8 +131,7 @@
   }
 
   /** Predicate that is mapped to a field in the account index. */
-  static class AccountPredicate extends IndexPredicate<AccountState>
-      implements Matchable<AccountState> {
+  static class AccountPredicate extends IndexPredicate<AccountState> {
     AccountPredicate(FieldDef<AccountState, ?> def, String value) {
       super(def, value);
     }
@@ -141,16 +139,6 @@
     AccountPredicate(FieldDef<AccountState, ?> def, String name, String value) {
       super(def, name, value);
     }
-
-    @Override
-    public boolean match(AccountState object) {
-      return true;
-    }
-
-    @Override
-    public int getCost() {
-      return 1;
-    }
   }
 
   private AccountPredicates() {}
diff --git a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index 091edca..1d67009 100644
--- a/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -31,6 +31,7 @@
 import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.inject.Inject;
@@ -46,20 +47,24 @@
 public class InternalAccountQuery extends InternalQuery<AccountState, InternalAccountQuery> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  private final ExternalIdKeyFactory externalIdKeyFactory;
+
   @Inject
   InternalAccountQuery(
       AccountQueryProcessor queryProcessor,
       AccountIndexCollection indexes,
-      IndexConfig indexConfig) {
+      IndexConfig indexConfig,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     super(queryProcessor, indexes, indexConfig);
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
-  public List<AccountState> byDefault(String query) {
-    return query(AccountPredicates.defaultPredicate(schema(), true, query));
+  public List<AccountState> byDefault(String query, boolean canSeeSecondaryEmails) {
+    return query(AccountPredicates.defaultPredicate(schema(), canSeeSecondaryEmails, query));
   }
 
   public List<AccountState> byExternalId(String scheme, String id) {
-    return byExternalId(ExternalId.Key.create(scheme, id));
+    return byExternalId(externalIdKeyFactory.create(scheme, id));
   }
 
   public List<AccountState> byExternalId(ExternalId.Key externalId) {
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalContext.java b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
new file mode 100644
index 0000000..4dedbb5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalContext.java
@@ -0,0 +1,59 @@
+// 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.approval;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.server.notedb.ChangeNotes;
+
+/** Entity representing all required information to match predicates for copying approvals. */
+@AutoValue
+public abstract class ApprovalContext {
+  /** Approval on the source patch set to be copied. */
+  public abstract PatchSetApproval patchSetApproval();
+
+  /**
+   * Target change and patch set for the approval. This must be used instead of getting the PatchSet
+   * from {@link #changeNotes()} because it is possible we are now creating the patch-set, so it
+   * doesn't exist in changeNotes yet.
+   */
+  public abstract PatchSet target();
+
+  /** {@link ChangeNotes} of the change in question. */
+  public abstract ChangeNotes changeNotes();
+
+  /** {@link ChangeKind} of the delta between the origin and target patch set. */
+  public abstract ChangeKind changeKind();
+
+  public static ApprovalContext create(
+      ChangeNotes changeNotes, PatchSetApproval psa, PatchSet patchSet, ChangeKind changeKind) {
+    checkState(
+        psa.patchSetId().changeId().equals(patchSet.id().changeId()),
+        "approval and target must be the same change. got: %s, %s",
+        psa.patchSetId(),
+        patchSet.id());
+    // TODO(ekempin): Use checkState to verify that psa.patchSetId().get() + 1 == id.get() so that
+    // it's ensured that approvals are only copied to the next consecutive patch set. To add back
+    // this verification https://gerrit-review.googlesource.com/c/gerrit/+/312633 can be reverted.
+    // As explained in the commit message of this change doing this check is only possible if there
+    // are no changes with gaps in patch set numbers. Since it's planned to fix-up old changes with
+    // gaps in patch set numbers, this todo is a reminder to add back the check once this is done.
+    return new AutoValue_ApprovalContext(psa, patchSet, changeNotes, changeKind);
+  }
+}
diff --git a/java/com/google/gerrit/server/api/groups/Module.java b/java/com/google/gerrit/server/query/approval/ApprovalModule.java
similarity index 68%
copy from java/com/google/gerrit/server/api/groups/Module.java
copy to java/com/google/gerrit/server/query/approval/ApprovalModule.java
index 7d7af4e..ff4d5ad 100644
--- a/java/com/google/gerrit/server/api/groups/Module.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalModule.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2015 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,16 +12,16 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.server.api.groups;
+package com.google.gerrit.server.query.approval;
 
-import com.google.gerrit.extensions.api.groups.Groups;
 import com.google.gerrit.extensions.config.FactoryModule;
 
-public class Module extends FactoryModule {
+/** Module to bind logic related to approval copying. */
+public class ApprovalModule extends FactoryModule {
+
   @Override
   protected void configure() {
-    bind(Groups.class).to(GroupsImpl.class);
-
-    factory(GroupApiImpl.Factory.class);
+    factory(MagicValuePredicate.Factory.class);
+    factory(UserInPredicate.Factory.class);
   }
 }
diff --git a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
similarity index 60%
copy from java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
copy to java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
index 6451b0f..a6f8153 100644
--- a/java/com/google/gerrit/elasticsearch/bulk/DeleteRequest.java
+++ b/java/com/google/gerrit/server/query/approval/ApprovalPredicate.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2018 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,11 +12,15 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.elasticsearch.bulk;
+package com.google.gerrit.server.query.approval;
 
-public class DeleteRequest extends ActionRequest {
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
 
-  public DeleteRequest(String id, String index) {
-    super("delete", id, index);
+public abstract class ApprovalPredicate extends Predicate<ApprovalContext>
+    implements Matchable<ApprovalContext> {
+  @Override
+  public int getCost() {
+    return 1;
   }
 }
diff --git a/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
new file mode 100644
index 0000000..819f319
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ApprovalQueryBuilder.java
@@ -0,0 +1,104 @@
+// 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.approval;
+
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.GroupDescription;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.GroupResolver;
+import com.google.inject.Inject;
+import java.util.Arrays;
+
+public class ApprovalQueryBuilder extends QueryBuilder<ApprovalContext, ApprovalQueryBuilder> {
+  private static final QueryBuilder.Definition<ApprovalContext, ApprovalQueryBuilder> mydef =
+      new QueryBuilder.Definition<>(ApprovalQueryBuilder.class);
+
+  private final MagicValuePredicate.Factory magicValuePredicate;
+  private final UserInPredicate.Factory userInPredicate;
+  private final GroupResolver groupResolver;
+  private final GroupControl.Factory groupControl;
+  private final ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate;
+
+  @Inject
+  protected ApprovalQueryBuilder(
+      MagicValuePredicate.Factory magicValuePredicate,
+      UserInPredicate.Factory userInPredicate,
+      GroupResolver groupResolver,
+      GroupControl.Factory groupControl,
+      ListOfFilesUnchangedPredicate listOfFilesUnchangedPredicate) {
+    super(mydef, null);
+    this.magicValuePredicate = magicValuePredicate;
+    this.userInPredicate = userInPredicate;
+    this.groupResolver = groupResolver;
+    this.groupControl = groupControl;
+    this.listOfFilesUnchangedPredicate = listOfFilesUnchangedPredicate;
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> changekind(String term) throws QueryParseException {
+    return new ChangeKindPredicate(toEnumValue(ChangeKind.class, term));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> is(String term) throws QueryParseException {
+    return magicValuePredicate.create(toEnumValue(MagicValuePredicate.MagicValue.class, term));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> approverin(String group) throws QueryParseException {
+    return userInPredicate.create(UserInPredicate.Field.APPROVER, parseGroupOrThrow(group));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> uploaderin(String group) throws QueryParseException {
+    return userInPredicate.create(UserInPredicate.Field.UPLOADER, parseGroupOrThrow(group));
+  }
+
+  @Operator
+  public Predicate<ApprovalContext> has(String value) throws QueryParseException {
+    if (value.equals("unchanged-files")) {
+      return listOfFilesUnchangedPredicate;
+    }
+    throw error(
+        String.format(
+            "'%s' is not a supported argument for has. only 'unchanged-files' is supported",
+            value));
+  }
+
+  private static <T extends Enum<T>> T toEnumValue(Class<T> clazz, String term)
+      throws QueryParseException {
+    try {
+      return Enum.valueOf(clazz, term.toUpperCase().replace('-', '_'));
+    } catch (IllegalArgumentException e) {
+      throw new QueryParseException(
+          String.format(
+              "%s is not a valid term. valid options: %s",
+              term, Arrays.asList(clazz.getEnumConstants())),
+          e);
+    }
+  }
+
+  private AccountGroup.UUID parseGroupOrThrow(String maybeUUID) throws QueryParseException {
+    GroupDescription.Basic g = groupResolver.parseId(maybeUUID);
+    if (g == null || !groupControl.controlFor(g).isVisible()) {
+      throw error("Group " + maybeUUID + " not found");
+    }
+    return g.getGroupUUID();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
new file mode 100644
index 0000000..78711fd
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ChangeKindPredicate.java
@@ -0,0 +1,56 @@
+// 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.approval;
+
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.Predicate;
+import java.util.Collection;
+import java.util.Objects;
+
+/**
+ * Predicate that matches patch set approvals we want to copy if the diff between the old and new
+ * patch set is of a certain kind.
+ */
+public class ChangeKindPredicate extends ApprovalPredicate {
+  private final ChangeKind changeKind;
+
+  ChangeKindPredicate(ChangeKind changeKind) {
+    this.changeKind = changeKind;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    return ctx.changeKind().equals(changeKind);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ChangeKindPredicate(changeKind);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(changeKind);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ChangeKindPredicate)) {
+      return false;
+    }
+    return ((ChangeKindPredicate) other).changeKind.equals(changeKind);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
new file mode 100644
index 0000000..de7dd0a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/ListOfFilesUnchangedPredicate.java
@@ -0,0 +1,143 @@
+// 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.approval;
+
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Patch.ChangeType;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Predicate that matches when the new patch-set includes the same files as the old patch-set. */
+@Singleton
+public class ListOfFilesUnchangedPredicate extends ApprovalPredicate {
+  private final DiffOperations diffOperations;
+  private final GitRepositoryManager repositoryManager;
+
+  @Inject
+  public ListOfFilesUnchangedPredicate(
+      DiffOperations diffOperations, GitRepositoryManager repositoryManager) {
+    this.diffOperations = diffOperations;
+    this.repositoryManager = repositoryManager;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    PatchSet targetPatchSet = ctx.target();
+    PatchSet sourcePatchSet =
+        ctx.changeNotes().getPatchSets().get(ctx.patchSetApproval().patchSetId());
+
+    Integer parentNum =
+        isInitialCommit(ctx.changeNotes().getProjectName(), targetPatchSet.commitId()) ? 0 : 1;
+    try {
+      Map<String, FileDiffOutput> baseVsCurrent =
+          diffOperations.listModifiedFilesAgainstParent(
+              ctx.changeNotes().getProjectName(), targetPatchSet.commitId(), parentNum);
+      Map<String, FileDiffOutput> baseVsPrior =
+          diffOperations.listModifiedFilesAgainstParent(
+              ctx.changeNotes().getProjectName(), sourcePatchSet.commitId(), parentNum);
+      Map<String, FileDiffOutput> priorVsCurrent =
+          diffOperations.listModifiedFiles(
+              ctx.changeNotes().getProjectName(),
+              sourcePatchSet.commitId(),
+              targetPatchSet.commitId());
+      return match(baseVsCurrent, baseVsPrior, priorVsCurrent);
+    } catch (DiffNotAvailableException 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);
+    }
+  }
+
+  /**
+   * returns {@code true} if the files that were modified are the same in both inputs, and the
+   * {@link ChangeType} matches for each modified file.
+   */
+  public boolean match(
+      Map<String, FileDiffOutput> baseVsCurrent,
+      Map<String, FileDiffOutput> baseVsPrior,
+      Map<String, FileDiffOutput> priorVsCurrent) {
+    Set<String> allFiles = new HashSet<>();
+    allFiles.addAll(baseVsCurrent.keySet());
+    allFiles.addAll(baseVsPrior.keySet());
+    for (String file : allFiles) {
+      if (Patch.isMagic(file)) {
+        continue;
+      }
+      FileDiffOutput fileDiffOutput1 = baseVsCurrent.get(file);
+      FileDiffOutput fileDiffOutput2 = baseVsPrior.get(file);
+      if (!priorVsCurrent.containsKey(file)) {
+        // If the file is not modified between prior and current patchsets, then scan safely skip
+        // it. The file might has been modified due to rebase.
+        continue;
+      }
+      if (fileDiffOutput1 == null || fileDiffOutput2 == null) {
+        return false;
+      }
+      if (!fileDiffOutput2.changeType().equals(fileDiffOutput1.changeType())) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  public boolean isInitialCommit(Project.NameKey project, ObjectId objectId) {
+    try (Repository repo = repositoryManager.openRepository(project);
+        RevWalk revWalk = new RevWalk(repo)) {
+      return revWalk.parseCommit(objectId).getParentCount() == 0;
+    } catch (IOException ex) {
+      throw new StorageException(ex);
+    }
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new ListOfFilesUnchangedPredicate(diffOperations, repositoryManager);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(diffOperations, repositoryManager);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof ListOfFilesUnchangedPredicate)) {
+      return false;
+    }
+    ListOfFilesUnchangedPredicate o = (ListOfFilesUnchangedPredicate) other;
+    return Objects.equals(o.diffOperations, diffOperations)
+        && Objects.equals(o.repositoryManager, repositoryManager);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
new file mode 100644
index 0000000..326620d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/MagicValuePredicate.java
@@ -0,0 +1,101 @@
+// 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.approval;
+
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.Optional;
+
+/** Predicate that matches patch set approvals we want to copy based on the value. */
+public class MagicValuePredicate extends ApprovalPredicate {
+  enum MagicValue {
+    MIN,
+    MAX,
+    ANY
+  }
+
+  public interface Factory {
+    MagicValuePredicate create(MagicValue value);
+  }
+
+  private final MagicValue value;
+  private final ProjectCache projectCache;
+
+  @Inject
+  MagicValuePredicate(ProjectCache projectCache, @Assisted MagicValue value) {
+    this.projectCache = projectCache;
+    this.value = value;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    Optional<LabelType> lt =
+        getLabelType(ctx.changeNotes().getProjectName(), ctx.patchSetApproval().labelId());
+    short pValue;
+    switch (value) {
+      case ANY:
+        return true;
+      case MIN:
+        if (!lt.isPresent()) {
+          return false;
+        }
+        pValue = lt.get().getMaxNegative();
+        break;
+      case MAX:
+        if (!lt.isPresent()) {
+          return false;
+        }
+        pValue = lt.get().getMaxPositive();
+        break;
+      default:
+        throw new IllegalArgumentException("unrecognized label value: " + value);
+    }
+    return pValue == ctx.patchSetApproval().value();
+  }
+
+  private Optional<LabelType> getLabelType(Project.NameKey project, LabelId labelId) {
+    return projectCache
+        .get(project)
+        .orElseThrow(() -> new IllegalStateException(project + " absent"))
+        .getLabelTypes()
+        .byLabel(labelId);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new MagicValuePredicate(projectCache, value);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(value);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof MagicValuePredicate)) {
+      return false;
+    }
+    return ((MagicValuePredicate) other).value.equals(value);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/approval/UserInPredicate.java b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
new file mode 100644
index 0000000..ac6720d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/approval/UserInPredicate.java
@@ -0,0 +1,71 @@
+package com.google.gerrit.server.query.approval;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
+import java.util.Objects;
+
+/** Predicate that matches group memberships of users such as uploader or approver. */
+public class UserInPredicate extends ApprovalPredicate {
+  interface Factory {
+    UserInPredicate create(Field field, AccountGroup.UUID group);
+  }
+
+  enum Field {
+    UPLOADER,
+    APPROVER
+  }
+
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final Field field;
+  private final AccountGroup.UUID group;
+
+  @Inject
+  UserInPredicate(
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      @Assisted Field field,
+      @Assisted AccountGroup.UUID group) {
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.field = field;
+    this.group = group;
+  }
+
+  @Override
+  public boolean match(ApprovalContext ctx) {
+    Account.Id accountId;
+    if (field == Field.UPLOADER) {
+      PatchSet patchSet = ctx.target();
+      accountId = patchSet.uploader();
+    } else if (field == Field.APPROVER) {
+      accountId = ctx.patchSetApproval().accountId();
+    } else {
+      throw new IllegalStateException("unknown field in group membership check: " + field);
+    }
+    return identifiedUserFactory.create(accountId).getEffectiveGroups().contains(group);
+  }
+
+  @Override
+  public Predicate<ApprovalContext> copy(
+      Collection<? extends Predicate<ApprovalContext>> children) {
+    return new UserInPredicate(identifiedUserFactory, field, group);
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(field, group);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (!(other instanceof UserInPredicate)) {
+      return false;
+    }
+    UserInPredicate o = (UserInPredicate) other;
+    return Objects.equals(o.field, field) && Objects.equals(o.group, group);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/java/com/google/gerrit/server/query/change/AssigneePredicate.java
deleted file mode 100644
index 35a91c9..0000000
--- a/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class AssigneePredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public AssigneePredicate(Account.Id id) {
-    super(ChangeField.ASSIGNEE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    if (id.get() == ChangeField.NO_ASSIGNEE) {
-      Account.Id assignee = object.change().getAssignee();
-      return assignee == null;
-    }
-    return id.equals(object.change().getAssignee());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java b/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java
deleted file mode 100644
index 2b18767..0000000
--- a/java/com/google/gerrit/server/query/change/AttentionSetPredicate.java
+++ /dev/null
@@ -1,41 +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.query.change;
-
-import static com.google.gerrit.server.util.AttentionSetUtil.additionsOnly;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-/** Simple predicate for searching by attention set. */
-public class AttentionSetPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  AttentionSetPredicate(Account.Id id) {
-    super(ChangeField.ATTENTION_SET_USERS, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData changeData) {
-    return additionsOnly(changeData.attentionSet()).stream()
-        .anyMatch(update -> update.account().equals(id));
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/java/com/google/gerrit/server/query/change/AuthorPredicate.java
deleted file mode 100644
index 79914a3..0000000
--- a/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.AUTHOR;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_AUTHOR;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class AuthorPredicate extends ChangeIndexPredicate {
-  public AuthorPredicate(String value) {
-    super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getAuthorParts(object).contains(getValue().toLowerCase());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
index 68f83e8..6ca3acc 100644
--- a/java/com/google/gerrit/server/query/change/BooleanPredicate.java
+++ b/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -25,9 +25,4 @@
   public boolean match(ChangeData object) {
     return getValue().equals(getField().get(object));
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeData.java b/java/com/google/gerrit/server/query/change/ChangeData.java
index f7167cd..9961519 100644
--- a/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -22,6 +22,7 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableMap;
@@ -33,10 +34,10 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Table;
 import com.google.common.flogger.FluentLogger;
 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;
@@ -51,12 +52,13 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.SubmitTypeRecord;
 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;
 import com.google.gerrit.server.CurrentUser;
@@ -66,12 +68,15 @@
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.CommentThread;
 import com.google.gerrit.server.change.CommentThreads;
 import com.google.gerrit.server.change.MergeabilityCache;
 import com.google.gerrit.server.change.PureRevert;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.TrackingFooters;
+import com.google.gerrit.server.experiments.ExperimentFeatures;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -84,6 +89,9 @@
 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.project.SubmitRequirementsAdapter;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.project.SubmitRequirementsUtil;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -101,6 +109,7 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -263,7 +272,7 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, project, id, null, null);
+            null, null, null, project, id, null, null);
     cd.currentPatchSet =
         PatchSet.builder()
             .id(PatchSet.id(id, currentPatchSetId))
@@ -281,6 +290,7 @@
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
+  private final ExperimentFeatures experimentFeatures;
   private final GitRepositoryManager repoManager;
   private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
@@ -289,6 +299,7 @@
   private final ProjectCache projectCache;
   private final TrackingFooters trackingFooters;
   private final PureRevert pureRevert;
+  private final SubmitRequirementsEvaluator submitRequirementsEvaluator;
   private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   // Required assisted injected fields.
@@ -300,6 +311,8 @@
   private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
       Maps.newLinkedHashMapWithExpectedSize(1);
 
+  private Map<SubmitRequirement, SubmitRequirementResult> submitRequirements;
+
   private StorageConstraint storageConstraint = StorageConstraint.NOTEDB_ONLY;
   private Change change;
   private ChangeNotes notes;
@@ -318,11 +331,20 @@
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
   private Boolean mergeable;
-  private Boolean merge;
   private Set<String> hashtags;
-  private Map<Account.Id, Ref> editsByUser;
+  /**
+   * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the edit ref for this
+   * change and a given user.
+   */
+  private Table<Account.Id, PatchSet.Id, ObjectId> editsByUser;
+
   private Set<Account.Id> reviewedBy;
-  private Map<Account.Id, Ref> draftsByUser;
+  /**
+   * Map from {@link com.google.gerrit.entities.Account.Id} to the tip of the draft comments ref for
+   * this change and the user.
+   */
+  private Map<Account.Id, ObjectId> draftsByUser;
+
   private ImmutableListMultimap<Account.Id, String> stars;
   private StarsOf starsOf;
   private ImmutableMap<Account.Id, StarRef> starRefs;
@@ -334,7 +356,7 @@
   private PersonIdent author;
   private PersonIdent committer;
   private ImmutableSet<AttentionSetUpdate> attentionSet;
-  private int parentCount;
+  private Integer parentCount;
   private Integer unresolvedCommentCount;
   private Integer totalCommentCount;
   private LabelTypes labelTypes;
@@ -350,6 +372,7 @@
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory notesFactory,
       CommentsUtil commentsUtil,
+      ExperimentFeatures experimentFeatures,
       GitRepositoryManager repoManager,
       MergeUtil.Factory mergeUtilFactory,
       MergeabilityCache mergeabilityCache,
@@ -358,6 +381,7 @@
       ProjectCache projectCache,
       TrackingFooters trackingFooters,
       PureRevert pureRevert,
+      SubmitRequirementsEvaluator submitRequirementsEvaluator,
       SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       @Assisted Project.NameKey project,
       @Assisted Change.Id id,
@@ -368,6 +392,7 @@
     this.cmUtil = cmUtil;
     this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
+    this.experimentFeatures = experimentFeatures;
     this.repoManager = repoManager;
     this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
@@ -377,6 +402,7 @@
     this.starredChangesUtil = starredChangesUtil;
     this.trackingFooters = trackingFooters;
     this.pureRevert = pureRevert;
+    this.submitRequirementsEvaluator = submitRequirementsEvaluator;
     this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
 
     this.project = project;
@@ -472,6 +498,26 @@
     changedLines = Optional.of(new ChangedLines(insertions, deletions));
   }
 
+  public void setLinesInserted(int insertions) {
+    changedLines =
+        Optional.of(
+            new ChangedLines(
+                insertions,
+                changedLines != null && changedLines.isPresent()
+                    ? changedLines.get().deletions
+                    : -1));
+  }
+
+  public void setLinesDeleted(int deletions) {
+    changedLines =
+        Optional.of(
+            new ChangedLines(
+                changedLines != null && changedLines.isPresent()
+                    ? changedLines.get().insertions
+                    : -1,
+                deletions));
+  }
+
   public void setNoChangedLines() {
     changedLines = Optional.empty();
   }
@@ -559,8 +605,7 @@
       } else {
         try {
           currentApprovals =
-              ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(notes(), c.currentPatchSetId(), null, null));
+              ImmutableList.copyOf(approvalsUtil.byPatchSet(notes(), c.currentPatchSetId()));
         } catch (StorageException e) {
           if (e.getCause() instanceof NoSuchChangeException) {
             currentApprovals = Collections.emptyList();
@@ -631,7 +676,6 @@
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
       parentCount = c.getParentCount();
-      merge = parentCount > 1;
     } catch (IOException e) {
       throw new StorageException(
           String.format(
@@ -691,7 +735,7 @@
     this.attentionSet = attentionSet;
   }
 
-  /** @return patches for the change, in patch set ID order. */
+  /** Returns patches for the change, in patch set ID order. */
   public Collection<PatchSet> patchSets() {
     if (patchSets == null) {
       patchSets = psUtil.byChange(notes());
@@ -704,7 +748,7 @@
     this.patchSets = patchSets;
   }
 
-  /** @return patch with the given ID, or null if it does not exist. */
+  /** Returns patch with the given ID, or null if it does not exist. */
   public PatchSet patchSet(PatchSet.Id psId) {
     if (currentPatchSet != null && currentPatchSet.id().equals(psId)) {
       return currentPatchSet;
@@ -718,8 +762,8 @@
   }
 
   /**
-   * @return all patch set approvals for the change, keyed by ID, ordered by timestamp within each
-   *     patch set.
+   * Returns all patch set approvals for the change, keyed by ID, ordered by timestamp within each
+   * patch set.
    */
   public ListMultimap<PatchSet.Id, PatchSetApproval> approvals() {
     if (allApprovals == null) {
@@ -895,6 +939,57 @@
     return messages;
   }
 
+  /**
+   * Get all evaluated submit requirements for this change, including those from parent projects.
+   * For closed changes, submit requirements are read from the change notes. For active changes,
+   * submit requirements are evaluated online.
+   *
+   * <p>For changes loaded from the index, the value will be set from index field {@link
+   * com.google.gerrit.server.index.change.ChangeField#STORED_SUBMIT_REQUIREMENTS}.
+   */
+  public Map<SubmitRequirement, SubmitRequirementResult> submitRequirements() {
+    if (!experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)) {
+      return Collections.emptyMap();
+    }
+    if (submitRequirements == null) {
+      if (!lazyload()) {
+        return Collections.emptyMap();
+      }
+      Change c = change();
+      if (c == null || !c.isClosed()) {
+        // Open changes: Evaluate submit requirements online.
+        submitRequirements =
+            submitRequirementsEvaluator.evaluateAllRequirements(this, /* includeLegacy= */ true);
+        return submitRequirements;
+      }
+      // Closed changes: Load submit requirement results from NoteDb.
+      Map<SubmitRequirement, SubmitRequirementResult> projectConfigRequirements =
+          notes().getSubmitRequirementsResult().stream()
+              .filter(r -> !r.isLegacy())
+              .collect(Collectors.toMap(r -> r.submitRequirement(), Function.identity()));
+      Map<SubmitRequirement, SubmitRequirementResult> legacyRequirements =
+          SubmitRequirementsAdapter.getLegacyRequirements(submitRuleEvaluatorFactory, this);
+      submitRequirements =
+          SubmitRequirementsUtil.mergeLegacyAndNonLegacyRequirements(
+              projectConfigRequirements, legacyRequirements);
+    }
+    return submitRequirements;
+  }
+
+  public void setSubmitRequirements(
+      Map<SubmitRequirement, SubmitRequirementResult> submitRequirements) {
+    if (!experimentFeatures.isFeatureEnabled(
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD)) {
+      // Only set back values from the index if the experiment is not active. While the experiment
+      // is active, we want
+      // to compute SRs from scratch to ensure fresh results.
+      // TODO(ghareeb, hiesel): Remove this.
+      this.submitRequirements = submitRequirements;
+    }
+  }
+
   public List<SubmitRecord> submitRecords(SubmitRuleOptions 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
@@ -987,36 +1082,39 @@
 
   @Nullable
   public Boolean isMerge() {
-    if (merge == null) {
+    if (parentCount == null) {
       if (!loadCommitData()) {
         return null;
       }
     }
-    return merge;
+    return parentCount > 1;
   }
 
   public Set<Account.Id> editsByUser() {
-    return editRefs().keySet();
+    return editRefs().rowKeySet();
   }
 
-  public Map<Account.Id, Ref> editRefs() {
+  public Table<Account.Id, PatchSet.Id, ObjectId> editRefs() {
     if (editsByUser == null) {
       if (!lazyload()) {
-        return Collections.emptyMap();
+        return HashBasedTable.create();
       }
       Change c = change();
       if (c == null) {
-        return Collections.emptyMap();
+        return HashBasedTable.create();
       }
-      editsByUser = new HashMap<>();
+      editsByUser = HashBasedTable.create();
       Change.Id id = requireNonNull(change.getId());
       try (Repository repo = repoManager.openRepository(project())) {
         for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_USERS)) {
-          String name = ref.getName().substring(RefNames.REFS_USERS.length());
-          if (id.equals(Change.Id.fromEditRefPart(name))) {
-            Account.Id accountId = Account.Id.fromRefPart(name);
+          if (!RefNames.isRefsEdit(ref.getName())) {
+            continue;
+          }
+          PatchSet.Id ps = PatchSet.Id.fromEditRef(ref.getName());
+          if (id.equals(ps.changeId())) {
+            Account.Id accountId = Account.Id.fromRef(ref.getName());
             if (accountId != null) {
-              editsByUser.put(accountId, ref);
+              editsByUser.put(accountId, ps, ref.getObjectId());
             }
           }
         }
@@ -1031,48 +1129,7 @@
     return draftRefs().keySet();
   }
 
-  public Map<Account.Id, Ref> draftRefs() {
-    if (draftsByUser == null) {
-      if (!lazyload()) {
-        return Collections.emptyMap();
-      }
-      Change c = change();
-      if (c == null) {
-        return Collections.emptyMap();
-      }
-
-      draftsByUser = new HashMap<>();
-      for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
-        Account.Id account = Account.Id.fromRefSuffix(ref.getName());
-        if (account != null
-            // Double-check that any drafts exist for this user after
-            // filtering out zombies. If some but not all drafts in the ref
-            // were zombies, the returned Ref still includes those zombies;
-            // this is suboptimal, but is ok for the purposes of
-            // draftsByUser(), and easier than trying to rebuild the change at
-            // this point.
-            && !notes().getDraftComments(account, ref).isEmpty()) {
-          draftsByUser.put(account, ref);
-        }
-      }
-    }
-    return draftsByUser;
-  }
-
   public boolean isReviewedBy(Account.Id accountId) {
-    Collection<String> stars = stars(accountId);
-
-    PatchSet ps = currentPatchSet();
-    if (ps != null) {
-      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.number())) {
-        return true;
-      }
-
-      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.number())) {
-        return false;
-      }
-    }
-
     return reviewedBy().contains(accountId);
   }
 
@@ -1170,8 +1227,8 @@
   }
 
   /**
-   * @return {@code null} if {@code revertOf} is {@code null}; true if the change is a pure revert;
-   *     false otherwise.
+   * Returns {@code null} if {@code revertOf} is {@code null}; true if the change is a pure revert;
+   * false otherwise.
    */
   @Nullable
   public Boolean isPureRevert() {
@@ -1213,7 +1270,14 @@
       }
 
       ImmutableSetMultimap.Builder<NameKey, RefState> result = ImmutableSetMultimap.builder();
-      editRefs().values().forEach(r -> result.put(project, RefState.of(r)));
+      for (Table.Cell<Account.Id, PatchSet.Id, ObjectId> edit : editRefs().cellSet()) {
+        result.put(
+            project,
+            RefState.create(
+                RefNames.refsEdit(
+                    edit.getRowKey(), edit.getColumnKey().changeId(), edit.getColumnKey()),
+                edit.getValue()));
+      }
       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
@@ -1222,7 +1286,14 @@
       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)));
+      draftRefs()
+          .entrySet()
+          .forEach(
+              r ->
+                  result.put(
+                      allUsersName,
+                      RefState.create(
+                          RefNames.refsDraftComments(getId(), r.getKey()), r.getValue())));
 
       refStates = result.build();
     }
@@ -1230,14 +1301,35 @@
     return refStates;
   }
 
-  @UsedAt(UsedAt.Project.GOOGLE)
-  public void setRefStates(Iterable<byte[]> 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;
+    if (draftsByUser == null) {
+      // Recover draft refs as well. Draft comments are represented as refs in the repository.
+      // ChangeData exposes #draftsByUser which just provides a Set of Account.Ids of users who
+      // have drafts comments on this change. Recovering this list from RefStates makes it
+      // available even on ChangeData instances retrieved from the index.
+      draftsByUser = new HashMap<>();
+      if (refStates.containsKey(allUsersName)) {
+        refStates.get(allUsersName).stream()
+            .filter(r -> RefNames.isRefsDraftsComments(r.ref()))
+            .forEach(r -> draftsByUser.put(Account.Id.fromRef(r.ref()), r.id()));
+      }
+    }
+    if (editsByUser == null) {
+      // Recover edit refs as well. Edits are represented as refs in the repository.
+      // ChangeData exposes #editsByUser which just provides a Set of Account.Ids of users who
+      // have edits on this change. Recovering this list from RefStates makes it available even
+      // on ChangeData instances retrieved from the index.
+      editsByUser = HashBasedTable.create();
+      if (refStates.containsKey(project())) {
+        refStates.get(project()).stream()
+            .filter(r -> RefNames.isRefsEdit(r.ref()))
+            .forEach(
+                r ->
+                    editsByUser.put(
+                        Account.Id.fromRef(r.ref()), PatchSet.Id.fromEditRef(r.ref()), r.id()));
+      }
+    }
   }
 
   public ImmutableList<byte[]> getRefStatePatterns() {
@@ -1269,4 +1361,32 @@
 
     public abstract ImmutableSortedSet<String> stars();
   }
+
+  private Map<Account.Id, ObjectId> draftRefs() {
+    if (draftsByUser == null) {
+      if (!lazyload()) {
+        return Collections.emptyMap();
+      }
+      Change c = change();
+      if (c == null) {
+        return Collections.emptyMap();
+      }
+
+      draftsByUser = new HashMap<>();
+      for (Ref ref : commentsUtil.getDraftRefs(notes().getChangeId())) {
+        Account.Id account = Account.Id.fromRefSuffix(ref.getName());
+        if (account != null
+            // Double-check that any drafts exist for this user after
+            // filtering out zombies. If some but not all drafts in the ref
+            // were zombies, the returned Ref still includes those zombies;
+            // this is suboptimal, but is ok for the purposes of
+            // draftsByUser(), and easier than trying to rebuild the change at
+            // this point.
+            && !notes().getDraftComments(account, ref).isEmpty()) {
+          draftsByUser.put(account, ref.getObjectId());
+        }
+      }
+    }
+    return draftsByUser;
+  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeDataSource.java b/java/com/google/gerrit/server/query/change/ChangeDataSource.java
index 34579a9..26ce46c 100644
--- a/java/com/google/gerrit/server/query/change/ChangeDataSource.java
+++ b/java/com/google/gerrit/server/query/change/ChangeDataSource.java
@@ -17,6 +17,6 @@
 import com.google.gerrit.index.query.DataSource;
 
 public interface ChangeDataSource extends DataSource<ChangeData> {
-  /** @return true if all returned ChangeData.hasChange() will be true. */
+  /** Returns true if all returned ChangeData.hasChange() will be true. */
   boolean hasChange();
 }
diff --git a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
deleted file mode 100644
index 05cc6ca..0000000
--- a/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ /dev/null
@@ -1,44 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-/** Predicate over Change-Id strings (aka Change.Key). */
-public class ChangeIdPredicate extends ChangeIndexPredicate {
-  public ChangeIdPredicate(String id) {
-    super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Change change = cd.change();
-    if (change == null) {
-      return false;
-    }
-
-    String key = change.getKey().get();
-    if (key.equals(getValue()) || key.startsWith(getValue())) {
-      return true;
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index a176a58..ccd4109 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -16,12 +16,10 @@
 
 import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.index.query.IndexPredicate;
-import com.google.gerrit.index.query.Matchable;
 import com.google.gerrit.index.query.Predicate;
 
 /** Predicate that is mapped to a field in the change index. */
-public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
-    implements Matchable<ChangeData> {
+public class ChangeIndexPredicate extends IndexPredicate<ChangeData> {
   /**
    * Returns an index predicate that matches no changes in the index.
    *
diff --git a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index a66c43ae..60587da 100644
--- a/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -17,13 +17,11 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.index.IndexUtils;
-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;
@@ -42,7 +40,6 @@
     ChangeIsVisibleToPredicate forUser(CurrentUser user);
   }
 
-  protected final ChangeNotes.Factory notesFactory;
   protected final CurrentUser user;
   protected final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
@@ -50,13 +47,11 @@
 
   @Inject
   public ChangeIsVisibleToPredicate(
-      ChangeNotes.Factory notesFactory,
       PermissionBackend permissionBackend,
       ProjectCache projectCache,
       Provider<AnonymousUser> anonymousUserProvider,
       @Assisted CurrentUser user) {
     super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
-    this.notesFactory = notesFactory;
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
@@ -91,7 +86,10 @@
                     .filter(u -> u instanceof GroupBackedUser || u instanceof InternalUser)
                     .orElseGet(anonymousUserProvider::get));
     try {
-      withUser.change(cd).check(ChangePermission.READ);
+      if (!withUser.change(cd).test(ChangePermission.READ)) {
+        logger.atFine().log("Filter out non-visisble change: %s", cd);
+        return false;
+      }
     } catch (PermissionBackendException e) {
       Throwable cause = e.getCause();
       if (cause instanceof RepositoryNotFoundException) {
@@ -101,9 +99,6 @@
         return false;
       }
       throw new StorageException("unable to check permissions on change " + cd.getId(), e);
-    } catch (AuthException e) {
-      logger.atFine().log("Filter out non-visisble change: %s", cd);
-      return false;
     }
 
     cd.cacheVisibleTo(user);
diff --git a/java/com/google/gerrit/server/query/change/ChangePredicates.java b/java/com/google/gerrit/server/query/change/ChangePredicates.java
new file mode 100644
index 0000000..043b37d
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/ChangePredicates.java
@@ -0,0 +1,314 @@
+// 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.base.CharMatcher;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.git.ObjectIds;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.change.HashtagsUtil;
+import com.google.gerrit.server.index.change.ChangeField;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+
+/** Predicates that match against {@link ChangeData}. */
+public class ChangePredicates {
+  private ChangePredicates() {}
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} is in the attention set.
+   */
+  public static Predicate<ChangeData> attentionSet(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.ATTENTION_SET_USERS, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that are assigned to the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> assignee(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.ASSIGNEE, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that are a revert of the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> revertOf(Change.Id revertOf) {
+    return new ChangeIndexPredicate(ChangeField.REVERT_OF, revertOf.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that have a comment authored by the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> commentBy(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.COMMENTBY, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has a pending change edit.
+   */
+  public static Predicate<ChangeData> editBy(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.EDITBY, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@link
+   * com.google.gerrit.entities.Account.Id} has a pending draft comment.
+   */
+  public static Predicate<ChangeData> draftBy(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.DRAFTBY, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that were reviewed by any of the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> reviewedBy(Collection<Account.Id> ids) {
+    List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
+    for (Account.Id id : ids) {
+      predicates.add(new ChangeIndexPredicate(ChangeField.REVIEWEDBY, id.toString()));
+    }
+    return Predicate.or(predicates);
+  }
+
+  /** Returns a predicate that matches changes that were not yet reviewed. */
+  public static Predicate<ChangeData> unreviewed() {
+    return Predicate.not(
+        new ChangeIndexPredicate(ChangeField.REVIEWEDBY, ChangeField.NOT_REVIEWED.toString()));
+  }
+
+  /**
+   * Returns a predicate that matches the change with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> id(Change.Id id) {
+    return new ChangeIndexPredicate(
+        ChangeField.LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches the change with the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> idStr(Change.Id id) {
+    return new ChangeIndexPredicate(
+        ChangeField.LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes owned by the provided {@link
+   * com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> owner(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.OWNER, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes where the latest patch set was uploaded by the
+   * provided {@link com.google.gerrit.entities.Account.Id}.
+   */
+  public static Predicate<ChangeData> uploader(Account.Id id) {
+    return new ChangeIndexPredicate(ChangeField.UPLOADER, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that are a cherry pick of the provided {@link
+   * com.google.gerrit.entities.Change.Id}.
+   */
+  public static Predicate<ChangeData> cherryPickOf(Change.Id id) {
+    return new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_CHANGE, id.toString());
+  }
+
+  /**
+   * Returns a predicate that matches changes that are a cherry pick of the provided {@link
+   * com.google.gerrit.entities.PatchSet.Id}.
+   */
+  public static Predicate<ChangeData> cherryPickOf(PatchSet.Id psId) {
+    return Predicate.and(
+        cherryPickOf(psId.changeId()),
+        new ChangeIndexPredicate(ChangeField.CHERRY_PICK_OF_PATCHSET, String.valueOf(psId.get())));
+  }
+
+  /**
+   * Returns a predicate that matches changes in the provided {@link
+   * com.google.gerrit.entities.Project.NameKey}.
+   */
+  public static Predicate<ChangeData> project(Project.NameKey id) {
+    return new ChangeIndexPredicate(ChangeField.PROJECT, id.get());
+  }
+
+  /** Returns a predicate that matches changes targeted at the provided {@code refName}. */
+  public static Predicate<ChangeData> ref(String refName) {
+    return new ChangeIndexPredicate(ChangeField.REF, refName);
+  }
+
+  /** Returns a predicate that matches changes in the provided {@code topic}. */
+  public static Predicate<ChangeData> exactTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.EXACT_TOPIC, topic);
+  }
+
+  /** Returns a predicate that matches changes in the provided {@code topic}. */
+  public static Predicate<ChangeData> fuzzyTopic(String topic) {
+    return new ChangeIndexPredicate(ChangeField.FUZZY_TOPIC, topic);
+  }
+
+  /** Returns a predicate that matches changes submitted in the provided {@code changeSet}. */
+  public static Predicate<ChangeData> submissionId(String changeSet) {
+    return new ChangeIndexPredicate(ChangeField.SUBMISSIONID, changeSet);
+  }
+
+  /** Returns a predicate that matches changes that modified the provided {@code path}. */
+  public static Predicate<ChangeData> path(String path) {
+    return new ChangeIndexPredicate(ChangeField.PATH, path);
+  }
+
+  /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
+  public static Predicate<ChangeData> hashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
+  /** Returns a predicate that matches changes tagged with the provided {@code hashtag}. */
+  public static Predicate<ChangeData> fuzzyHashtag(String hashtag) {
+    // Use toLowerCase without locale to match behavior in ChangeField.
+    return new ChangeIndexPredicate(
+        ChangeField.FUZZY_HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
+  }
+
+  /** Returns a predicate that matches changes that modified the provided {@code file}. */
+  public static Predicate<ChangeData> file(ChangeQueryBuilder.Arguments args, String file) {
+    Predicate<ChangeData> eqPath = path(file);
+    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
+      return eqPath;
+    }
+    return Predicate.or(eqPath, new ChangeIndexPredicate(ChangeField.FILE_PART, file));
+  }
+
+  /**
+   * Returns a predicate that matches changes with the provided {@code footer} in their commit
+   * message.
+   */
+  public static Predicate<ChangeData> footer(String footer) {
+    int indexEquals = footer.indexOf('=');
+    int indexColon = footer.indexOf(':');
+
+    // footer key cannot contain '='
+    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
+      footer = footer.substring(0, indexEquals) + ": " + footer.substring(indexEquals + 1);
+    }
+    return new ChangeIndexPredicate(ChangeField.FOOTER, footer.toLowerCase(Locale.US));
+  }
+
+  /**
+   * Returns a predicate that matches changes that modified files in the provided {@code directory}.
+   */
+  public static Predicate<ChangeData> directory(String directory) {
+    return new ChangeIndexPredicate(
+        ChangeField.DIRECTORY, CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes with the provided {@code trackingId}. */
+  public static Predicate<ChangeData> trackingId(String trackingId) {
+    return new ChangeIndexPredicate(ChangeField.TR, trackingId);
+  }
+
+  /** Returns a predicate that matches changes authored by the provided {@code exactAuthor}. */
+  public static Predicate<ChangeData> exactAuthor(String exactAuthor) {
+    return new ChangeIndexPredicate(ChangeField.EXACT_AUTHOR, exactAuthor.toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes authored by the provided {@code author}. */
+  public static Predicate<ChangeData> author(String author) {
+    return new ChangeIndexPredicate(ChangeField.AUTHOR, author);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the patch set was committed by {@code
+   * exactCommitter}.
+   */
+  public static Predicate<ChangeData> exactCommitter(String exactCommitter) {
+    return new ChangeIndexPredicate(
+        ChangeField.EXACT_COMMITTER, exactCommitter.toLowerCase(Locale.US));
+  }
+
+  /**
+   * Returns a predicate that matches changes where the patch set was committed by {@code
+   * committer}.
+   */
+  public static Predicate<ChangeData> committer(String comitter) {
+    return new ChangeIndexPredicate(ChangeField.COMMITTER, comitter.toLowerCase(Locale.US));
+  }
+
+  /** Returns a predicate that matches changes whose ID starts with the provided {@code id}. */
+  public static Predicate<ChangeData> idPrefix(String id) {
+    return new ChangeIndexPredicate(ChangeField.ID, id);
+  }
+
+  /**
+   * Returns a predicate that matches changes in a project that has the provided {@code prefix} in
+   * its name.
+   */
+  public static Predicate<ChangeData> projectPrefix(String prefix) {
+    return new ChangeIndexPredicate(ChangeField.PROJECTS, prefix);
+  }
+
+  /**
+   * Returns a predicate that matches changes where a patch set has the provided {@code commitId}
+   * either as prefix or as full {@link org.eclipse.jgit.lib.ObjectId}.
+   */
+  public static Predicate<ChangeData> commitPrefix(String commitId) {
+    if (commitId.length() == ObjectIds.STR_LEN) {
+      return new ChangeIndexPredicate(ChangeField.EXACT_COMMIT, commitId);
+    }
+    return new ChangeIndexPredicate(ChangeField.COMMIT, commitId);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@code message} appears in the
+   * commit message. Uses full-text search semantics.
+   */
+  public static Predicate<ChangeData> message(String message) {
+    return new ChangeIndexPredicate(ChangeField.COMMIT_MESSAGE, message);
+  }
+
+  /**
+   * Returns a predicate that matches changes where the provided {@code comment} appears in any
+   * comment on any patch set of the change. Uses full-text search semantics.
+   */
+  public static Predicate<ChangeData> comment(String comment) {
+    return new ChangeIndexPredicate(ChangeField.COMMENT, comment);
+  }
+
+  /**
+   * Returns a predicate that matches with changes having a specific submit rule evaluating to a
+   * certain result. Value should be in the form of "$ruleName=$status" with $ruleName equals to
+   * '$plugin_name~$rule_name' and $rule_name equals to the name of the class that implements the
+   * {@link com.google.gerrit.server.rules.SubmitRule}. For gerrit core rules, $ruleName should be
+   * in the form of 'gerrit~$rule_name'.
+   */
+  public static Predicate<ChangeData> submitRuleStatus(String value) {
+    return new ChangeIndexPredicate(ChangeField.SUBMIT_RULE_RESULT, value);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index b02b52d..da36633 100644
--- a/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.entities.GroupDescription;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.exceptions.NotSignedInException;
@@ -77,8 +78,10 @@
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.plugincontext.PluginSetContext;
 import com.google.gerrit.server.project.ChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.submit.SubmitDryRun;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -86,6 +89,7 @@
 import com.google.inject.util.Providers;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
@@ -138,6 +142,7 @@
   public static final String FIELD_ADDED = "added";
   public static final String FIELD_AGE = "age";
   public static final String FIELD_ATTENTION_SET_USERS = "attentionusers";
+  public static final String FIELD_ATTENTION_SET_USERS_COUNT = "attentionuserscount";
   public static final String FIELD_ATTENTION_SET_FULL = "attentionfull";
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
@@ -175,7 +180,6 @@
   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";
   public static final String FIELD_PENDING_REVIEWER_BY_EMAIL = "pendingreviewerbyemail";
   public static final String FIELD_PRIVATE = "private";
@@ -183,21 +187,21 @@
   public static final String FIELD_PROJECTS = "projects";
   public static final String FIELD_REF = "ref";
   public static final String FIELD_REVIEWEDBY = "reviewedby";
-  public static final String FIELD_REVIEWER = "reviewer";
   public static final String FIELD_REVIEWERIN = "reviewerin";
   public static final String FIELD_STAR = "star";
   public static final String FIELD_STARBY = "starby";
-  public static final String FIELD_STARREDBY = "starredby";
   public static final String FIELD_STARTED = "started";
   public static final String FIELD_STATUS = "status";
   public static final String FIELD_SUBMISSIONID = "submissionid";
   public static final String FIELD_TR = "tr";
   public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
+  public static final String FIELD_UPLOADER = "uploader";
+  public static final String FIELD_UPLOADERIN = "uploaderin";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
   public static final String FIELD_WIP = "wip";
   public static final String FIELD_REVERTOF = "revertof";
-  public static final String FIELD_CHERRY_PICK_OF = "cherrypickof";
+  public static final String FIELD_CHERRYPICK = "cherrypick";
   public static final String FIELD_CHERRY_PICK_OF_CHANGE = "cherrypickofchange";
   public static final String FIELD_CHERRY_PICK_OF_PATCHSET = "cherrypickofpatchset";
 
@@ -205,7 +209,9 @@
   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 String ARG_ID_NON_UPLOADER = "non_uploader";
   public static final Account.Id OWNER_ACCOUNT_ID = Account.id(0);
+  public static final Account.Id NON_UPLOADER_ACCOUNT_ID = Account.id(-1);
 
   public static final String OPERATOR_MERGED_BEFORE = "mergedbefore";
   public static final String OPERATOR_MERGED_AFTER = "mergedafter";
@@ -246,7 +252,9 @@
     final ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory;
     final OperatorAliasConfig operatorAliasConfig;
     final boolean indexMergeable;
+    final boolean conflictsPredicateEnabled;
     final HasOperandAliasConfig hasOperandAliasConfig;
+    final PluginSetContext<SubmitRule> submitRules;
 
     private final Provider<CurrentUser> self;
 
@@ -281,7 +289,8 @@
         OperatorAliasConfig operatorAliasConfig,
         @GerritServerConfig Config gerritConfig,
         HasOperandAliasConfig hasOperandAliasConfig,
-        ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory) {
+        ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
+        PluginSetContext<SubmitRule> submitRules) {
       this(
           queryProvider,
           rewriter,
@@ -310,8 +319,10 @@
           groupMembers,
           operatorAliasConfig,
           MergeabilityComputationBehavior.fromConfig(gerritConfig).includeInIndex(),
+          gerritConfig.getBoolean("change", null, "conflictsPredicateEnabled", true),
           hasOperandAliasConfig,
-          changeIsVisbleToPredicateFactory);
+          changeIsVisbleToPredicateFactory,
+          submitRules);
     }
 
     private Arguments(
@@ -342,8 +353,10 @@
         GroupMembers groupMembers,
         OperatorAliasConfig operatorAliasConfig,
         boolean indexMergeable,
+        boolean conflictsPredicateEnabled,
         HasOperandAliasConfig hasOperandAliasConfig,
-        ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory) {
+        ChangeIsVisibleToPredicate.Factory changeIsVisbleToPredicateFactory,
+        PluginSetContext<SubmitRule> submitRules) {
       this.queryProvider = queryProvider;
       this.rewriter = rewriter;
       this.opFactories = opFactories;
@@ -372,7 +385,9 @@
       this.changeIsVisbleToPredicateFactory = changeIsVisbleToPredicateFactory;
       this.operatorAliasConfig = operatorAliasConfig;
       this.indexMergeable = indexMergeable;
+      this.conflictsPredicateEnabled = conflictsPredicateEnabled;
       this.hasOperandAliasConfig = hasOperandAliasConfig;
+      this.submitRules = submitRules;
     }
 
     Arguments asUser(CurrentUser otherUser) {
@@ -404,8 +419,10 @@
           groupMembers,
           operatorAliasConfig,
           indexMergeable,
+          conflictsPredicateEnabled,
           hasOperandAliasConfig,
-          changeIsVisbleToPredicateFactory);
+          changeIsVisbleToPredicateFactory,
+          submitRules);
     }
 
     Arguments asUser(Account.Id otherId) {
@@ -465,10 +482,6 @@
     hasOperandAliases = args.hasOperandAliasConfig.getChangeQueryHasOperandAliases();
   }
 
-  public Arguments getArgs() {
-    return args;
-  }
-
   public ChangeQueryBuilder asUser(CurrentUser user) {
     return new ChangeQueryBuilder(builderDef, args.asUser(user));
   }
@@ -527,17 +540,17 @@
       return Predicate.and(
           project(triplet.get().project().get()),
           branch(triplet.get().branch().branch()),
-          new ChangeIdPredicate(parseChangeId(triplet.get().id().get())));
+          ChangePredicates.idPrefix(parseChangeId(triplet.get().id().get())));
     }
     if (PAT_LEGACY_ID.matcher(query).matches()) {
       Integer id = Ints.tryParse(query);
       if (id != null) {
         return args.getSchema().useLegacyNumericFields()
-            ? new LegacyChangeIdPredicate(Change.id(id))
-            : new LegacyChangeIdStrPredicate(Change.id(id));
+            ? ChangePredicates.id(Change.id(id))
+            : ChangePredicates.idStr(Change.id(id));
       }
     } else if (PAT_CHANGE_ID.matcher(query).matches()) {
-      return new ChangeIdPredicate(parseChangeId(query));
+      return ChangePredicates.idPrefix(parseChangeId(query));
     }
 
     throw new QueryParseException("Invalid change format");
@@ -545,13 +558,13 @@
 
   @Operator
   public Predicate<ChangeData> comment(String value) {
-    return new CommentPredicate(args.index, value);
+    return ChangePredicates.comment(value);
   }
 
   @Operator
   public Predicate<ChangeData> status(String statusName) {
     if ("reviewed".equalsIgnoreCase(statusName)) {
-      return IsReviewedPredicate.create();
+      return ChangePredicates.unreviewed();
     }
     return ChangeStatusPredicate.parse(statusName);
   }
@@ -561,22 +574,55 @@
   }
 
   @Operator
+  public Predicate<ChangeData> rule(String value) throws QueryParseException {
+    String ruleNameArg = value;
+    String statusArg = null;
+    String[] queryArgs = value.split("=");
+    if (queryArgs.length > 2) {
+      throw new QueryParseException(
+          "Invalid query arguments. Correct format is 'rule:<rule_name>=<status>' "
+              + "with <rule_name> in the form of <plugin>~<rule>. For Gerrit core rules, "
+              + "rule name should be specified either as gerrit~<rule> or <rule>.");
+    }
+    if (queryArgs.length == 2) {
+      ruleNameArg = queryArgs[0];
+      statusArg = queryArgs[1];
+    }
+
+    // If ruleName is not prefixed by the plugin name, add the "gerrit~" prefix to it.
+    if (!ruleNameArg.contains("~")) {
+      ruleNameArg = "gerrit~" + ruleNameArg;
+    }
+
+    return statusArg == null
+        ? Predicate.or(
+            Arrays.asList(
+                ChangePredicates.submitRuleStatus(ruleNameArg + "=" + SubmitRecord.Status.OK),
+                ChangePredicates.submitRuleStatus(ruleNameArg + "=" + SubmitRecord.Status.FORCED)))
+        : ChangePredicates.submitRuleStatus(ruleNameArg + "=" + statusArg);
+  }
+
+  @Operator
   public Predicate<ChangeData> has(String value) throws QueryParseException {
     value = hasOperandAliases.getOrDefault(value, value);
     if ("star".equalsIgnoreCase(value)) {
-      return starredby(self());
-    }
-
-    if ("stars".equalsIgnoreCase(value)) {
-      return new HasStarsPredicate(self());
+      return starredBySelf();
     }
 
     if ("draft".equalsIgnoreCase(value)) {
-      return draftby(self());
+      return draftBySelf();
     }
 
     if ("edit".equalsIgnoreCase(value)) {
-      return new EditByPredicate(self());
+      return ChangePredicates.editBy(self());
+    }
+
+    if ("attention".equalsIgnoreCase(value)) {
+      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
+        throw new QueryParseException(
+            "'has:attention' operator is not supported by change index version");
+      }
+      return new IsAttentionPredicate();
     }
 
     if ("unresolved".equalsIgnoreCase(value)) {
@@ -598,11 +644,11 @@
   @Operator
   public Predicate<ChangeData> is(String value) throws QueryParseException {
     if ("starred".equalsIgnoreCase(value)) {
-      return starredby(self());
+      return starredBySelf();
     }
 
     if ("watched".equalsIgnoreCase(value)) {
-      return new IsWatchedByPredicate(args, false);
+      return new IsWatchedByPredicate(args);
     }
 
     if ("visible".equalsIgnoreCase(value)) {
@@ -610,11 +656,19 @@
     }
 
     if ("reviewed".equalsIgnoreCase(value)) {
-      return IsReviewedPredicate.create();
+      return ChangePredicates.unreviewed();
     }
 
     if ("owner".equalsIgnoreCase(value)) {
-      return new OwnerPredicate(self());
+      return ChangePredicates.owner(self());
+    }
+
+    if ("uploader".equalsIgnoreCase(value)) {
+      if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+        throw new QueryParseException(
+            "'is:uploader' operator is not supported by change index version");
+      }
+      return ChangePredicates.uploader(self());
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
@@ -652,12 +706,20 @@
           "'is:private' operator is not supported by change index version");
     }
 
+    if ("attention".equalsIgnoreCase(value)) {
+      if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
+        throw new QueryParseException(
+            "'is:attention' operator is not supported by change index version");
+      }
+      return new IsAttentionPredicate();
+    }
+
     if ("assigned".equalsIgnoreCase(value)) {
-      return Predicate.not(new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE)));
+      return Predicate.not(ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE)));
     }
 
     if ("unassigned".equalsIgnoreCase(value)) {
-      return new AssigneePredicate(Account.id(ChangeField.NO_ASSIGNEE));
+      return ChangePredicates.assignee(Account.id(ChangeField.NO_ASSIGNEE));
     }
 
     if ("submittable".equalsIgnoreCase(value)) {
@@ -673,7 +735,7 @@
     }
 
     if ("ignored".equalsIgnoreCase(value)) {
-      return star("ignore");
+      return ignoredBySelf();
     }
 
     if ("started".equalsIgnoreCase(value)) {
@@ -691,6 +753,14 @@
       throw new QueryParseException("'is:wip' operator is not supported by change index version");
     }
 
+    if ("cherrypick".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.CHERRY_PICK)) {
+        return new BooleanPredicate(ChangeField.CHERRY_PICK);
+      }
+      throw new QueryParseException(
+          "'is:cherrypick' operator is not supported by change index version");
+    }
+
     // for plugins the value will be operandName_pluginName
     List<String> names = Lists.newArrayList(Splitter.on('_').split(value));
     if (names.size() == 2) {
@@ -704,11 +774,14 @@
 
   @Operator
   public Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   @Operator
   public Predicate<ChangeData> conflicts(String value) throws QueryParseException {
+    if (!args.conflictsPredicateEnabled) {
+      throw new QueryParseException("'conflicts:' operator is not supported by server");
+    }
     List<Change> changes = parseChange(value);
     List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
     for (Change c : changes) {
@@ -727,12 +800,12 @@
     if (name.startsWith("^")) {
       return new RegexProjectPredicate(name);
     }
-    return new ProjectPredicate(name);
+    return ChangePredicates.project(Project.nameKey(name));
   }
 
   @Operator
   public Predicate<ChangeData> projects(String name) {
-    return new ProjectPrefixPredicate(name);
+    return ChangePredicates.projectPrefix(name);
   }
 
   @Operator
@@ -790,12 +863,28 @@
 
   @Operator
   public Predicate<ChangeData> hashtag(String hashtag) {
-    return new HashtagPredicate(hashtag);
+    return ChangePredicates.hashtag(hashtag);
+  }
+
+  @Operator
+  public Predicate<ChangeData> inhashtag(String hashtag) throws QueryParseException {
+    if (hashtag.startsWith("^")) {
+      return new RegexHashtagPredicate(hashtag);
+    }
+    if (hashtag.isEmpty()) {
+      return ChangePredicates.hashtag(hashtag);
+    }
+
+    if (!args.index.getSchema().hasField(ChangeField.FUZZY_HASHTAG)) {
+      throw new QueryParseException(
+          "'inhashtag' operator is not supported by change index version");
+    }
+    return ChangePredicates.fuzzyHashtag(hashtag);
   }
 
   @Operator
   public Predicate<ChangeData> topic(String name) {
-    return new ExactTopicPredicate(name);
+    return ChangePredicates.exactTopic(name);
   }
 
   @Operator
@@ -804,9 +893,9 @@
       return new RegexTopicPredicate(name);
     }
     if (name.isEmpty()) {
-      return new ExactTopicPredicate(name);
+      return ChangePredicates.exactTopic(name);
     }
-    return new FuzzyTopicPredicate(name, args.index);
+    return ChangePredicates.fuzzyTopic(name);
   }
 
   @Operator
@@ -814,7 +903,7 @@
     if (ref.startsWith("^")) {
       return new RegexRefPredicate(ref);
     }
-    return new RefPredicate(ref);
+    return ChangePredicates.ref(ref);
   }
 
   @Operator
@@ -827,7 +916,7 @@
     if (file.startsWith("^")) {
       return new RegexPathPredicate(file);
     }
-    return EqualsFilePredicate.create(args, file);
+    return ChangePredicates.file(args, file);
   }
 
   @Operator
@@ -835,7 +924,7 @@
     if (path.startsWith("^")) {
       return new RegexPathPredicate(path);
     }
-    return new EqualsPathPredicate(FIELD_PATH, path);
+    return ChangePredicates.path(path);
   }
 
   @Operator
@@ -868,7 +957,7 @@
   @Operator
   public Predicate<ChangeData> footer(String footer) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.FOOTER)) {
-      return new FooterPredicate(footer);
+      return ChangePredicates.footer(footer);
     }
     throw new QueryParseException("'footer' operator is not supported by change index version");
   }
@@ -884,7 +973,7 @@
       if (directory.startsWith("^")) {
         return new RegexDirectoryPredicate(directory);
       }
-      return new DirectoryPredicate(directory);
+      return ChangePredicates.directory(directory);
     }
     throw new QueryParseException("'directory' operator is not supported by change index version");
   }
@@ -915,6 +1004,8 @@
         if (pair.getKey().equalsIgnoreCase(ARG_ID_USER)) {
           if (pair.getValue().equals(ARG_ID_OWNER)) {
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else if (pair.getValue().equals(ARG_ID_NON_UPLOADER)) {
+            accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
           } else {
             accounts = parseAccount(pair.getValue());
           }
@@ -932,6 +1023,8 @@
         try {
           if (value.equals(ARG_ID_OWNER)) {
             accounts = Collections.singleton(OWNER_ACCOUNT_ID);
+          } else if (value.equals(ARG_ID_NON_UPLOADER)) {
+            accounts = Collections.singleton(NON_UPLOADER_ACCOUNT_ID);
           } else {
             accounts = parseAccount(value);
           }
@@ -956,7 +1049,7 @@
     int eq = name.indexOf('=');
     if (args.getSchema().hasField(ChangeField.SUBMIT_RECORD) && eq > 0) {
       String statusName = name.substring(eq + 1).toUpperCase();
-      if (!isInt(statusName)) {
+      if (!isInt(statusName) && !MagicLabelValue.tryParse(statusName).isPresent()) {
         SubmitRecord.Label.Status status =
             Enums.getIfPresent(SubmitRecord.Label.Status.class, statusName).orNull();
         if (status == null) {
@@ -981,70 +1074,30 @@
 
   @Operator
   public Predicate<ChangeData> message(String text) {
-    return new MessagePredicate(args.index, text);
+    return ChangePredicates.message(text);
   }
 
   @Operator
   public Predicate<ChangeData> star(String label) throws QueryParseException {
-    return new StarPredicate(self(), label);
-  }
-
-  @Operator
-  public Predicate<ChangeData> starredby(String who)
-      throws QueryParseException, IOException, ConfigInvalidException {
-    return starredby(parseAccount(who));
-  }
-
-  private Predicate<ChangeData> starredby(Set<Account.Id> who) {
-    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
-    for (Account.Id id : who) {
-      p.add(starredby(id));
+    if ("ignore".equalsIgnoreCase(label)) {
+      return ignoredBySelf();
     }
-    return Predicate.or(p);
-  }
-
-  private Predicate<ChangeData> starredby(Account.Id who) {
-    return new StarPredicate(who, StarredChangesUtil.DEFAULT_LABEL);
-  }
-
-  @Operator
-  public Predicate<ChangeData> watchedby(String who)
-      throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> m = parseAccount(who);
-    List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size());
-
-    Account.Id callerId;
-    try {
-      CurrentUser caller = args.self.get();
-      callerId = caller.isIdentifiedUser() ? caller.getAccountId() : null;
-    } catch (ProvisionException e) {
-      callerId = null;
+    if ("star".equalsIgnoreCase(label)) {
+      return starredBySelf();
     }
-
-    for (Account.Id id : m) {
-      // Each child IsWatchedByPredicate includes a visibility filter for the
-      // corresponding user, to ensure that predicate subtree only returns
-      // changes visible to that user. The exception is if one of the users is
-      // the caller of this method, in which case visibility is already being
-      // checked at the top level.
-      p.add(new IsWatchedByPredicate(args.asUser(id), !id.equals(callerId)));
-    }
-    return Predicate.or(p);
+    throw new IllegalArgumentException();
   }
 
-  @Operator
-  public Predicate<ChangeData> draftby(String who)
-      throws QueryParseException, IOException, ConfigInvalidException {
-    Set<Account.Id> m = parseAccount(who);
-    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
-    for (Account.Id id : m) {
-      p.add(draftby(id));
-    }
-    return Predicate.or(p);
+  private Predicate<ChangeData> ignoredBySelf() throws QueryParseException {
+    return new StarPredicate(self(), StarredChangesUtil.IGNORE_LABEL);
   }
 
-  private Predicate<ChangeData> draftby(Account.Id who) {
-    return new HasDraftByPredicate(who);
+  private Predicate<ChangeData> starredBySelf() throws QueryParseException {
+    return new StarPredicate(self(), StarredChangesUtil.DEFAULT_LABEL);
+  }
+
+  private Predicate<ChangeData> draftBySelf() throws QueryParseException {
+    return ChangePredicates.draftBy(self());
   }
 
   @Operator
@@ -1103,9 +1156,9 @@
   }
 
   private Predicate<ChangeData> owner(Set<Account.Id> who) {
-    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(who.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
-      p.add(new OwnerPredicate(id));
+      p.add(ChangePredicates.owner(id));
     }
     return Predicate.or(p);
   }
@@ -1120,6 +1173,23 @@
   }
 
   @Operator
+  public Predicate<ChangeData> uploader(String who)
+      throws QueryParseException, IOException, ConfigInvalidException {
+    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+      throw new QueryParseException("'uploader' operator is not supported by change index version");
+    }
+    return uploader(parseAccount(who, (AccountState s) -> true));
+  }
+
+  private Predicate<ChangeData> uploader(Set<Account.Id> who) {
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
+    for (Account.Id id : who) {
+      p.add(ChangePredicates.uploader(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
   public Predicate<ChangeData> attention(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
     if (!args.index.getSchema().hasField(ChangeField.ATTENTION_SET_USERS)) {
@@ -1130,7 +1200,7 @@
   }
 
   private Predicate<ChangeData> attention(Set<Account.Id> who) {
-    return Predicate.or(who.stream().map(AttentionSetPredicate::new).collect(toImmutableSet()));
+    return Predicate.or(who.stream().map(ChangePredicates::attentionSet).collect(toImmutableSet()));
   }
 
   @Operator
@@ -1140,9 +1210,9 @@
   }
 
   private Predicate<ChangeData> assignee(Set<Account.Id> who) {
-    List<AssigneePredicate> p = Lists.newArrayListWithCapacity(who.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
-      p.add(new AssigneePredicate(id));
+      p.add(ChangePredicates.assignee(id));
     }
     return Predicate.or(p);
   }
@@ -1161,9 +1231,34 @@
     }
 
     Set<Account.Id> accounts = getMembers(groupId);
-    List<OwnerPredicate> p = Lists.newArrayListWithCapacity(accounts.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(accounts.size());
     for (Account.Id id : accounts) {
-      p.add(new OwnerPredicate(id));
+      p.add(ChangePredicates.owner(id));
+    }
+    return Predicate.or(p);
+  }
+
+  @Operator
+  public Predicate<ChangeData> uploaderin(String group) throws QueryParseException, IOException {
+    if (!args.getSchema().hasField(ChangeField.UPLOADER)) {
+      throw new QueryParseException("'uploader' operator is not supported by change index version");
+    }
+
+    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, group);
+    if (g == null) {
+      throw error("Group " + group + " not found");
+    }
+
+    AccountGroup.UUID groupId = g.getUUID();
+    GroupDescription.Basic groupDescription = args.groupBackend.get(groupId);
+    if (!(groupDescription instanceof GroupDescription.Internal)) {
+      return new UploaderinPredicate(args.userFactory, groupId);
+    }
+
+    Set<Account.Id> accounts = getMembers(groupId);
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(accounts.size());
+    for (Account.Id id : accounts) {
+      p.add(ChangePredicates.uploader(id));
     }
     return Predicate.or(p);
   }
@@ -1215,7 +1310,7 @@
 
   @Operator
   public Predicate<ChangeData> tr(String trackingId) {
-    return new TrackingIdPredicate(trackingId);
+    return ChangePredicates.trackingId(trackingId);
   }
 
   @Operator
@@ -1259,9 +1354,9 @@
   }
 
   private Predicate<ChangeData> commentby(Set<Account.Id> who) {
-    List<CommentByPredicate> p = Lists.newArrayListWithCapacity(who.size());
+    List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(who.size());
     for (Account.Id id : who) {
-      p.add(new CommentByPredicate(id));
+      p.add(ChangePredicates.commentBy(id));
     }
     return Predicate.or(p);
   }
@@ -1321,7 +1416,7 @@
   @Operator
   public Predicate<ChangeData> reviewedby(String who)
       throws QueryParseException, IOException, ConfigInvalidException {
-    return IsReviewedPredicate.create(parseAccount(who));
+    return ChangePredicates.reviewedBy(parseAccount(who));
   }
 
   @Operator
@@ -1373,18 +1468,18 @@
   public Predicate<ChangeData> author(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ExactAuthorPredicate::new, AuthorPredicate::new);
+          who.trim(), ChangePredicates::exactAuthor, ChangePredicates::author);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::author);
   }
 
   @Operator
   public Predicate<ChangeData> committer(String who) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
       return getAuthorOrCommitterPredicate(
-          who.trim(), ExactCommitterPredicate::new, CommitterPredicate::new);
+          who.trim(), ChangePredicates::exactCommitter, ChangePredicates::committer);
     }
-    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), ChangePredicates::committer);
   }
 
   @Operator
@@ -1404,8 +1499,11 @@
 
   @Operator
   public Predicate<ChangeData> revertof(String value) throws QueryParseException {
+    if (value == null || Ints.tryParse(value) == null) {
+      throw new QueryParseException("'revertof' must be an integer");
+    }
     if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
-      return new RevertOfPredicate(value);
+      return ChangePredicates.revertOf(Change.id(Ints.tryParse(value)));
     }
     throw new QueryParseException("'revertof' operator is not supported by change index version");
   }
@@ -1413,7 +1511,7 @@
   @Operator
   public Predicate<ChangeData> submissionId(String value) throws QueryParseException {
     if (args.getSchema().hasField(ChangeField.SUBMISSIONID)) {
-      return new SubmissionIdPredicate(value);
+      return ChangePredicates.submissionId(value);
     }
     throw new QueryParseException(
         "'submissionid' operator is not supported by change index version");
@@ -1424,13 +1522,11 @@
     if (args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_CHANGE)
         && args.getSchema().hasField(ChangeField.CHERRY_PICK_OF_PATCHSET)) {
       if (Ints.tryParse(value) != null) {
-        return new CherryPickOfChangePredicate(value);
+        return ChangePredicates.cherryPickOf(Change.id(Ints.tryParse(value)));
       }
       try {
         PatchSet.Id patchSetId = PatchSet.Id.parse(value);
-        return Predicate.and(
-            new CherryPickOfChangePredicate(patchSetId.changeId().toString()),
-            new CherryPickOfPatchSetPredicate(patchSetId.getId()));
+        return ChangePredicates.cherryPickOf(patchSetId);
       } catch (IllegalArgumentException e) {
         throw new QueryParseException(
             "'"
diff --git a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 88e93d9..0721433 100644
--- a/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -124,11 +124,6 @@
   }
 
   @Override
-  public int getCost() {
-    return 0;
-  }
-
-  @Override
   public int hashCode() {
     return Objects.hashCode(status);
   }
diff --git a/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java b/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java
deleted file mode 100644
index d452017..0000000
--- a/java/com/google/gerrit/server/query/change/CherryPickOfChangePredicate.java
+++ /dev/null
@@ -1,36 +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.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CherryPickOfChangePredicate extends ChangeIndexPredicate {
-  public CherryPickOfChangePredicate(String cherryPickOfChange) {
-    super(ChangeField.CHERRY_PICK_OF_CHANGE, cherryPickOfChange);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (cd.change().getCherryPickOf() == null) {
-      return false;
-    }
-    return Integer.toString(cd.change().getCherryPickOf().changeId().get()).equals(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java b/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java
deleted file mode 100644
index 888f45d..0000000
--- a/java/com/google/gerrit/server/query/change/CherryPickOfPatchSetPredicate.java
+++ /dev/null
@@ -1,36 +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.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CherryPickOfPatchSetPredicate extends ChangeIndexPredicate {
-  public CherryPickOfPatchSetPredicate(String cherryPickOfPatchSet) {
-    super(ChangeField.CHERRY_PICK_OF_PATCHSET, cherryPickOfPatchSet);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (cd.change().getCherryPickOf() == null) {
-      return false;
-    }
-    return cd.change().getCherryPickOf().getId().equals(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/java/com/google/gerrit/server/query/change/CommentByPredicate.java
deleted file mode 100644
index b8cf100..0000000
--- a/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.HumanComment;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Objects;
-
-public class CommentByPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public CommentByPredicate(Account.Id id) {
-    super(ChangeField.COMMENTBY, id.toString());
-    this.id = id;
-  }
-
-  Account.Id getAccountId() {
-    return id;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    for (ChangeMessage m : cd.messages()) {
-      if (Objects.equals(m.getAuthor(), id)) {
-        return true;
-      }
-    }
-    for (HumanComment c : cd.publishedComments()) {
-      if (Objects.equals(c.author.getId(), id)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommentPredicate.java b/java/com/google/gerrit/server/query/change/CommentPredicate.java
deleted file mode 100644
index 4b14f08..0000000
--- a/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-public class CommentPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public CommentPredicate(ChangeIndex index, String value) {
-    super(ChangeField.COMMENT, value);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    try {
-      Change.Id id = object.getId();
-      Predicate<ChangeData> p =
-          Predicate.and(
-              index.getSchema().useLegacyNumericFields()
-                  ? new LegacyChangeIdPredicate(id)
-                  : new LegacyChangeIdStrPredicate(id),
-              this);
-      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(id)) {
-          return true;
-        }
-      }
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommitPredicate.java b/java/com/google/gerrit/server/query/change/CommitPredicate.java
deleted file mode 100644
index b54ee64..0000000
--- a/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.git.ObjectIds.matchesAbbreviation;
-import static com.google.gerrit.server.index.change.ChangeField.COMMIT;
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
-
-import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.git.ObjectIds;
-import com.google.gerrit.index.FieldDef;
-
-public class CommitPredicate extends ChangeIndexPredicate {
-  static FieldDef<ChangeData, ?> commitField(String id) {
-    if (id.length() == ObjectIds.STR_LEN) {
-      return EXACT_COMMIT;
-    }
-    return COMMIT;
-  }
-
-  public CommitPredicate(String id) {
-    super(commitField(id), id);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    String id = getValue().toLowerCase();
-    for (PatchSet p : object.patchSets()) {
-      if (equals(p, id)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  protected boolean equals(PatchSet p, String id) {
-    if (getField() == EXACT_COMMIT) {
-      return p.commitId().name().equals(id);
-    }
-    return matchesAbbreviation(p.commitId(), id);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/java/com/google/gerrit/server/query/change/CommitterPredicate.java
deleted file mode 100644
index 1dcf97f..0000000
--- a/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.COMMITTER;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_COMMITTER;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class CommitterPredicate extends ChangeIndexPredicate {
-  public CommitterPredicate(String value) {
-    super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getCommitterParts(object).contains(getValue().toLowerCase());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index f4af4ca..f95dbb0 100644
--- a/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -83,17 +83,17 @@
 
     List<Predicate<ChangeData>> filePredicates = new ArrayList<>(files.size());
     for (String file : files) {
-      filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+      filePredicates.add(ChangePredicates.path(file));
     }
 
     List<Predicate<ChangeData>> and = new ArrayList<>(5);
-    and.add(new ProjectPredicate(c.getProject().get()));
-    and.add(new RefPredicate(c.getDest().branch()));
+    and.add(ChangePredicates.project(c.getProject()));
+    and.add(ChangePredicates.ref(c.getDest().branch()));
     and.add(
         Predicate.not(
             args.getSchema().useLegacyNumericFields()
-                ? new LegacyChangeIdPredicate(c.getId())
-                : new LegacyChangeIdStrPredicate(c.getId())));
+                ? ChangePredicates.id(c.getId())
+                : ChangePredicates.idStr(c.getId())));
     and.add(Predicate.or(filePredicates));
 
     ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
diff --git a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java b/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
deleted file mode 100644
index 3ab3e26..0000000
--- a/java/com/google/gerrit/server/query/change/DirectoryPredicate.java
+++ /dev/null
@@ -1,39 +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.query.change;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class DirectoryPredicate extends ChangeIndexPredicate {
-  private static String clean(String directory) {
-    return CharMatcher.is('/').trimFrom(directory).toLowerCase(Locale.US);
-  }
-
-  DirectoryPredicate(String value) {
-    super(ChangeField.DIRECTORY, clean(value));
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getDirectories(cd).contains(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EditByPredicate.java b/java/com/google/gerrit/server/query/change/EditByPredicate.java
deleted file mode 100644
index 0fd66f8..0000000
--- a/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class EditByPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public EditByPredicate(Account.Id id) {
-    super(ChangeField.EDITBY, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.editsByUser().contains(id);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
deleted file mode 100644
index 9c033b6..0000000
--- a/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-
-public class EqualsFilePredicate extends ChangeIndexPredicate {
-  public static Predicate<ChangeData> create(Arguments args, String value) {
-    Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
-    if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
-      return eqPath;
-    }
-    return Predicate.or(eqPath, new EqualsFilePredicate(value));
-  }
-
-  private EqualsFilePredicate(String value) {
-    super(ChangeField.FILE_PART, ChangeQueryBuilder.FIELD_FILE, value);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getFileParts(object).contains(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 30d5e2f..12efecb 100644
--- a/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -91,8 +91,8 @@
   }
 
   protected static LabelType type(LabelTypes types, String toFind) {
-    if (types.byLabel(toFind) != null) {
-      return types.byLabel(toFind);
+    if (types.byLabel(toFind).isPresent()) {
+      return types.byLabel(toFind).get();
     }
 
     for (LabelType lt : types.getLabelTypes()) {
@@ -108,16 +108,23 @@
       return false;
     }
 
-    if (account != null
-        && !account.equals(approver)
-        && !account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)) {
-      return false;
-    }
+    if (account != null) {
+      // case when account in query is numeric
+      if (!account.equals(approver) && !isMagicUser()) {
+        return false;
+      }
 
-    if (account != null
-        && account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
-        && !cd.change().getOwner().equals(approver)) {
-      return false;
+      // case when account in query = owner
+      if (account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
+          && !cd.change().getOwner().equals(approver)) {
+        return false;
+      }
+
+      // case when account in query = non_uploader
+      if (account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID)
+          && cd.currentPatchSet().uploader().equals(approver)) {
+        return false;
+      }
     }
 
     IdentifiedUser reviewer = userFactory.create(approver);
@@ -139,6 +146,11 @@
     }
   }
 
+  private boolean isMagicUser() {
+    return account.equals(ChangeQueryBuilder.OWNER_ACCOUNT_ID)
+        || account.equals(ChangeQueryBuilder.NON_UPLOADER_ACCOUNT_ID);
+  }
+
   @Override
   public int getCost() {
     return 1 + (group == null ? 0 : 1);
diff --git a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
deleted file mode 100644
index 76936fa..0000000
--- a/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ /dev/null
@@ -1,34 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Collections;
-
-public class EqualsPathPredicate extends ChangeIndexPredicate {
-  public EqualsPathPredicate(String fieldName, String value) {
-    super(ChangeField.PATH, fieldName, value);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return Collections.binarySearch(object.currentFilePaths(), value) >= 0;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
deleted file mode 100644
index c1b6928..0000000
--- a/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_AUTHOR;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTAUTHOR;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class ExactAuthorPredicate extends ChangeIndexPredicate {
-  public ExactAuthorPredicate(String value) {
-    super(EXACT_AUTHOR, FIELD_EXACTAUTHOR, value.toLowerCase(Locale.US));
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
deleted file mode 100644
index dac63af..0000000
--- a/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMITTER;
-import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTCOMMITTER;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class ExactCommitterPredicate extends ChangeIndexPredicate {
-  public ExactCommitterPredicate(String value) {
-    super(EXACT_COMMITTER, FIELD_EXACTCOMMITTER, value.toLowerCase(Locale.US));
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
deleted file mode 100644
index 6683c91..0000000
--- a/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.EXACT_TOPIC;
-
-import com.google.gerrit.entities.Change;
-
-public class ExactTopicPredicate extends ChangeIndexPredicate {
-  public ExactTopicPredicate(String topic) {
-    super(EXACT_TOPIC, topic);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return getValue().equals(change.getTopic());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
index bddd2ec..c16bc83 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionListPredicate.java
@@ -36,9 +36,4 @@
   public boolean match(ChangeData cd) {
     return ChangeField.getAllExtensionsAsList(cd).equals(value);
   }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
index ee573a7..39715cf 100644
--- a/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
+++ b/java/com/google/gerrit/server/query/change/FileExtensionPredicate.java
@@ -33,9 +33,4 @@
   public boolean match(ChangeData object) {
     return ChangeField.getExtensions(object).contains(value);
   }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/FooterPredicate.java b/java/com/google/gerrit/server/query/change/FooterPredicate.java
deleted file mode 100644
index 4d7588c..0000000
--- a/java/com/google/gerrit/server/query/change/FooterPredicate.java
+++ /dev/null
@@ -1,45 +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.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.Locale;
-
-public class FooterPredicate extends ChangeIndexPredicate {
-  private static String clean(String value) {
-    int indexEquals = value.indexOf('=');
-    int indexColon = value.indexOf(':');
-
-    // footer key cannot contain '='
-    if (indexEquals > 0 && (indexEquals < indexColon || indexColon < 0)) {
-      value = value.substring(0, indexEquals) + ": " + value.substring(indexEquals + 1);
-    }
-    return value.toLowerCase(Locale.US);
-  }
-
-  FooterPredicate(String value) {
-    super(ChangeField.FOOTER, clean(value));
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return ChangeField.getFooters(cd).contains(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
deleted file mode 100644
index d558b0f..0000000
--- a/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ /dev/null
@@ -1,62 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
-
-import com.google.common.collect.Iterables;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-public class FuzzyTopicPredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public FuzzyTopicPredicate(String topic, ChangeIndex index) {
-    super(FUZZY_TOPIC, topic);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Change change = cd.change();
-    if (change == null) {
-      return false;
-    }
-    String t = change.getTopic();
-    if (t == null) {
-      return false;
-    }
-    try {
-      Predicate<ChangeData> thisId =
-          index.getSchema().useLegacyNumericFields()
-              ? new LegacyChangeIdPredicate(cd.getId())
-              : new LegacyChangeIdStrPredicate(cd.getId());
-      Iterable<ChangeData> results =
-          index.getSource(and(thisId, this), IndexedChangeQuery.oneResult()).read();
-      return !Iterables.isEmpty(results);
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/GroupPredicate.java b/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 99f37d6..f470cf9 100644
--- a/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -33,9 +33,4 @@
     }
     return false;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
deleted file mode 100644
index 6d1576f..0000000
--- a/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class HasDraftByPredicate extends ChangeIndexPredicate {
-  protected final Account.Id accountId;
-
-  public HasDraftByPredicate(Account.Id accountId) {
-    super(ChangeField.DRAFTBY, accountId.toString());
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.draftsByUser().contains(accountId);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
deleted file mode 100644
index 2fbd1e8..0000000
--- a/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ /dev/null
@@ -1,42 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class HasStarsPredicate extends ChangeIndexPredicate {
-  protected final Account.Id accountId;
-
-  public HasStarsPredicate(Account.Id accountId) {
-    super(ChangeField.STARBY, accountId.toString());
-    this.accountId = accountId;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.stars().containsKey(accountId);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_STARBY + ":" + accountId;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/java/com/google/gerrit/server/query/change/HashtagPredicate.java
deleted file mode 100644
index 1fe4af4..0000000
--- a/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.server.change.HashtagsUtil;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class HashtagPredicate extends ChangeIndexPredicate {
-  public HashtagPredicate(String hashtag) {
-    // Use toLowerCase without locale to match behavior in ChangeField.
-    // TODO(dborowitz): Change both.
-    super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag).toLowerCase());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    for (String hashtag : object.notes().load().getHashtags()) {
-      if (hashtag.equalsIgnoreCase(getValue())) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index 1012f4a..c8e9f66 100644
--- a/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -61,15 +61,15 @@
   }
 
   private static Predicate<ChangeData> ref(BranchNameKey branch) {
-    return new RefPredicate(branch.branch());
+    return ChangePredicates.ref(branch.branch());
   }
 
   private static Predicate<ChangeData> change(Change.Key key) {
-    return new ChangeIdPredicate(key.get());
+    return ChangePredicates.idPrefix(key.get());
   }
 
   private static Predicate<ChangeData> project(Project.NameKey project) {
-    return new ProjectPredicate(project.get());
+    return ChangePredicates.project(project);
   }
 
   private static Predicate<ChangeData> status(Change.Status status) {
@@ -77,7 +77,7 @@
   }
 
   private static Predicate<ChangeData> commit(String id) {
-    return new CommitPredicate(id);
+    return ChangePredicates.commitPrefix(id);
   }
 
   private final ChangeData.Factory changeDataFactory;
@@ -99,8 +99,8 @@
     predicateFactory =
         (id) ->
             schema().useLegacyNumericFields()
-                ? new LegacyChangeIdPredicate(id)
-                : new LegacyChangeIdStrPredicate(id);
+                ? ChangePredicates.id(id)
+                : ChangePredicates.idStr(id);
   }
 
   public List<ChangeData> byKey(Change.Key key) {
@@ -108,7 +108,7 @@
   }
 
   public List<ChangeData> byKeyPrefix(String prefix) {
-    return query(new ChangeIdPredicate(prefix));
+    return query(ChangePredicates.idPrefix(prefix));
   }
 
   public List<ChangeData> byLegacyChangeId(Change.Id id) {
@@ -166,7 +166,7 @@
   Iterable<ChangeData> byCommitsOnBranchNotMerged(
       Repository repo, BranchNameKey branch, Collection<String> hashes, int indexLimit)
       throws IOException {
-    if (hashes.size() > indexLimit) {
+    if (hashes.size() > indexLimit || !indexes.getSearchIndex().isEnabled()) {
       return byCommitsOnBranchNotMergedFromDatabase(repo, branch, hashes);
     }
     return byCommitsOnBranchNotMergedFromIndex(branch, hashes);
@@ -226,7 +226,7 @@
   }
 
   public List<ChangeData> byTopicOpen(String topic) {
-    return query(and(new ExactTopicPredicate(topic), open()));
+    return query(and(ChangePredicates.exactTopic(topic), open()));
   }
 
   public List<ChangeData> byCommit(ObjectId id) {
@@ -270,14 +270,17 @@
 
   private static Predicate<ChangeData> byBranchCommitPred(
       String project, String branch, String hash) {
-    return and(new ProjectPredicate(project), new RefPredicate(branch), commit(hash));
+    return and(
+        ChangePredicates.project(Project.nameKey(project)),
+        ChangePredicates.ref(branch),
+        commit(hash));
   }
 
   public List<ChangeData> bySubmissionId(String cs) {
     if (Strings.isNullOrEmpty(cs)) {
       return Collections.emptyList();
     }
-    return query(new SubmissionIdPredicate(cs));
+    return query(ChangePredicates.submissionId(cs));
   }
 
   private static Predicate<ChangeData> byProjectGroupsPredicate(
diff --git a/java/com/google/gerrit/server/query/change/IsAttentionPredicate.java b/java/com/google/gerrit/server/query/change/IsAttentionPredicate.java
new file mode 100644
index 0000000..d20d64a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/IsAttentionPredicate.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.query.change;
+
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.index.change.ChangeField;
+
+public class IsAttentionPredicate extends IntegerRangeChangePredicate {
+  public IsAttentionPredicate() throws QueryParseException {
+    this(">0");
+  }
+
+  public IsAttentionPredicate(String value) throws QueryParseException {
+    super(ChangeField.ATTENTION_SET_USERS_COUNT, value);
+  }
+
+  @Override
+  protected Integer getValueInt(ChangeData changeData) {
+    return ChangeField.ATTENTION_SET_USERS_COUNT.get(changeData);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
deleted file mode 100644
index 4b32b06..0000000
--- a/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ /dev/null
@@ -1,59 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.REVIEWEDBY;
-
-import com.google.gerrit.entities.Account;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.server.index.change.ChangeField;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.List;
-import java.util.Set;
-
-public class IsReviewedPredicate extends ChangeIndexPredicate {
-  protected static final Account.Id NOT_REVIEWED = Account.id(ChangeField.NOT_REVIEWED);
-
-  public static Predicate<ChangeData> create() {
-    return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
-  }
-
-  public static Predicate<ChangeData> create(Collection<Account.Id> ids) {
-    List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
-    for (Account.Id id : ids) {
-      predicates.add(new IsReviewedPredicate(id));
-    }
-    return Predicate.or(predicates);
-  }
-
-  protected final Account.Id id;
-
-  private IsReviewedPredicate(Account.Id id) {
-    super(REVIEWEDBY, Integer.toString(id.get()));
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    Set<Account.Id> reviewedBy = cd.reviewedBy();
-    return !reviewedBy.isEmpty() ? reviewedBy.contains(id) : id.equals(NOT_REVIEWED);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index 3a43fd3..054a69e 100644
--- a/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -36,14 +36,13 @@
 
   protected final CurrentUser user;
 
-  public IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
-      throws QueryParseException {
-    super(filters(args, checkIsVisible));
+  public IsWatchedByPredicate(ChangeQueryBuilder.Arguments args) throws QueryParseException {
+    super(filters(args));
     this.user = args.getUser();
   }
 
-  protected static List<Predicate<ChangeData>> filters(
-      ChangeQueryBuilder.Arguments args, boolean checkIsVisible) throws QueryParseException {
+  protected static List<Predicate<ChangeData>> filters(ChangeQueryBuilder.Arguments args)
+      throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
     ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
     for (ProjectWatchKey w : getWatches(args)) {
@@ -82,11 +81,8 @@
     }
     if (r.isEmpty()) {
       return ImmutableList.of(ChangeIndexPredicate.none());
-    } else if (checkIsVisible) {
-      return ImmutableList.of(or(r), builder.isVisible());
-    } else {
-      return ImmutableList.of(or(r));
     }
+    return ImmutableList.of(or(r));
   }
 
   protected static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
diff --git a/java/com/google/gerrit/server/query/change/LabelPredicate.java b/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 38d1dbe..5f017fb 100644
--- a/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
@@ -59,12 +60,12 @@
   protected static class Parsed {
     protected final String label;
     protected final String test;
-    protected final int expVal;
+    protected final int numericValue;
 
-    protected Parsed(String label, String test, int expVal) {
+    protected Parsed(String label, String test, int numericValue) {
       this.label = label;
       this.test = test;
-      this.expVal = expVal;
+      this.numericValue = numericValue;
     }
   }
 
@@ -83,6 +84,14 @@
 
   protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
+
+    try {
+      MagicLabelVote mlv = MagicLabelVote.parseWithEquals(v);
+      return ImmutableList.of(magicLabelPredicate(args, mlv));
+    } catch (IllegalArgumentException e) {
+      // Try next format.
+    }
+
     Parsed parsed = null;
 
     try {
@@ -108,7 +117,7 @@
     } else {
       range =
           RangeUtil.getRange(
-              parsed.label, parsed.test, parsed.expVal, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
+              parsed.label, parsed.test, parsed.numericValue, -MAX_LABEL_VALUE, MAX_LABEL_VALUE);
     }
     String prefix = range.prefix;
     int min = range.min;
@@ -148,6 +157,17 @@
     return or(r);
   }
 
+  protected static Predicate<ChangeData> magicLabelPredicate(Args args, MagicLabelVote mlv) {
+    if (args.accounts == null || args.accounts.isEmpty()) {
+      return new MagicLabelPredicate(args, mlv, /* account= */ null);
+    }
+    List<Predicate<ChangeData>> r = new ArrayList<>();
+    for (Account.Id a : args.accounts) {
+      r.add(new MagicLabelPredicate(args, mlv, a));
+    }
+    return or(r);
+  }
+
   @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_LABEL + ":" + value;
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
deleted file mode 100644
index d531236..0000000
--- a/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ /dev/null
@@ -1,39 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
-
-import com.google.gerrit.entities.Change;
-
-/** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
-  protected final Change.Id id;
-
-  public LegacyChangeIdPredicate(Change.Id id) {
-    super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return id.equals(object.getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java b/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
deleted file mode 100644
index bae9c0d..0000000
--- a/java/com/google/gerrit/server/query/change/LegacyChangeIdStrPredicate.java
+++ /dev/null
@@ -1,39 +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.query.change;
-
-import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID_STR;
-
-import com.google.gerrit.entities.Change;
-
-/** Predicate over change number (aka legacy ID or Change.Id). */
-public class LegacyChangeIdStrPredicate extends ChangeIndexPredicate {
-  protected final Change.Id id;
-
-  public LegacyChangeIdStrPredicate(Change.Id id) {
-    super(LEGACY_ID_STR, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
-    this.id = id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    return id.equals(object.getId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.java
new file mode 100644
index 0000000..3917c79
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelPredicate.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.query.change;
+
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelTypes;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+public class MagicLabelPredicate extends ChangeIndexPredicate {
+  protected final LabelPredicate.Args args;
+  private final MagicLabelVote magicLabelVote;
+  private final Account.Id account;
+
+  public MagicLabelPredicate(
+      LabelPredicate.Args args, MagicLabelVote magicLabelVote, Account.Id account) {
+    super(ChangeField.LABEL, magicLabelVote.formatLabel());
+    this.account = account;
+    this.args = args;
+    this.magicLabelVote = magicLabelVote;
+  }
+
+  @Override
+  public boolean match(ChangeData changeData) {
+    Change change = changeData.change();
+    if (change == null) {
+      // The change has disappeared.
+      //
+      return false;
+    }
+
+    Optional<ProjectState> project = args.projectCache.get(change.getDest().project());
+    if (!project.isPresent()) {
+      // The project has disappeared.
+      //
+      return false;
+    }
+
+    LabelType labelType = type(project.get().getLabelTypes(), magicLabelVote.label());
+    if (labelType == null) {
+      return false; // Label is not defined by this project.
+    }
+
+    switch (magicLabelVote.value()) {
+      case ANY:
+        return matchAny(changeData, labelType);
+      case MIN:
+        return matchNumeric(changeData, magicLabelVote.label(), labelType.getMin().getValue());
+      case MAX:
+        return matchNumeric(changeData, magicLabelVote.label(), labelType.getMax().getValue());
+    }
+
+    throw new IllegalStateException("Unsupported magic label value: " + magicLabelVote.value());
+  }
+
+  private boolean matchAny(ChangeData changeData, LabelType labelType) {
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (LabelValue labelValue : labelType.getValues()) {
+      if (labelValue.getValue() != 0) {
+        predicates.add(numericPredicate(labelType.getName(), labelValue.getValue()));
+      }
+    }
+    return or(predicates).asMatchable().match(changeData);
+  }
+
+  private boolean matchNumeric(ChangeData changeData, String label, short value) {
+    return numericPredicate(label, value).match(changeData);
+  }
+
+  private EqualsLabelPredicate numericPredicate(String label, short value) {
+    return new EqualsLabelPredicate(args, label, value, account);
+  }
+
+  protected static LabelType type(LabelTypes types, String toFind) {
+    if (types.byLabel(toFind).isPresent()) {
+      return types.byLabel(toFind).get();
+    }
+
+    for (LabelType lt : types.getLabelTypes()) {
+      if (toFind.equalsIgnoreCase(lt.getName())) {
+        return lt;
+      }
+    }
+    return null;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelValue.java b/java/com/google/gerrit/server/query/change/MagicLabelValue.java
new file mode 100644
index 0000000..c4bcbe3
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelValue.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.server.query.change;
+
+import java.util.Optional;
+
+public enum MagicLabelValue {
+  ANY,
+  MIN,
+  MAX;
+
+  public static Optional<MagicLabelValue> tryParse(String value) {
+    try {
+      return Optional.of(MagicLabelValue.valueOf(value));
+    } catch (IllegalArgumentException e) {
+      return Optional.empty();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MagicLabelVote.java b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
new file mode 100644
index 0000000..c29ac72
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/MagicLabelVote.java
@@ -0,0 +1,47 @@
+// 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 static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.gerrit.entities.LabelType;
+import java.util.Locale;
+
+/** An entity representing a special label vote that's not numeric, e.g. MAX, MIN, etc... */
+@AutoValue
+public abstract class MagicLabelVote {
+  public static MagicLabelVote parseWithEquals(String text) {
+    checkArgument(!Strings.isNullOrEmpty(text), "Empty label vote");
+    int e = text.lastIndexOf('=');
+    checkArgument(e >= 0, "Label vote missing '=': %s", text);
+    String label = text.substring(0, e);
+    String voteValue = text.substring(e + 1);
+    return create(label, MagicLabelValue.valueOf(voteValue));
+  }
+
+  public static MagicLabelVote create(String label, MagicLabelValue value) {
+    return new AutoValue_MagicLabelVote(LabelType.checkNameInternal(label), value);
+  }
+
+  public abstract String label();
+
+  public abstract MagicLabelValue value();
+
+  public String formatLabel() {
+    return label().toLowerCase(Locale.US) + "=" + value().name();
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/MessagePredicate.java b/java/com/google/gerrit/server/query/change/MessagePredicate.java
deleted file mode 100644
index 44cbd8e..0000000
--- a/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.exceptions.StorageException;
-import com.google.gerrit.index.query.Predicate;
-import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.index.change.IndexedChangeQuery;
-
-/** Predicate to match changes that contains specified text in commit messages body. */
-public class MessagePredicate extends ChangeIndexPredicate {
-  protected final ChangeIndex index;
-
-  public MessagePredicate(ChangeIndex index, String value) {
-    super(ChangeField.COMMIT_MESSAGE, value);
-    this.index = index;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    try {
-      Predicate<ChangeData> p =
-          Predicate.and(
-              index.getSchema().useLegacyNumericFields()
-                  ? new LegacyChangeIdPredicate(object.getId())
-                  : new LegacyChangeIdStrPredicate(object.getId()),
-              this);
-      for (ChangeData cData : index.getSource(p, IndexedChangeQuery.oneResult()).read()) {
-        if (cData.getId().equals(object.getId())) {
-          return true;
-        }
-      }
-    } catch (QueryParseException e) {
-      throw new StorageException(e);
-    }
-
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/java/com/google/gerrit/server/query/change/OwnerPredicate.java
deleted file mode 100644
index 923a9ca..0000000
--- a/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.Account;
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class OwnerPredicate extends ChangeIndexPredicate {
-  protected final Account.Id id;
-
-  public OwnerPredicate(Account.Id id) {
-    super(ChangeField.OWNER, id.toString());
-    this.id = id;
-  }
-
-  protected Account.Id getAccountId() {
-    return id;
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    return change != null && id.equals(change.getOwner());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index 2c82075..4a54c03 100644
--- a/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -47,10 +47,10 @@
     }
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.get().getName()));
+    r.add(ChangePredicates.project(projectState.get().getNameKey()));
     try {
       for (ProjectInfo p : childProjects.list(projectState.get().getNameKey())) {
-        r.add(new ProjectPredicate(p.name));
+        r.add(ChangePredicates.project(Project.nameKey(p.name)));
       }
     } catch (PermissionBackendException e) {
       logger.atWarning().withCause(e).log("cannot check permissions to expand child projects");
diff --git a/java/com/google/gerrit/server/query/change/PredicateArgs.java b/java/com/google/gerrit/server/query/change/PredicateArgs.java
index ad7917e..d82b9bc 100644
--- a/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -40,7 +40,6 @@
    * name]}.
    *
    * @param args arguments to be parsed
-   * @throws QueryParseException
    */
   PredicateArgs(String args) throws QueryParseException {
     positional = new ArrayList<>();
diff --git a/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPredicate.java
deleted file mode 100644
index db5a932..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.Change;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ProjectPredicate extends ChangeIndexPredicate {
-  public ProjectPredicate(String id) {
-    super(ChangeField.PROJECT, id);
-  }
-
-  protected Project.NameKey getValueKey() {
-    return Project.nameKey(getValue());
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-
-    Project.NameKey p = change.getDest().project();
-    return p.equals(getValueKey());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
deleted file mode 100644
index c23a175..0000000
--- a/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class ProjectPrefixPredicate extends ChangeIndexPredicate {
-  public ProjectPrefixPredicate(String prefix) {
-    super(ChangeField.PROJECTS, prefix);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change c = object.change();
-    return c != null && c.getDest().project().get().startsWith(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/RefPredicate.java b/java/com/google/gerrit/server/query/change/RefPredicate.java
deleted file mode 100644
index 1c70a62..0000000
--- a/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class RefPredicate extends ChangeIndexPredicate {
-  public RefPredicate(String ref) {
-    super(ChangeField.REF, ref);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    return getValue().equals(change.getDest().branch());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
new file mode 100644
index 0000000..24efa6a
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/RegexHashtagPredicate.java
@@ -0,0 +1,51 @@
+// 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 static com.google.gerrit.server.index.change.ChangeField.HASHTAG;
+
+import dk.brics.automaton.RegExp;
+import dk.brics.automaton.RunAutomaton;
+
+public class RegexHashtagPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
+
+  public RegexHashtagPredicate(String re) {
+    super(HASHTAG, re);
+
+    if (re.startsWith("^")) {
+      re = re.substring(1);
+    }
+
+    if (re.endsWith("$") && !re.endsWith("\\$")) {
+      re = re.substring(0, re.length() - 1);
+    }
+
+    this.pattern = new RunAutomaton(new RegExp(re).toAutomaton());
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    if (cd.hashtags().isEmpty()) {
+      return false;
+    }
+    return cd.hashtags().stream().anyMatch(ht -> pattern.run(ht));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/RevertOfPredicate.java b/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
deleted file mode 100644
index eea1b1e..0000000
--- a/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class RevertOfPredicate extends ChangeIndexPredicate {
-  public RevertOfPredicate(String revertOf) {
-    super(ChangeField.REVERT_OF, revertOf);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    if (cd.change().getRevertOf() == null) {
-      return false;
-    }
-    return cd.change().getRevertOf().toString().equals(value);
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
index 62fe9e8..3a51972 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -45,9 +45,4 @@
   public boolean match(ChangeData cd) {
     return cd.reviewersByEmail().asTable().get(state, adr) != null;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index d783f76..142e956 100644
--- a/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -52,9 +52,4 @@
   public boolean match(ChangeData cd) {
     return cd.reviewers().asTable().get(state, id) != null;
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/StarPredicate.java b/java/com/google/gerrit/server/query/change/StarPredicate.java
index 788f1a3..4a8fe43 100644
--- a/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -34,11 +34,6 @@
   }
 
   @Override
-  public int getCost() {
-    return 1;
-  }
-
-  @Override
   public String toString() {
     return ChangeQueryBuilder.FIELD_STAR + ":" + label;
   }
diff --git a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
deleted file mode 100644
index 093447e..0000000
--- a/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ /dev/null
@@ -1,41 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.entities.Change;
-import com.google.gerrit.server.index.change.ChangeField;
-
-public class SubmissionIdPredicate extends ChangeIndexPredicate {
-  public SubmissionIdPredicate(String changeSet) {
-    super(ChangeField.SUBMISSIONID, changeSet);
-  }
-
-  @Override
-  public boolean match(ChangeData object) {
-    Change change = object.change();
-    if (change == null) {
-      return false;
-    }
-    if (change.getSubmissionId() == null) {
-      return false;
-    }
-    return getValue().equals(change.getSubmissionId());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index 4ca684a..ecddbb6 100644
--- a/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -43,9 +43,4 @@
   public boolean match(ChangeData in) {
     return ChangeField.formatSubmitRecordValues(in).contains(getValue());
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
new file mode 100644
index 0000000..e63714f
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementChangeQueryBuilder.java
@@ -0,0 +1,35 @@
+// 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.gerrit.index.query.QueryBuilder;
+import com.google.inject.Inject;
+
+/**
+ * A query builder for submit requirement expressions that includes all {@link ChangeQueryBuilder}
+ * operators, in addition to extra operators contributed by this class.
+ *
+ * <p>Operators defined in this class cannot be used in change queries.
+ */
+public class SubmitRequirementChangeQueryBuilder extends ChangeQueryBuilder {
+
+  private static final QueryBuilder.Definition<ChangeData, ChangeQueryBuilder> def =
+      new QueryBuilder.Definition<>(SubmitRequirementChangeQueryBuilder.class);
+
+  @Inject
+  SubmitRequirementChangeQueryBuilder(Arguments args) {
+    super(def, args);
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SubmitRequirementPredicate.java b/java/com/google/gerrit/server/query/change/SubmitRequirementPredicate.java
new file mode 100644
index 0000000..184ede3
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/SubmitRequirementPredicate.java
@@ -0,0 +1,35 @@
+// 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.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
+
+/**
+ * Predicate that can be used in submit requirement expressions. Subclasses extended by this
+ * predicate cannot be used in search queries.
+ */
+public abstract class SubmitRequirementPredicate extends OperatorPredicate<ChangeData>
+    implements Matchable<ChangeData> {
+
+  public SubmitRequirementPredicate(String name, String value) {
+    super(name, value);
+  }
+
+  @Override
+  public boolean supportedForQueries() {
+    return false;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 2018fbc..060a92e 100644
--- a/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -30,9 +30,4 @@
     return cd.submitRecords(ChangeField.SUBMIT_RULE_OPTIONS_STRICT).stream()
         .anyMatch(r -> r.status == status);
   }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
 }
diff --git a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
deleted file mode 100644
index 622fa2c..0000000
--- a/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.server.index.change.ChangeField;
-
-public class TrackingIdPredicate extends ChangeIndexPredicate {
-  public TrackingIdPredicate(String trackingId) {
-    super(ChangeField.TR, trackingId);
-  }
-
-  @Override
-  public boolean match(ChangeData cd) {
-    return cd.trackingFooters().containsValue(getValue());
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/java/com/google/gerrit/server/query/change/UploaderinPredicate.java b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
new file mode 100644
index 0000000..f44c0b5
--- /dev/null
+++ b/java/com/google/gerrit/server/query/change/UploaderinPredicate.java
@@ -0,0 +1,50 @@
+// 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.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.server.IdentifiedUser;
+
+/**
+ * Predicate that matches changes where the latest patch set was uploaded by a user in the provided
+ * group.
+ */
+public class UploaderinPredicate extends PostFilterPredicate<ChangeData> {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
+
+  public UploaderinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+    super(ChangeQueryBuilder.FIELD_UPLOADERIN, uuid.get());
+    this.userFactory = userFactory;
+    this.uuid = uuid;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) {
+    PatchSet latestPatchSet = cd.currentPatchSet();
+    if (latestPatchSet == null) {
+      return false;
+    }
+    IdentifiedUser uploader = userFactory.create(latestPatchSet.uploader());
+    return uploader.getEffectiveGroups().contains(uuid);
+  }
+
+  @Override
+  public int getCost() {
+    return 2;
+  }
+}
diff --git a/java/com/google/gerrit/server/query/group/GroupPredicates.java b/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 992f60d..8a2dc8d 100644
--- a/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -44,7 +44,7 @@
   }
 
   public static Predicate<InternalGroup> name(String name) {
-    return new GroupPredicate(GroupField.NAME, GroupQueryBuilder.FIELD_NAME, name);
+    return new GroupPredicate(GroupField.NAME, name);
   }
 
   public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
diff --git a/java/com/google/gerrit/server/restapi/BUILD b/java/com/google/gerrit/server/restapi/BUILD
index 708e860..63817d2 100644
--- a/java/com/google/gerrit/server/restapi/BUILD
+++ b/java/com/google/gerrit/server/restapi/BUILD
@@ -35,6 +35,7 @@
         "//lib/auto:auto-value-annotations",
         "//lib/commons:compress",
         "//lib/commons:lang",
+        "//lib/errorprone:annotations",
         "//lib/flogger:api",
         "//lib/guice",
         "//lib/guice:guice-assistedinject",
diff --git a/java/com/google/gerrit/server/restapi/RestApiModule.java b/java/com/google/gerrit/server/restapi/RestApiModule.java
index 909c1f4..dffcf44 100644
--- a/java/com/google/gerrit/server/restapi/RestApiModule.java
+++ b/java/com/google/gerrit/server/restapi/RestApiModule.java
@@ -15,20 +15,26 @@
 package com.google.gerrit.server.restapi;
 
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.restapi.access.AccessRestApiModule;
+import com.google.gerrit.server.restapi.account.AccountRestApiModule;
+import com.google.gerrit.server.restapi.change.ChangeRestApiModule;
+import com.google.gerrit.server.restapi.config.ConfigRestApiModule;
 import com.google.gerrit.server.restapi.config.RestCacheAdminModule;
+import com.google.gerrit.server.restapi.group.GroupRestApiModule;
+import com.google.gerrit.server.restapi.project.ProjectRestApiModule;
 import com.google.inject.AbstractModule;
 
 public class RestApiModule extends AbstractModule {
   @Override
   protected void configure() {
-    install(new com.google.gerrit.server.restapi.access.Module());
-    install(new com.google.gerrit.server.restapi.account.Module());
-    install(new com.google.gerrit.server.restapi.change.Module());
-    install(new com.google.gerrit.server.restapi.config.Module());
+    install(new AccessRestApiModule());
+    install(new AccountRestApiModule());
+    install(new ChangeRestApiModule());
+    install(new ConfigRestApiModule());
     install(new RestCacheAdminModule());
-    install(new com.google.gerrit.server.restapi.group.Module());
+    install(new GroupRestApiModule());
     install(new PluginRestApiModule());
-    install(new com.google.gerrit.server.restapi.project.Module());
-    install(new com.google.gerrit.server.restapi.project.Module.BatchModule());
+    install(new ProjectRestApiModule());
+    install(new ProjectRestApiModule.BatchModule());
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/access/Module.java b/java/com/google/gerrit/server/restapi/access/AccessRestApiModule.java
similarity index 94%
rename from java/com/google/gerrit/server/restapi/access/Module.java
rename to java/com/google/gerrit/server/restapi/access/AccessRestApiModule.java
index 3a4955d..71bb696 100644
--- a/java/com/google/gerrit/server/restapi/access/Module.java
+++ b/java/com/google/gerrit/server/restapi/access/AccessRestApiModule.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.restapi.RestApiModule;
 
 /** Guice module that binds all REST endpoints for {@code /access/}. */
-public class Module extends RestApiModule {
+public class AccessRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
     bind(AccessCollection.class);
diff --git a/java/com/google/gerrit/server/restapi/access/ListAccess.java b/java/com/google/gerrit/server/restapi/access/ListAccess.java
index 1e1bade..c3b4ff2 100644
--- a/java/com/google/gerrit/server/restapi/access/ListAccess.java
+++ b/java/com/google/gerrit/server/restapi/access/ListAccess.java
@@ -14,11 +14,18 @@
 
 package com.google.gerrit.server.restapi.access;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.restapi.project.GetAccess;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -41,10 +48,15 @@
       usage = "projects for which the access rights should be returned")
   private List<String> projects = new ArrayList<>();
 
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
   private final GetAccess getAccess;
 
   @Inject
-  public ListAccess(GetAccess getAccess) {
+  public ListAccess(
+      PermissionBackend permissionBackend, ProjectCache projectCache, GetAccess getAccess) {
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
     this.getAccess = getAccess;
   }
 
@@ -53,7 +65,23 @@
       throws Exception {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      access.put(p, getAccess.apply(Project.nameKey(p)));
+      if (Strings.nullToEmpty(p).isEmpty()) {
+        continue;
+      }
+
+      Project.NameKey projectName = Project.nameKey(IdString.fromUrl(p).get().trim());
+
+      if (!projectCache.get(projectName).isPresent()) {
+        throw new ResourceNotFoundException(projectName.get());
+      }
+
+      try {
+        permissionBackend.currentUser().project(projectName).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        throw new ResourceNotFoundException(projectName.get(), e);
+      }
+
+      access.put(projectName.get(), getAccess.apply(projectName));
     }
     return Response.ok(access);
   }
diff --git a/java/com/google/gerrit/server/restapi/account/Module.java b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
similarity index 96%
rename from java/com/google/gerrit/server/restapi/account/Module.java
rename to java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
index 7570465..2a8f55f 100644
--- a/java/com/google/gerrit/server/restapi/account/Module.java
+++ b/java/com/google/gerrit/server/restapi/account/AccountRestApiModule.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.inject.Provides;
 
-public class Module extends RestApiModule {
+public class AccountRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
     bind(AccountsCollection.class);
@@ -99,10 +99,6 @@
     delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
     bind(StarredChanges.Create.class);
 
-    child(ACCOUNT_KIND, "stars.changes").to(Stars.class);
-    get(STAR_KIND).to(Stars.Get.class);
-    post(STAR_KIND).to(Stars.Post.class);
-
     get(ACCOUNT_KIND, "external.ids").to(GetExternalIds.class);
     post(ACCOUNT_KIND, "external.ids:delete").to(DeleteExternalIds.class);
 
diff --git a/java/com/google/gerrit/server/restapi/account/CreateAccount.java b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
index b65f4ee..b4946c4 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateAccount.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateAccount.java
@@ -44,10 +44,11 @@
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.GroupResolver;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -85,6 +86,7 @@
   private final Provider<GroupsUpdate> groupsUpdate;
   private final OutgoingEmailValidator validator;
   private final AuthConfig authConfig;
+  private final ExternalIdFactory externalIdFactory;
 
   @Inject
   CreateAccount(
@@ -97,7 +99,8 @@
       PluginSetContext<AccountExternalIdCreator> externalIdCreators,
       @UserInitiated Provider<GroupsUpdate> groupsUpdate,
       OutgoingEmailValidator validator,
-      AuthConfig authConfig) {
+      AuthConfig authConfig,
+      ExternalIdFactory externalIdFactory) {
     this.seq = seq;
     this.groupResolver = groupResolver;
     this.authorizedKeys = authorizedKeys;
@@ -108,6 +111,7 @@
     this.groupsUpdate = groupsUpdate;
     this.validator = validator;
     this.authConfig = authConfig;
+    this.externalIdFactory = externalIdFactory;
   }
 
   @Override
@@ -142,10 +146,10 @@
       if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
-      extIds.add(ExternalId.createEmail(accountId, input.email));
+      extIds.add(externalIdFactory.createEmail(accountId, input.email));
     }
 
-    extIds.add(ExternalId.createUsername(username, accountId, input.httpPassword));
+    extIds.add(externalIdFactory.createUsername(username, accountId, input.httpPassword));
     externalIdCreators.runEach(c -> extIds.addAll(c.create(accountId, username, input.email)));
 
     try {
@@ -209,10 +213,10 @@
 
   private void addGroupMember(AccountGroup.UUID groupUuid, Account.Id accountId)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, ImmutableSet.of(accountId)))
             .build();
-    groupsUpdate.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdate.get().updateGroup(groupUuid, groupDelta);
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/account/CreateEmail.java b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
index 6ee4539..70fbb26 100644
--- a/java/com/google/gerrit/server/restapi/account/CreateEmail.java
+++ b/java/com/google/gerrit/server/restapi/account/CreateEmail.java
@@ -83,6 +83,7 @@
   private final OutgoingEmailValidator validator;
   private final MessageIdGenerator messageIdGenerator;
   private final boolean isDevMode;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   CreateEmail(
@@ -94,7 +95,8 @@
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
       OutgoingEmailValidator validator,
-      MessageIdGenerator messageIdGenerator) {
+      MessageIdGenerator messageIdGenerator,
+      AuthRequest.Factory authRequestFactory) {
     this.self = self;
     this.realm = realm;
     this.permissionBackend = permissionBackend;
@@ -104,6 +106,7 @@
     this.validator = validator;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
     this.messageIdGenerator = messageIdGenerator;
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -151,7 +154,7 @@
         logger.atWarning().log("skipping email validation in developer mode");
       }
       try {
-        accountManager.link(user.getAccountId(), AuthRequest.forEmail(email));
+        accountManager.link(user.getAccountId(), authRequestFactory.createForEmail(email));
       } catch (AccountException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
index ec82e1a..1485a6e56 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteDraftComments.java
@@ -41,13 +41,12 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
-import com.google.gerrit.server.query.change.HasDraftByPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.restapi.change.CommentJson;
 import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.gerrit.server.update.BatchUpdate;
-import com.google.gerrit.server.update.BatchUpdate.Factory;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
@@ -80,7 +79,7 @@
   @Inject
   DeleteDraftComments(
       Provider<CurrentUser> userProvider,
-      Factory batchUpdateFactory,
+      BatchUpdate.Factory batchUpdateFactory,
       Provider<ChangeQueryBuilder> queryBuilderProvider,
       Provider<InternalChangeQuery> queryProvider,
       ChangeData.Factory changeDataFactory,
@@ -147,7 +146,7 @@
 
   private Predicate<ChangeData> predicate(Account.Id accountId, DeleteDraftCommentsInput input)
       throws BadRequestException {
-    Predicate<ChangeData> hasDraft = new HasDraftByPredicate(accountId);
+    Predicate<ChangeData> hasDraft = ChangePredicates.draftBy(accountId);
     if (CharMatcher.whitespace().trimFrom(Strings.nullToEmpty(input.query)).isEmpty()) {
       return hasDraft;
     }
diff --git a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
index 445a5d6..e099a70 100644
--- a/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
+++ b/java/com/google/gerrit/server/restapi/account/DeleteExternalIds.java
@@ -18,6 +18,8 @@
 import static java.util.stream.Collectors.toMap;
 import static java.util.stream.Collectors.toSet;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
@@ -29,6 +31,7 @@
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -56,17 +59,20 @@
   private final AccountManager accountManager;
   private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   DeleteExternalIds(
       PermissionBackend permissionBackend,
       AccountManager accountManager,
       ExternalIds externalIds,
-      Provider<CurrentUser> self) {
+      Provider<CurrentUser> self,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
     this.externalIds = externalIds;
     this.self = self;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   @Override
@@ -87,15 +93,24 @@
     List<ExternalId> toDelete = new ArrayList<>();
     Optional<ExternalId.Key> last = resource.getUser().getLastLoginExternalIdKey();
     for (String externalIdStr : extIds) {
-      ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
+      ExternalId id = externalIdMap.get(externalIdKeyFactory.parse(externalIdStr));
 
       if (id == null) {
         throw new UnprocessableEntityException(
             String.format("External id %s does not exist", externalIdStr));
       }
 
-      if ((!id.isScheme(SCHEME_USERNAME))
-          && (!last.isPresent() || (!last.get().equals(id.key())))) {
+      if (!last.isPresent() || !last.get().equals(id.key())) {
+        if (id.isScheme(SCHEME_USERNAME)) {
+          if (self.get().hasSameAccountId(resource.getUser())) {
+            throw new AuthException("User cannot delete its own externalId in 'username:' scheme");
+          }
+          permissionBackend
+              .currentUser()
+              .checkAny(
+                  ImmutableSet.of(
+                      GlobalPermission.ADMINISTRATE_SERVER, GlobalPermission.MAINTAIN_SERVER));
+        }
         toDelete.add(id);
       } else {
         throw new ResourceConflictException(
diff --git a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
index 2427def..9361e27 100644
--- a/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
+++ b/java/com/google/gerrit/server/restapi/account/PutHttpPassword.java
@@ -34,6 +34,8 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.mail.send.HttpPasswordUpdateSender;
 import com.google.gerrit.server.permissions.GlobalPermission;
@@ -77,6 +79,8 @@
   private final ExternalIds externalIds;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory;
+  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   PutHttpPassword(
@@ -84,12 +88,16 @@
       PermissionBackend permissionBackend,
       ExternalIds externalIds,
       @UserInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory) {
+      HttpPasswordUpdateSender.Factory httpPasswordUpdateSenderFactory,
+      ExternalIdFactory externalIdFactory,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.httpPasswordUpdateSenderFactory = httpPasswordUpdateSenderFactory;
+    this.externalIdFactory = externalIdFactory;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   @Override
@@ -125,7 +133,7 @@
     String userName =
         user.getUserName().orElseThrow(() -> new ResourceConflictException("username must be set"));
     Optional<ExternalId> optionalExtId =
-        externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, userName));
+        externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, userName));
     ExternalId extId = optionalExtId.orElseThrow(ResourceNotFoundException::new);
     accountsUpdateProvider
         .get()
@@ -134,7 +142,7 @@
             extId.accountId(),
             u ->
                 u.updateExternalId(
-                    ExternalId.createWithPassword(
+                    externalIdFactory.createWithPassword(
                         extId.key(), extId.accountId(), extId.email(), newPassword)));
 
     try {
diff --git a/java/com/google/gerrit/server/restapi/account/PutPreferred.java b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
index 32b5ff2..aee0b78 100644
--- a/java/com/google/gerrit/server/restapi/account/PutPreferred.java
+++ b/java/com/google/gerrit/server/restapi/account/PutPreferred.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -62,17 +63,20 @@
   private final PermissionBackend permissionBackend;
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final ExternalIds externalIds;
+  private final ExternalIdFactory externalIdFactory;
 
   @Inject
   PutPreferred(
       Provider<CurrentUser> self,
       PermissionBackend permissionBackend,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
-      ExternalIds externalIds) {
+      ExternalIds externalIds,
+      ExternalIdFactory externalIdFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.externalIds = externalIds;
+    this.externalIdFactory = externalIdFactory;
   }
 
   @Override
@@ -137,7 +141,8 @@
                     }
 
                     // claim the email now
-                    u.addExternalId(ExternalId.createEmail(a.account().id(), preferredEmail));
+                    u.addExternalId(
+                        externalIdFactory.createEmail(a.account().id(), preferredEmail));
                     matchingEmail = preferredEmail;
                   } else {
                     // Realm says that the email doesn't belong to the user. This can only happen as
diff --git a/java/com/google/gerrit/server/restapi/account/PutUsername.java b/java/com/google/gerrit/server/restapi/account/PutUsername.java
index 05bf1fd..f295389 100644
--- a/java/com/google/gerrit/server/restapi/account/PutUsername.java
+++ b/java/com/google/gerrit/server/restapi/account/PutUsername.java
@@ -34,6 +34,8 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -65,6 +67,8 @@
   private final Provider<AccountsUpdate> accountsUpdateProvider;
   private final SshKeyCache sshKeyCache;
   private final Realm realm;
+  private final ExternalIdFactory externalIdFactory;
+  private final ExternalIdKeyFactory externalIdKeyFactory;
 
   @Inject
   PutUsername(
@@ -73,13 +77,17 @@
       ExternalIds externalIds,
       @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
       SshKeyCache sshKeyCache,
-      Realm realm) {
+      Realm realm,
+      ExternalIdFactory externalIdFactory,
+      ExternalIdKeyFactory externalIdKeyFactory) {
     this.self = self;
     this.permissionBackend = permissionBackend;
     this.externalIds = externalIds;
     this.accountsUpdateProvider = accountsUpdateProvider;
     this.sshKeyCache = sshKeyCache;
     this.realm = realm;
+    this.externalIdFactory = externalIdFactory;
+    this.externalIdKeyFactory = externalIdKeyFactory;
   }
 
   @Override
@@ -107,14 +115,14 @@
       throw new UnprocessableEntityException("invalid username");
     }
 
-    ExternalId.Key key = ExternalId.Key.create(SCHEME_USERNAME, input.username);
+    ExternalId.Key key = externalIdKeyFactory.create(SCHEME_USERNAME, input.username);
     try {
       accountsUpdateProvider
           .get()
           .update(
               "Set Username via API",
               accountId,
-              u -> u.addExternalId(ExternalId.create(key, accountId, null, null)));
+              u -> u.addExternalId(externalIdFactory.create(key, accountId, null, null)));
     } catch (DuplicateKeyException dupeErr) {
       // If we are using this identity, don't report the exception.
       Optional<ExternalId> other = externalIds.get(key);
diff --git a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
index 0d12fd4..e6b4eee 100644
--- a/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
+++ b/java/com/google/gerrit/server/restapi/account/QueryAccounts.java
@@ -107,8 +107,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListAccountsOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListAccountsOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/account/StarredChanges.java b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
index e67fe9e..39c1fef 100644
--- a/java/com/google/gerrit/server/restapi/account/StarredChanges.java
+++ b/java/com/google/gerrit/server/restapi/account/StarredChanges.java
@@ -90,7 +90,7 @@
     return (RestReadView<AccountResource>)
         self -> {
           QueryChanges query = changes.list();
-          query.addQuery("starredby:" + self.getUser().getAccountId().get());
+          query.addQuery("has:star");
           return query.apply(TopLevelResource.INSTANCE);
         };
   }
diff --git a/java/com/google/gerrit/server/restapi/account/Stars.java b/java/com/google/gerrit/server/restapi/account/Stars.java
deleted file mode 100644
index cc362f2..0000000
--- a/java/com/google/gerrit/server/restapi/account/Stars.java
+++ /dev/null
@@ -1,183 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.account;
-
-import com.google.gerrit.extensions.api.changes.StarsInput;
-import com.google.gerrit.extensions.common.ChangeInfo;
-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.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.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.account.AccountResource;
-import com.google.gerrit.server.account.AccountResource.Star;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.permissions.PermissionBackendException;
-import com.google.gerrit.server.restapi.change.ChangesCollection;
-import com.google.gerrit.server.restapi.change.QueryChanges;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
-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> {
-
-  private final ChangesCollection changes;
-  private final ListStarredChanges listStarredChanges;
-  private final StarredChangesUtil starredChangesUtil;
-  private final DynamicMap<RestView<AccountResource.Star>> views;
-
-  @Inject
-  Stars(
-      ChangesCollection changes,
-      ListStarredChanges listStarredChanges,
-      StarredChangesUtil starredChangesUtil,
-      DynamicMap<RestView<AccountResource.Star>> views) {
-    this.changes = changes;
-    this.listStarredChanges = listStarredChanges;
-    this.starredChangesUtil = starredChangesUtil;
-    this.views = views;
-  }
-
-  @Override
-  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);
-  }
-
-  @Override
-  public DynamicMap<RestView<Star>> views() {
-    return views;
-  }
-
-  @Override
-  public ListStarredChanges list() {
-    return listStarredChanges;
-  }
-
-  @Singleton
-  public static class ListStarredChanges implements RestReadView<AccountResource> {
-
-    private final Provider<CurrentUser> self;
-    private final ChangesCollection changes;
-
-    @Inject
-    ListStarredChanges(Provider<CurrentUser> self, ChangesCollection changes) {
-      this.self = self;
-      this.changes = changes;
-    }
-
-    @Override
-    @SuppressWarnings("unchecked")
-    public Response<List<ChangeInfo>> apply(AccountResource rsrc)
-        throws RestApiException, PermissionBackendException {
-      if (!self.get().hasSameAccountId(rsrc.getUser())) {
-        throw new AuthException("not allowed to list stars of another account");
-      }
-
-      // The type of the value in the response that is returned by QueryChanges depends on the
-      // number of queries that is provided as input. If a single query is provided as input the
-      // value type is {@code List<ChangeInfo>}, if multiple queries are provided as input the value
-      // type is {@code List<List<ChangeInfo>>) (one {@code List<ChangeInfo>} as result to each
-      // query). Since in this case we provide exactly one query ("has:stars") as input we know that
-      // the value always has the type {@code List<ChangeInfo>} and hence we can safely cast the
-      // value to this type.
-      QueryChanges query = changes.list();
-      query.addQuery("has:stars");
-      Response<?> response = query.apply(TopLevelResource.INSTANCE);
-      List<ChangeInfo> value = (List<ChangeInfo>) response.value();
-      return Response.ok(value);
-    }
-  }
-
-  @Singleton
-  public static class Get implements RestReadView<AccountResource.Star> {
-
-    private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
-
-    @Inject
-    Get(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
-      this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
-    }
-
-    @Override
-    public Response<SortedSet<String>> apply(AccountResource.Star rsrc) throws AuthException {
-      if (!self.get().hasSameAccountId(rsrc.getUser())) {
-        throw new AuthException("not allowed to get stars of another account");
-      }
-      return Response.ok(
-          starredChangesUtil.getLabels(self.get().getAccountId(), rsrc.getChange().getId()));
-    }
-  }
-
-  @Singleton
-  public static class Post implements RestModifyView<AccountResource.Star, StarsInput> {
-
-    private final Provider<CurrentUser> self;
-    private final StarredChangesUtil starredChangesUtil;
-
-    @Inject
-    Post(Provider<CurrentUser> self, StarredChangesUtil starredChangesUtil) {
-      this.self = self;
-      this.starredChangesUtil = starredChangesUtil;
-    }
-
-    @Override
-    public Response<Collection<String>> apply(AccountResource.Star rsrc, StarsInput in)
-        throws AuthException, BadRequestException {
-      if (!self.get().hasSameAccountId(rsrc.getUser())) {
-        throw new AuthException("not allowed to update stars of another account");
-      }
-      try {
-        return Response.ok(
-            starredChangesUtil.star(
-                self.get().getAccountId(),
-                rsrc.getChange().getProject(),
-                rsrc.getChange().getId(),
-                in.add,
-                in.remove));
-      } catch (IllegalLabelException e) {
-        throw new BadRequestException(e.getMessage());
-      }
-    }
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Abandon.java b/java/com/google/gerrit/server/restapi/change/Abandon.java
index b207390..2cfc3f5 100644
--- a/java/com/google/gerrit/server/restapi/change/Abandon.java
+++ b/java/com/google/gerrit/server/restapi/change/Abandon.java
@@ -14,9 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -45,8 +43,6 @@
 @Singleton
 public class Abandon
     implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
   private final AbandonOp.Factory abandonOpFactory;
@@ -129,7 +125,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Abandon")
@@ -140,17 +136,9 @@
     if (!change.isNew()) {
       return description;
     }
-
-    try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     return description.setVisible(rsrc.permissions().testOrFalse(ChangePermission.ABANDON));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/Module.java b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
similarity index 98%
rename from java/com/google/gerrit/server/restapi/change/Module.java
rename to java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
index f87c9a1..02b4c13 100644
--- a/java/com/google/gerrit/server/restapi/change/Module.java
+++ b/java/com/google/gerrit/server/restapi/change/ChangeRestApiModule.java
@@ -53,7 +53,7 @@
 import com.google.gerrit.server.restapi.change.Reviewed.PutReviewed;
 import com.google.gerrit.server.util.AttentionSetEmail;
 
-public class Module extends RestApiModule {
+public class ChangeRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
     bind(ChangesCollection.class);
@@ -120,11 +120,10 @@
     delete(CHANGE_KIND, "private").to(DeletePrivate.class);
     put(CHANGE_KIND, "ignore").to(Ignore.class);
     put(CHANGE_KIND, "unignore").to(Unignore.class);
-    put(CHANGE_KIND, "reviewed").to(MarkAsReviewed.class);
-    put(CHANGE_KIND, "unreviewed").to(MarkAsUnreviewed.class);
     post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
     post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
     put(CHANGE_KIND, "message").to(PutMessage.class);
+    post(CHANGE_KIND, "check.submit_requirement").to(CheckSubmitRequirement.class);
 
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
     child(CHANGE_KIND, "reviewers").to(Reviewers.class);
diff --git a/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
new file mode 100644
index 0000000..55b234c
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/change/CheckSubmitRequirement.java
@@ -0,0 +1,79 @@
+// 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.restapi.change;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.SubmitRequirementsJson;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.inject.Inject;
+import java.util.Optional;
+
+/**
+ * A rest view to evaluate (test) a {@link com.google.gerrit.entities.SubmitRequirement} on a given
+ * change.
+ *
+ * <p>TODO(ghareeb): Can this class be made singleton?
+ */
+public class CheckSubmitRequirement
+    implements RestModifyView<ChangeResource, SubmitRequirementInput> {
+  private final SubmitRequirementsEvaluator evaluator;
+
+  @Inject
+  public CheckSubmitRequirement(SubmitRequirementsEvaluator evaluator) {
+    this.evaluator = evaluator;
+  }
+
+  @Override
+  public Response<SubmitRequirementResultInfo> apply(
+      ChangeResource resource, SubmitRequirementInput input) throws BadRequestException {
+    SubmitRequirement requirement = createSubmitRequirement(input);
+    SubmitRequirementResult res =
+        evaluator.evaluateRequirement(requirement, resource.getChangeData());
+    return Response.ok(SubmitRequirementsJson.toInfo(requirement, res));
+  }
+
+  private SubmitRequirement createSubmitRequirement(SubmitRequirementInput input)
+      throws BadRequestException {
+    validateSubmitRequirementInput(input);
+    return SubmitRequirement.builder()
+        .setName(input.name)
+        .setDescription(Optional.ofNullable(input.description))
+        .setApplicabilityExpression(SubmitRequirementExpression.of(input.applicabilityExpression))
+        .setSubmittabilityExpression(
+            SubmitRequirementExpression.create(input.submittabilityExpression))
+        .setOverrideExpression(SubmitRequirementExpression.of(input.overrideExpression))
+        .setAllowOverrideInChildProjects(
+            input.allowOverrideInChildProjects == null ? true : input.allowOverrideInChildProjects)
+        .build();
+  }
+
+  private void validateSubmitRequirementInput(SubmitRequirementInput input)
+      throws BadRequestException {
+    if (input.name == null) {
+      throw new BadRequestException("Field 'name' is missing from input.");
+    }
+    if (input.submittabilityExpression == null) {
+      throw new BadRequestException("Field 'submittability_expression' is missing from input.");
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
index ee6484c..5375936 100644
--- a/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CherryPickChange.java
@@ -33,11 +33,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.PatchSetInserter;
@@ -328,6 +328,7 @@
                 input.parent - 1,
                 input.allowEmpty,
                 input.allowConflicts);
+        oi.flush();
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationConflictException("Cherry pick failed: " + e.getMessage(), e);
       }
@@ -448,6 +449,9 @@
     if (workInProgress != null) {
       inserter.setWorkInProgress(workInProgress);
     }
+    if (shouldSetToReady(cherryPickCommit, destNotes, workInProgress)) {
+      inserter.setWorkInProgress(false);
+    }
     bu.addOp(destChange.getId(), inserter);
     PatchSet.Id sourcePatchSetId = sourceChange == null ? null : sourceChange.currentPatchSetId();
     // If sourceChange is not provided, reset cherryPickOf to avoid stale value.
@@ -461,6 +465,20 @@
     return destChange.getId();
   }
 
+  /**
+   * We should set the change to be "ready for review" if: 1. workInProgress is not already set on
+   * this request. 2. The patch-set doesn't have any git conflict markers. 3. The change used to be
+   * work in progress (because of a previous patch-set).
+   */
+  private boolean shouldSetToReady(
+      CodeReviewCommit cherryPickCommit,
+      ChangeNotes destChangeNotes,
+      @Nullable Boolean workInProgress) {
+    return workInProgress == null
+        && cherryPickCommit.getFilesWithGitConflicts().isEmpty()
+        && destChangeNotes.getChange().isWorkInProgress();
+  }
+
   private Change.Id createNewChange(
       BatchUpdate bu,
       CodeReviewCommit cherryPickCommit,
diff --git a/java/com/google/gerrit/server/restapi/change/CommentJson.java b/java/com/google/gerrit/server/restapi/change/CommentJson.java
index edc8fcf..81b6fb3 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentJson.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentJson.java
@@ -190,7 +190,7 @@
       return CommentContextKey.builder()
           .project(project)
           .changeId(changeId)
-          .id(r.id)
+          .id(Url.decode(r.id)) // We reverse the encoding done while filling comment info
           .path(r.path)
           .patchset(r.patchSet)
           .contextPadding(contextPadding)
diff --git a/java/com/google/gerrit/server/restapi/change/CommentPorter.java b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
index 34af285..1a4eb18 100644
--- a/java/com/google/gerrit/server/restapi/change/CommentPorter.java
+++ b/java/com/google/gerrit/server/restapi/change/CommentPorter.java
@@ -29,28 +29,30 @@
 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.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.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.DiffMappings;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.GitPositionTransformer;
 import com.google.gerrit.server.patch.GitPositionTransformer.BestPositionOnConflict;
 import com.google.gerrit.server.patch.GitPositionTransformer.FileMapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Mapping;
 import com.google.gerrit.server.patch.GitPositionTransformer.Position;
 import com.google.gerrit.server.patch.GitPositionTransformer.PositionedEntity;
-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.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.FileEdits;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.function.Function;
 import java.util.stream.Stream;
@@ -99,15 +101,15 @@
     }
   }
 
+  private final DiffOperations diffOperations;
   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, Metrics metrics) {
-    this.patchListCache = patchListCache;
+  public CommentPorter(DiffOperations diffOperations, CommentsUtil commentsUtil, Metrics metrics) {
+    this.diffOperations = diffOperations;
     this.commentsUtil = commentsUtil;
     this.metrics = metrics;
   }
@@ -142,10 +144,13 @@
       PatchSet targetPatchset,
       List<HumanComment> comments,
       List<HumanCommentFilter> filters) {
-
-    ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
-    ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
-    return port(changeNotes, targetPatchset, relevantComments);
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments", Metadata.builder().patchSetId(targetPatchset.number()).build())) {
+      ImmutableList<HumanCommentFilter> allFilters = addDefaultFilters(filters, targetPatchset);
+      ImmutableList<HumanComment> relevantComments = filter(comments, allFilters);
+      return port(changeNotes, targetPatchset, relevantComments);
+    }
   }
 
   private ImmutableList<HumanCommentFilter> addDefaultFilters(
@@ -203,20 +208,29 @@
       PatchSet originalPatchset,
       PatchSet targetPatchset,
       ImmutableList<HumanComment> comments) {
-    Map<Short, List<HumanComment>> commentsPerSide =
-        comments.stream().collect(groupingBy(comment -> comment.side));
-    ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
-    for (Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
-      portedComments.addAll(
-          portSamePatchsetAndSide(
-              project,
-              change,
-              originalPatchset,
-              targetPatchset,
-              sideAndComments.getValue(),
-              sideAndComments.getKey()));
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments same patchset",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .build())) {
+      Map<Short, List<HumanComment>> commentsPerSide =
+          comments.stream().collect(groupingBy(comment -> comment.side));
+      ImmutableList.Builder<HumanComment> portedComments = ImmutableList.builder();
+      for (Map.Entry<Short, List<HumanComment>> sideAndComments : commentsPerSide.entrySet()) {
+        portedComments.addAll(
+            portSamePatchsetAndSide(
+                project,
+                change,
+                originalPatchset,
+                targetPatchset,
+                sideAndComments.getValue(),
+                sideAndComments.getKey()));
+      }
+      return portedComments.build();
     }
-    return portedComments.build();
   }
 
   private ImmutableList<HumanComment> portSamePatchsetAndSide(
@@ -226,30 +240,40 @@
       PatchSet targetPatchset,
       List<HumanComment> comments,
       short side) {
-    ImmutableSet<Mapping> mappings;
-    try {
-      mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
-    } catch (Exception e) {
-      logger.atWarning().withCause(e).log(
-          "Could not determine some necessary diff mappings for porting comments on change %s from"
-              + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
-              + " destination.",
-          change.getChangeId(),
-          originalPatchset.id().getId(),
-          targetPatchset.id().getId(),
-          comments.size());
-      mappings = getFallbackMappings(comments);
-    }
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Porting comments same patchset and side",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .commentSide(side)
+                .build())) {
+      ImmutableSet<Mapping> mappings;
+      try {
+        mappings = loadMappings(project, change, originalPatchset, targetPatchset, side);
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "Could not determine some necessary diff mappings for porting comments on change %s from"
+                + " patchset %s to patchset %s. Mapping %d affected comments to the fallback"
+                + " destination.",
+            change.getChangeId(),
+            originalPatchset.id().getId(),
+            targetPatchset.id().getId(),
+            comments.size());
+        mappings = getFallbackMappings(comments);
+      }
 
-    ImmutableList<PositionedEntity<HumanComment>> positionedComments =
-        comments.stream().map(this::toPositionedEntity).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());
+      ImmutableList<PositionedEntity<HumanComment>> positionedComments =
+          comments.stream().map(this::toPositionedEntity).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(
@@ -258,10 +282,19 @@
       PatchSet originalPatchset,
       PatchSet targetPatchset,
       short side)
-      throws PatchListNotAvailableException {
-    ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
-    ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
-    return loadCommitMappings(project, originalCommit, targetCommit);
+      throws DiffNotAvailableException {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Loading commit mappings",
+            Metadata.builder()
+                .projectName(project.get())
+                .changeId(change.getChangeId())
+                .patchSetId(originalPatchset.number())
+                .build())) {
+      ObjectId originalCommit = determineCommitId(change, originalPatchset, side);
+      ObjectId targetCommit = determineCommitId(change, targetPatchset, side);
+      return loadCommitMappings(project, originalCommit, targetCommit);
+    }
   }
 
   private ObjectId determineCommitId(Change change, PatchSet patchset, short side) {
@@ -277,12 +310,24 @@
 
   private ImmutableSet<Mapping> loadCommitMappings(
       Project.NameKey project, ObjectId originalCommit, ObjectId targetCommit)
-      throws PatchListNotAvailableException {
-    PatchList patchList =
-        patchListCache.get(
-            PatchListKey.againstCommit(originalCommit, targetCommit, Whitespace.IGNORE_NONE),
-            project);
-    return patchList.getPatches().stream().map(DiffMappings::toMapping).collect(toImmutableSet());
+      throws DiffNotAvailableException {
+    try (TraceTimer ignored =
+        TraceContext.newTimer(
+            "Computing diffs", Metadata.builder().commit(originalCommit.name()).build())) {
+      Map<String, FileDiffOutput> modifiedFiles =
+          diffOperations.listModifiedFiles(project, originalCommit, targetCommit);
+      return modifiedFiles.values().stream()
+          .map(CommentPorter::getFileEdits)
+          .map(DiffMappings::toMapping)
+          .collect(toImmutableSet());
+    }
+  }
+
+  private static FileEdits getFileEdits(FileDiffOutput fileDiffOutput) {
+    return FileEdits.create(
+        fileDiffOutput.edits().stream().map(TaggedEdit::edit).collect(toImmutableList()),
+        fileDiffOutput.oldPath(),
+        fileDiffOutput.newPath());
   }
 
   private ImmutableSet<Mapping> getFallbackMappings(List<HumanComment> comments) {
diff --git a/java/com/google/gerrit/server/restapi/change/CreateChange.java b/java/com/google/gerrit/server/restapi/change/CreateChange.java
index c392bd1..fa47bef 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateChange.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateChange.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -377,6 +378,8 @@
         // create an empty commit
         c = newCommit(oi, rw, author, committer, mergeTip, commitMessage);
       }
+      // Flush inserter so that commit becomes visible to validators
+      oi.flush();
 
       Change.Id changeId = Change.id(seq.nextChangeId());
       ChangeInserter ins = changeInserterFactory.create(changeId, c, input.branch);
@@ -385,6 +388,17 @@
       ins.setPrivate(input.isPrivate);
       ins.setWorkInProgress(input.workInProgress || !c.getFilesWithGitConflicts().isEmpty());
       ins.setGroups(groups);
+
+      if (input.validationOptions != null) {
+        ImmutableListMultimap.Builder<String, String> validationOptions =
+            ImmutableListMultimap.builder();
+        input
+            .validationOptions
+            .entrySet()
+            .forEach(e -> validationOptions.put(e.getKey(), e.getValue()));
+        ins.setValidationOptions(validationOptions.build());
+      }
+
       try (BatchUpdate bu = updateFactory.create(projectState.getNameKey(), me, now)) {
         bu.setRepository(git, rw, oi);
         bu.setNotify(
diff --git a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
index af4bf69..e943e47 100644
--- a/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
+++ b/java/com/google/gerrit/server/restapi/change/CreateMergePatchSet.java
@@ -194,6 +194,7 @@
               sourceCommit,
               author,
               ObjectId.fromString(change.getKey().get().substring(1)));
+      oi.flush();
 
       PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.id());
       PatchSetInserter psInserter =
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
index 842ed2a..d867e00 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteAssignee.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.Input;
 import com.google.gerrit.extensions.restapi.Response;
@@ -34,8 +33,9 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -94,7 +94,7 @@
       IdentifiedUser deletedAssigneeUser = userFactory.create(currentAssigneeId);
       deletedAssignee = deletedAssigneeUser.state();
       update.removeAssignee();
-      addMessage(ctx, update, deletedAssigneeUser);
+      addMessage(ctx, deletedAssigneeUser);
       return true;
     }
 
@@ -102,19 +102,18 @@
       return deletedAssignee != null ? deletedAssignee.account().id() : null;
     }
 
-    private void addMessage(
-        ChangeContext ctx, ChangeUpdate update, IdentifiedUser deletedAssignee) {
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Assignee deleted: " + deletedAssignee.getNameEmail(),
-              ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
-      cmUtil.addChangeMessage(update, cmsg);
+    private void addMessage(ChangeContext ctx, IdentifiedUser deletedAssignee) {
+      cmUtil.setChangeMessage(
+          ctx,
+          "Assignee deleted: "
+              + AccountTemplateUtil.getAccountTemplate(deletedAssignee.getAccountId()),
+          ChangeMessagesUtil.TAG_DELETE_ASSIGNEE);
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      assigneeChanged.fire(change, ctx.getAccount(), deletedAssignee, ctx.getWhen());
+    public void postUpdate(PostUpdateContext ctx) {
+      assigneeChanged.fire(
+          ctx.getChangeData(change), ctx.getAccount(), deletedAssignee, ctx.getWhen());
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
index 5b44957..0e868e70 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteChangeMessage.java
@@ -14,12 +14,11 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
 import static java.util.Objects.requireNonNull;
 
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
@@ -42,6 +41,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -85,7 +85,7 @@
     permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
 
     String newChangeMessage =
-        createNewChangeMessage(user.asIdentifiedUser().getName(), input.reason);
+        createNewChangeMessage(user.asIdentifiedUser().getAccountId(), input.reason);
     DeleteChangeMessageOp deleteChangeMessageOp =
         new DeleteChangeMessageOp(resource.getChangeMessageId(), newChangeMessage);
     try (BatchUpdate batchUpdate =
@@ -107,26 +107,29 @@
         changeMessagesUtil.byChange(notesFactory.createChecked(project, cId));
     ChangeMessage updatedChangeMessage = messages.get(targetIdx);
     AccountLoader accountLoader = accountLoaderFactory.create(true);
-    ChangeMessageInfo info = createChangeMessageInfo(updatedChangeMessage, accountLoader);
+    ChangeMessageInfo info =
+        changeMessagesUtil.createChangeMessageInfoWithReplacedTemplates(
+            updatedChangeMessage, accountLoader);
     accountLoader.fill();
     return info;
   }
 
-  @VisibleForTesting
-  public static String createNewChangeMessage(String deletedBy, @Nullable String deletedReason) {
-    requireNonNull(deletedBy, "user name must not be null");
+  public static String createNewChangeMessage(
+      Account.Id deletedBy, @Nullable String deletedReason) {
+    requireNonNull(deletedBy, "user must not be null");
 
     if (Strings.isNullOrEmpty(deletedReason)) {
       return createNewChangeMessage(deletedBy);
     }
-    return String.format("Change message removed by: %s\nReason: %s", deletedBy, deletedReason);
+    return String.format(
+        "Change message removed by: %s\nReason: %s",
+        AccountTemplateUtil.getAccountTemplate(deletedBy), deletedReason);
   }
 
-  @VisibleForTesting
-  public static String createNewChangeMessage(String deletedBy) {
-    requireNonNull(deletedBy, "user name must not be null");
+  public static String createNewChangeMessage(Account.Id deletedBy) {
+    requireNonNull(deletedBy, "user must not be null");
 
-    return "Change message removed by: " + deletedBy;
+    return "Change message removed by: " + AccountTemplateUtil.getAccountTemplate(deletedBy);
   }
 
   private class DeleteChangeMessageOp implements BatchUpdateOp {
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
index 3e4a483..db8e9de 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteReviewer.java
@@ -64,7 +64,7 @@
       if (rsrc.isByEmail()) {
         op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail());
       } else {
-        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().state(), input);
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
       }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
diff --git a/java/com/google/gerrit/server/restapi/change/DeleteVote.java b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
index 4b813df..7ee38d4 100644
--- a/java/com/google/gerrit/server/restapi/change/DeleteVote.java
+++ b/java/com/google/gerrit/server/restapi/change/DeleteVote.java
@@ -21,7 +21,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelTypes;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -34,11 +33,13 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
+import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.NotifyResolver;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
@@ -53,11 +54,13 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.HashMap;
@@ -72,13 +75,14 @@
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
-  private final IdentifiedUser.GenericFactory userFactory;
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyResolver notifyResolver;
   private final RemoveReviewerControl removeReviewerControl;
   private final ProjectCache projectCache;
   private final MessageIdGenerator messageIdGenerator;
+  private final AddToAttentionSetOp.Factory attentionSetOpfactory;
+  private final Provider<CurrentUser> currentUserProvider;
 
   @Inject
   DeleteVote(
@@ -86,24 +90,26 @@
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
-      IdentifiedUser.GenericFactory userFactory,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
       NotifyResolver notifyResolver,
       RemoveReviewerControl removeReviewerControl,
       ProjectCache projectCache,
-      MessageIdGenerator messageIdGenerator) {
+      MessageIdGenerator messageIdGenerator,
+      AddToAttentionSetOp.Factory attentionSetOpFactory,
+      Provider<CurrentUser> currentUserProvider) {
     this.updateFactory = updateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
-    this.userFactory = userFactory;
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyResolver = notifyResolver;
     this.removeReviewerControl = removeReviewerControl;
     this.projectCache = projectCache;
     this.messageIdGenerator = messageIdGenerator;
+    this.attentionSetOpfactory = attentionSetOpFactory;
+    this.currentUserProvider = currentUserProvider;
   }
 
   @Override
@@ -140,6 +146,14 @@
               r.getReviewerUser().state(),
               rsrc.getLabel(),
               input));
+      if (!r.getReviewerUser().getAccountId().equals(currentUserProvider.get().getAccountId())) {
+        bu.addOp(
+            change.getId(),
+            attentionSetOpfactory.create(
+                r.getReviewerUser().getAccountId(),
+                /* reason= */ "Their vote was deleted",
+                /* notify= */ false));
+      }
       bu.execute();
     }
 
@@ -152,7 +166,7 @@
     private final String label;
     private final DeleteVoteInput input;
 
-    private ChangeMessage changeMessage;
+    private String mailMessage;
     private Change change;
     private PatchSet ps;
     private Map<String, Short> newApprovals = new HashMap<>();
@@ -181,7 +195,7 @@
       for (PatchSetApproval a :
           approvalsUtil.byPatchSetUser(
               ctx.getNotes(), psId, accountId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
-        if (labelTypes.byLabel(a.labelId()) == null) {
+        if (!labelTypes.byLabel(a.labelId()).isPresent()) {
           continue; // Ignore undefined labels.
         } else if (!a.label().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
@@ -211,17 +225,15 @@
       StringBuilder msg = new StringBuilder();
       msg.append("Removed ");
       LabelVote.appendTo(msg, label, requireNonNull(oldApprovals.get(label)));
-      msg.append(" by ").append(userFactory.create(accountId).getNameEmail()).append("\n");
-      changeMessage =
-          ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
-      cmUtil.addChangeMessage(ctx.getUpdate(psId), changeMessage);
-
+      msg.append(" by ").append(AccountTemplateUtil.getAccountTemplate(accountId)).append("\n");
+      mailMessage =
+          cmUtil.setChangeMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_VOTE);
       return true;
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      if (changeMessage == null) {
+    public void postUpdate(PostUpdateContext ctx) {
+      if (mailMessage == null) {
         return;
       }
 
@@ -232,7 +244,7 @@
           ReplyToChangeSender emailSender =
               deleteVoteSenderFactory.create(ctx.getProject(), change.getId());
           emailSender.setFrom(user.getAccountId());
-          emailSender.setChangeMessage(changeMessage.getMessage(), ctx.getWhen());
+          emailSender.setChangeMessage(mailMessage, ctx.getWhen());
           emailSender.setNotify(notify);
           emailSender.setMessageId(
               messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
@@ -243,13 +255,13 @@
       }
 
       voteDeleted.fire(
-          change,
+          ctx.getChangeData(change),
           ps,
           accountState,
           newApprovals,
           oldApprovals,
           input.notify,
-          changeMessage.getMessage(),
+          mailMessage,
           user.state(),
           ctx.getWhen());
     }
diff --git a/java/com/google/gerrit/server/restapi/change/Files.java b/java/com/google/gerrit/server/restapi/change/Files.java
index f82284e..320e57d 100644
--- a/java/com/google/gerrit/server/restapi/change/Files.java
+++ b/java/com/google/gerrit/server/restapi/change/Files.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.restapi.change;
 
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
@@ -43,11 +44,11 @@
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginItemContext;
 import com.google.inject.Inject;
@@ -63,6 +64,7 @@
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
@@ -112,30 +114,30 @@
     @Option(name = "-q")
     String query;
 
+    private final DiffOperations diffOperations;
     private final Provider<CurrentUser> self;
     private final FileInfoJson fileInfoJson;
     private final Revisions revisions;
     private final GitRepositoryManager gitManager;
-    private final PatchListCache patchListCache;
     private final PatchSetUtil psUtil;
     private final PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore;
     private final GerritApi gApi;
 
     @Inject
     ListFiles(
+        DiffOperations diffOperations,
         Provider<CurrentUser> self,
         FileInfoJson fileInfoJson,
         Revisions revisions,
         GitRepositoryManager gitManager,
-        PatchListCache patchListCache,
         PatchSetUtil psUtil,
         PluginItemContext<AccountPatchReviewStore> accountPatchReviewStore,
         GerritApi gApi) {
+      this.diffOperations = diffOperations;
       this.self = self;
       this.fileInfoJson = fileInfoJson;
       this.revisions = revisions;
       this.gitManager = gitManager;
-      this.patchListCache = patchListCache;
       this.psUtil = psUtil;
       this.accountPatchReviewStore = accountPatchReviewStore;
       this.gApi = gApi;
@@ -181,7 +183,7 @@
         r =
             Response.ok(
                 fileInfoJson.getFileInfoMap(
-                    resource.getChange(), resource.getPatchSet().commitId(), parentNum - 1));
+                    resource.getChange(), resource.getPatchSet().commitId(), parentNum));
       } else {
         r = Response.ok(fileInfoJson.getFileInfoMap(resource.getChange(), resource.getPatchSet()));
       }
@@ -252,9 +254,7 @@
 
         try {
           return copy(res.files(), res.patchSetId(), resource, userId);
-        } catch (PatchListObjectTooLargeException e) {
-          logger.atWarning().log("Cannot copy patch review flags: %s", e.getMessage());
-        } catch (IOException | PatchListNotAvailableException e) {
+        } catch (IOException | DiffNotAvailableException e) {
           logger.atWarning().withCause(e).log("Cannot copy patch review flags");
         }
       }
@@ -264,7 +264,7 @@
 
     private List<String> copy(
         Set<String> paths, PatchSet.Id old, RevisionResource resource, Account.Id userId)
-        throws IOException, PatchListNotAvailableException {
+        throws IOException, DiffNotAvailableException {
       Project.NameKey project = resource.getChange().getProject();
       try (Repository git = gitManager.openRepository(project);
           ObjectReader reader = git.newObjectReader();
@@ -273,31 +273,35 @@
         Change change = resource.getChange();
         PatchSet patchSet = psUtil.get(resource.getNotes(), old);
         if (patchSet == null) {
-          throw new PatchListNotAvailableException(
+          throw new DiffNotAvailableException(
               String.format(
                   "patch set %s of change %s not found", old.get(), change.getId().get()));
         }
 
-        PatchList oldList = patchListCache.get(change, patchSet);
+        Map<String, FileDiffOutput> oldList =
+            diffOperations.listModifiedFilesAgainstParent(
+                project, patchSet.commitId(), /* parentNum= */ 0);
 
-        PatchList curList = patchListCache.get(change, resource.getPatchSet());
+        Map<String, FileDiffOutput> curList =
+            diffOperations.listModifiedFilesAgainstParent(
+                project, resource.getPatchSet().commitId(), /* parentNum= */ 0);
 
         int sz = paths.size();
         List<String> pathList = Lists.newArrayListWithCapacity(sz);
 
         tw.setFilter(PathFilterGroup.createFromStrings(paths));
         tw.setRecursive(true);
-        int o = tw.addTree(rw.parseCommit(oldList.getNewId()).getTree());
-        int c = tw.addTree(rw.parseCommit(curList.getNewId()).getTree());
+        int o = tw.addTree(rw.parseCommit(getNewId(oldList)).getTree());
+        int c = tw.addTree(rw.parseCommit(getNewId(curList)).getTree());
 
         int op = -1;
-        if (oldList.getOldId() != null) {
-          op = tw.addTree(rw.parseTree(oldList.getOldId()));
+        if (getOldId(oldList) != null) {
+          op = tw.addTree(rw.parseTree(getOldId(oldList)));
         }
 
         int cp = -1;
-        if (curList.getOldId() != null) {
-          cp = tw.addTree(rw.parseTree(curList.getOldId()));
+        if (getOldId(curList) != null) {
+          cp = tw.addTree(rw.parseTree(getOldId(curList)));
         }
 
         while (tw.next()) {
@@ -354,5 +358,18 @@
       h.putLong(PatchListKey.serialVersionUID);
       return h.hash().toString();
     }
+
+    @Nullable
+    private ObjectId getOldId(Map<String, FileDiffOutput> fileDiffList) {
+      return fileDiffList.isEmpty()
+          ? null
+          : Iterables.getFirst(fileDiffList.values(), null).oldCommitId();
+    }
+
+    private ObjectId getNewId(Map<String, FileDiffOutput> fileDiffList) {
+      return fileDiffList.isEmpty()
+          ? null
+          : Iterables.getFirst(fileDiffList.values(), null).newCommitId();
+    }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
index 6822d91..9a4eefd 100644
--- a/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
+++ b/java/com/google/gerrit/server/restapi/change/GetAttentionSet.java
@@ -24,9 +24,9 @@
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.util.AttentionSetUtil;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.sql.Timestamp;
 import java.util.Set;
 
 /** Reads the list of users currently in the attention set. */
@@ -47,10 +47,7 @@
     ImmutableSet<AttentionSetInfo> response =
         // This filtering should match ChangeJson.
         additionsOnly(changeResource.getNotes().getAttentionSet()).stream()
-            .map(
-                a ->
-                    new AttentionSetInfo(
-                        accountLoader.get(a.account()), Timestamp.from(a.timestamp()), a.reason()))
+            .map(a -> AttentionSetUtil.createAttentionSetInfo(a, accountLoader))
             .collect(toImmutableSet());
     accountLoader.fill();
     return Response.ok(response);
diff --git a/java/com/google/gerrit/server/restapi/change/GetChange.java b/java/com/google/gerrit/server/restapi/change/GetChange.java
index 340b9a0..a81171a 100644
--- a/java/com/google/gerrit/server/restapi/change/GetChange.java
+++ b/java/com/google/gerrit/server/restapi/change/GetChange.java
@@ -76,8 +76,9 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    EnumSet<ListChangesOption> optionSet = ListOption.fromHexString(ListChangesOption.class, hex);
+    options.addAll(optionSet);
   }
 
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/change/GetDetail.java b/java/com/google/gerrit/server/restapi/change/GetDetail.java
index 4139560..b365e57 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDetail.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDetail.java
@@ -16,6 +16,7 @@
 
 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.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -34,7 +35,7 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
+  void setOptionFlagsHex(String hex) throws BadRequestException {
     delegate.setOptionFlagsHex(hex);
   }
 
diff --git a/java/com/google/gerrit/server/restapi/change/GetDiff.java b/java/com/google/gerrit/server/restapi/change/GetDiff.java
index 19256bb..9424198 100644
--- a/java/com/google/gerrit/server/restapi/change/GetDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetDiff.java
@@ -74,6 +74,7 @@
   @Option(name = "--base", metaVar = "REVISION")
   String base;
 
+  /** 1-based index of the parent's position in the commit object. */
   @Option(name = "--parent", metaVar = "parent-number")
   int parentNum;
 
@@ -143,7 +144,7 @@
     } else if (parentNum > 0) {
       psf =
           patchScriptFactoryFactory.create(
-              notes, fileName, parentNum - 1, pId, prefs, currentUser.get());
+              notes, fileName, parentNum, pId, prefs, currentUser.get());
     } else {
       psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs, currentUser.get());
     }
@@ -230,21 +231,29 @@
     }
 
     @Override
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return webLinks.getEditLinks(projectName.get(), revB, sideB.fileName());
+    }
+
+    @Override
     public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type type) {
-      String rev;
-      String hash;
-      DiffSide side;
-      if (type == DiffSide.Type.SIDE_A) {
-        rev = revA;
-        hash = hashA;
-        side = sideA;
-      } else {
-        rev = revB;
-        hash = hashB;
-        side = sideB;
-      }
+      String rev = getSideRev(type);
+      String hash = getSideHash(type);
+      DiffSide side = getDiffSide(type);
       return webLinks.getFileLinks(projectName.get(), rev, hash, side.fileName());
     }
+
+    private String getSideRev(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? revA : revB;
+    }
+
+    private String getSideHash(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? hashA : hashB;
+    }
+
+    private DiffSide getDiffSide(DiffSide.Type sideType) {
+      return DiffSide.Type.SIDE_A == sideType ? sideA : sideB;
+    }
   }
 
   public GetDiff setBase(String base) {
diff --git a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
index 6089778..95e26a23 100644
--- a/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
+++ b/java/com/google/gerrit/server/restapi/change/GetFixPreview.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.server.change.FixResource;
 import com.google.gerrit.server.diff.DiffInfoCreator;
 import com.google.gerrit.server.diff.DiffSide;
-import com.google.gerrit.server.diff.DiffSide.Type;
 import com.google.gerrit.server.diff.DiffWebLinksProvider;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LargeObjectException;
@@ -121,7 +120,7 @@
         DiffSide.create(
             ps.getFileInfoA(),
             MoreObjects.firstNonNull(ps.getOldName(), ps.getNewName()),
-            Type.SIDE_A);
+            DiffSide.Type.SIDE_A);
     DiffSide sideB = DiffSide.create(ps.getFileInfoB(), ps.getNewName(), DiffSide.Type.SIDE_B);
 
     DiffInfoCreator diffInfoCreator =
@@ -137,7 +136,12 @@
     }
 
     @Override
-    public ImmutableList<WebLinkInfo> getFileWebLinks(Type fileInfoType) {
+    public ImmutableList<WebLinkInfo> getEditWebLinks() {
+      return ImmutableList.of();
+    }
+
+    @Override
+    public ImmutableList<WebLinkInfo> getFileWebLinks(DiffSide.Type fileInfoType) {
       return ImmutableList.of();
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
index 1e71d4c..81d97e2 100644
--- a/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
+++ b/java/com/google/gerrit/server/restapi/change/GetMetaDiff.java
@@ -63,8 +63,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListChangesOption.class, hex));
   }
 
   @Option(name = "--old", usage = "old NoteDb meta SHA-1")
diff --git a/java/com/google/gerrit/server/restapi/change/GetRelated.java b/java/com/google/gerrit/server/restapi/change/GetRelated.java
index ba1a1dc..0eef468 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRelated.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRelated.java
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
@@ -29,56 +25,39 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommonConverters;
-import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
+import com.google.gerrit.server.change.RelatedChangesSorter;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
-import java.util.Set;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
 public class GetRelated implements RestReadView<RevisionResource> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final PatchSetUtil psUtil;
-  private final RelatedChangesSorter sorter;
-  private final IndexConfig indexConfig;
   private final ChangeData.Factory changeDataFactory;
+  private final GetRelatedChangesUtil getRelatedChangesUtil;
 
   @Inject
-  GetRelated(
-      Provider<InternalChangeQuery> queryProvider,
-      PatchSetUtil psUtil,
-      RelatedChangesSorter sorter,
-      IndexConfig indexConfig,
-      ChangeData.Factory changeDataFactory) {
-    this.queryProvider = queryProvider;
-    this.psUtil = psUtil;
-    this.sorter = sorter;
-    this.indexConfig = indexConfig;
+  GetRelated(ChangeData.Factory changeDataFactory, GetRelatedChangesUtil getRelatedChangesUtil) {
     this.changeDataFactory = changeDataFactory;
+    this.getRelatedChangesUtil = getRelatedChangesUtil;
   }
 
   @Override
   public Response<RelatedChangesInfo> apply(RevisionResource rsrc)
-      throws RepositoryNotFoundException, IOException, NoSuchProjectException,
-          PermissionBackendException {
+      throws IOException, NoSuchProjectException, PermissionBackendException {
     RelatedChangesInfo relatedChangesInfo = new RelatedChangesInfo();
     relatedChangesInfo.changes = getRelated(rsrc);
     return Response.ok(relatedChangesInfo);
@@ -86,30 +65,15 @@
 
   public List<RelatedChangeAndCommitInfo> getRelated(RevisionResource rsrc)
       throws IOException, PermissionBackendException {
-    Set<String> groups = getAllGroups(rsrc.getNotes(), psUtil);
-    logger.atFine().log("groups = %s", groups);
-    if (groups.isEmpty()) {
-      return Collections.emptyList();
-    }
-
-    List<ChangeData> cds =
-        InternalChangeQuery.byProjectGroups(
-            queryProvider, indexConfig, rsrc.getChange().getProject(), groups);
-    if (cds.isEmpty()) {
-      return Collections.emptyList();
-    }
-    if (cds.size() == 1 && cds.get(0).getId().equals(rsrc.getChange().getId())) {
-      return Collections.emptyList();
-    }
-    List<RelatedChangeAndCommitInfo> result = new ArrayList<>(cds.size());
-
     boolean isEdit = rsrc.getEdit().isPresent();
     PatchSet basePs = isEdit ? rsrc.getEdit().get().getBasePatchSet() : rsrc.getPatchSet();
     logger.atFine().log("isEdit = %s, basePs = %s", isEdit, basePs);
 
-    cds = reloadChangeIfStale(cds, rsrc.getChange(), basePs);
+    List<RelatedChangesSorter.PatchSetData> sortedResult =
+        getRelatedChangesUtil.getRelated(changeDataFactory.create(rsrc.getNotes()), basePs);
 
-    for (RelatedChangesSorter.PatchSetData d : sorter.sort(cds, basePs)) {
+    List<RelatedChangeAndCommitInfo> result = new ArrayList<>(sortedResult.size());
+    for (RelatedChangesSorter.PatchSetData d : sortedResult) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
       if (isEdit && ps.id().equals(basePs.id())) {
@@ -134,37 +98,6 @@
     return result;
   }
 
-  @VisibleForTesting
-  public static Set<String> getAllGroups(ChangeNotes notes, PatchSetUtil psUtil) {
-    return psUtil.byChange(notes).stream().flatMap(ps -> ps.groups().stream()).collect(toSet());
-  }
-
-  private List<ChangeData> reloadChangeIfStale(
-      List<ChangeData> changeDatasFromIndex, Change wantedChange, PatchSet wantedPs) {
-    checkArgument(
-        wantedChange.getId().equals(wantedPs.id().changeId()),
-        "change of wantedPs (%s) doesn't match wantedChange (%s)",
-        wantedPs.id().changeId(),
-        wantedChange.getId());
-
-    List<ChangeData> changeDatas = new ArrayList<>(changeDatasFromIndex.size() + 1);
-    changeDatas.addAll(changeDatasFromIndex);
-
-    // Reload the change in case the patch set is absent.
-    changeDatas.stream()
-        .filter(
-            cd -> cd.getId().equals(wantedPs.id().changeId()) && cd.patchSet(wantedPs.id()) == null)
-        .forEach(ChangeData::reloadChange);
-
-    if (changeDatas.stream().noneMatch(cd -> cd.getId().equals(wantedPs.id().changeId()))) {
-      // The change of the wanted patch set is missing in the result from the index.
-      // Load it from NoteDb and add it to the result.
-      changeDatas.add(changeDataFactory.create(wantedChange));
-    }
-
-    return changeDatas;
-  }
-
   static RelatedChangeAndCommitInfo newChangeAndCommit(
       Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
     RelatedChangeAndCommitInfo info = new RelatedChangeAndCommitInfo();
diff --git a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
index 527129c..f3c0fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
+++ b/java/com/google/gerrit/server/restapi/change/GetRevisionActions.java
@@ -34,6 +34,6 @@
 
   @Override
   public Response<Map<String, ActionInfo>> apply(RevisionResource rsrc) {
-    return Response.withMustRevalidate(delegate.format(rsrc));
+    return Response.ok(delegate.format(rsrc));
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
index 65f90ae..89ee399 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeDrafts.java
@@ -27,12 +27,10 @@
 import com.google.gerrit.server.restapi.change.CommentJson.HumanCommentFormatter;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.util.List;
 import java.util.Map;
 import org.kohsuke.args4j.Option;
 
-@Singleton
 public class ListChangeDrafts implements RestReadView<ChangeResource> {
   private final ChangeData.Factory changeDataFactory;
   private final Provider<CommentJson> commentJson;
diff --git a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
index 099d0a6..c881621 100644
--- a/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
+++ b/java/com/google/gerrit/server/restapi/change/ListChangeMessages.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.restapi.change;
 
-import static com.google.gerrit.server.ChangeMessagesUtil.createChangeMessageInfo;
-
 import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.restapi.Response;
@@ -48,7 +46,10 @@
     List<ChangeMessage> messages = changeMessagesUtil.byChange(resource.getNotes());
     List<ChangeMessageInfo> messageInfos =
         messages.stream()
-            .map(m -> createChangeMessageInfo(m, accountLoader))
+            .map(
+                m ->
+                    changeMessagesUtil.createChangeMessageInfoWithReplacedTemplates(
+                        m, accountLoader))
             .collect(Collectors.toList());
     accountLoader.fill();
     return Response.ok(messageInfos);
diff --git a/java/com/google/gerrit/server/restapi/change/ListReviewers.java b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
index 3d07d43..12dbf4e 100644
--- a/java/com/google/gerrit/server/restapi/change/ListReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListReviewers.java
@@ -19,7 +19,7 @@
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
diff --git a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
index b44f637..2df2d29 100644
--- a/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/ListRevisionReviewers.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerJson;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
deleted file mode 100644
index fa4555b..0000000
--- a/java/com/google/gerrit/server/restapi/change/MarkAsReviewed.java
+++ /dev/null
@@ -1,70 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class MarkAsReviewed
-    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ChangeData.Factory changeDataFactory;
-  private final StarredChangesUtil stars;
-
-  @Inject
-  MarkAsReviewed(ChangeData.Factory changeDataFactory, StarredChangesUtil stars) {
-    this.changeDataFactory = changeDataFactory;
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Mark Reviewed")
-        .setTitle("Mark the change as reviewed to unhighlight it in the dashboard")
-        .setVisible(!isReviewed(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, IllegalLabelException {
-    stars.markAsReviewed(rsrc);
-    return Response.ok();
-  }
-
-  private boolean isReviewed(ChangeResource rsrc) {
-    try {
-      return changeDataFactory
-          .create(rsrc.getNotes())
-          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check if change is reviewed");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java b/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
deleted file mode 100644
index 601fc4a..0000000
--- a/java/com/google/gerrit/server/restapi/change/MarkAsUnreviewed.java
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.restapi.change;
-
-import com.google.common.flogger.FluentLogger;
-import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.common.Input;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class MarkAsUnreviewed
-    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  private final ChangeData.Factory changeDataFactory;
-  private final StarredChangesUtil stars;
-
-  @Inject
-  MarkAsUnreviewed(ChangeData.Factory changeDataFactory, StarredChangesUtil stars) {
-    this.changeDataFactory = changeDataFactory;
-    this.stars = stars;
-  }
-
-  @Override
-  public Description getDescription(ChangeResource rsrc) {
-    return new UiAction.Description()
-        .setLabel("Mark Unreviewed")
-        .setTitle("Mark the change as unreviewed to highlight it in the dashboard")
-        .setVisible(isReviewed(rsrc));
-  }
-
-  @Override
-  public Response<String> apply(ChangeResource rsrc, Input input) throws IllegalLabelException {
-    stars.markAsUnreviewed(rsrc);
-    return Response.ok();
-  }
-
-  private boolean isReviewed(ChangeResource rsrc) {
-    try {
-      return changeDataFactory
-          .create(rsrc.getNotes())
-          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("failed to check if change is reviewed");
-    }
-    return false;
-  }
-}
diff --git a/java/com/google/gerrit/server/restapi/change/Move.java b/java/com/google/gerrit/server/restapi/change/Move.java
index fc7e9f4..f52e81e 100644
--- a/java/com/google/gerrit/server/restapi/change/Move.java
+++ b/java/com/google/gerrit/server/restapi/change/Move.java
@@ -21,17 +21,14 @@
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.base.Strings;
-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.ChangeMessage;
 import com.google.gerrit.entities.LabelType;
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -42,11 +39,11 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -67,6 +64,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
@@ -75,8 +73,6 @@
 
 @Singleton
 public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final PermissionBackend permissionBackend;
   private final BatchUpdate.Factory updateFactory;
   private final ChangeJson.Factory json;
@@ -249,9 +245,7 @@
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
-      cmUtil.addChangeMessage(update, cmsg);
+      cmUtil.setChangeMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE);
 
       return true;
     }
@@ -269,11 +263,13 @@
           approvalsUtil.byPatchSet(
               ctx.getNotes(), psId, ctx.getRevWalk(), ctx.getRepoView().getConfig())) {
         ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project));
-        LabelType type = projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
+        Optional<LabelType> type =
+            projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId());
         // Only keep veto votes, defined as votes where:
         // 1- the label function allows minimum values to block submission.
         // 2- the vote holds the minimum value.
-        if (type == null || (type.isMaxNegative(psa) && type.getFunction().isBlock())) {
+        if (!type.isPresent()
+            || (type.get().isMaxNegative(psa) && type.get().getFunction().isBlock())) {
           continue;
         }
 
@@ -284,7 +280,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Move Change")
@@ -295,30 +291,15 @@
     if (!change.isNew()) {
       return description;
     }
-
-    try {
-      if (!projectCache
-          .get(rsrc.getProject())
-          .orElseThrow(illegalState(rsrc.getProject()))
-          .statePermitsWrite()) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .statePermitsWrite()) {
       return description;
     }
-
-    try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     return description.setVisible(
         and(
             permissionBackend.user(rsrc.getUser()).ref(change.getDest()).testCond(CREATE_CHANGE),
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 58321e9..5c252f4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -16,12 +16,12 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
@@ -43,7 +43,6 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Address;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.FixReplacement;
 import com.google.gerrit.entities.FixSuggestion;
@@ -55,14 +54,14 @@
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.RobotComment;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -81,7 +80,10 @@
 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.ApprovalsUtil;
+import com.google.gerrit.metrics.Counter1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
@@ -90,18 +92,22 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.PublishCommentUtil;
 import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.AddReviewersEmail;
-import com.google.gerrit.server.change.AddReviewersOp.Result;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.change.ModifyReviewersEmail;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
+import com.google.gerrit.server.change.ReviewerOp.Result;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.logging.Metadata;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -123,7 +129,7 @@
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.CommentsRejectedException;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -152,6 +158,29 @@
 public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
+  @Singleton
+  private static class Metrics {
+    final Counter1<String> draftHandling;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      draftHandling =
+          metricMaker.newCounter(
+              "change/post_review/draft_handling",
+              new Description(
+                      "Total number of draft handling option "
+                          + "(KEEP, PUBLISH, PUBLISH_ALL_REVISIONS) "
+                          + "selected by users while posting a review.")
+                  .setRate()
+                  .setUnit("count"),
+              Field.ofString("type", Metadata.Builder::eventType)
+                  .description(
+                      "The type of the draft handling option"
+                          + " (KEEP, PUBLISH, PUBLISH_ALL_REVISIONS).")
+                  .build());
+    }
+  }
+
   private static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
   public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
       "work_in_progress and ready are mutually exclusive";
@@ -161,6 +190,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
+  private final AccountCache accountCache;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
   private final CommentsUtil commentsUtil;
@@ -170,8 +200,9 @@
   private final AccountResolver accountResolver;
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
-  private final ReviewerAdder reviewerAdder;
-  private final AddReviewersEmail addReviewersEmail;
+  private final ReviewerModifier reviewerModifier;
+  private final Metrics metrics;
+  private final ModifyReviewersEmail modifyReviewersEmail;
   private final NotifyResolver notifyResolver;
   private final WorkInProgressOp.Factory workInProgressOpFactory;
   private final ProjectCache projectCache;
@@ -179,6 +210,7 @@
   private final PluginSetContext<CommentValidator> commentValidators;
   private final PluginSetContext<OnPostReview> onPostReviews;
   private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
+  private final ReviewerAdded reviewerAdded;
   private final boolean strictLabels;
   private final boolean publishPatchSetLevelComment;
 
@@ -187,6 +219,7 @@
       BatchUpdate.Factory updateFactory,
       ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
+      AccountCache accountCache,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
       CommentsUtil commentsUtil,
@@ -196,8 +229,9 @@
       AccountResolver accountResolver,
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
-      ReviewerAdder reviewerAdder,
-      AddReviewersEmail addReviewersEmail,
+      ReviewerModifier reviewerModifier,
+      Metrics metrics,
+      ModifyReviewersEmail modifyReviewersEmail,
       NotifyResolver notifyResolver,
       @GerritServerConfig Config gerritConfig,
       WorkInProgressOp.Factory workInProgressOpFactory,
@@ -205,10 +239,12 @@
       PermissionBackend permissionBackend,
       PluginSetContext<CommentValidator> commentValidators,
       PluginSetContext<OnPostReview> onPostReviews,
-      ReplyAttentionSetUpdates replyAttentionSetUpdates) {
+      ReplyAttentionSetUpdates replyAttentionSetUpdates,
+      ReviewerAdded reviewerAdded) {
     this.updateFactory = updateFactory;
     this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
+    this.accountCache = accountCache;
     this.commentsUtil = commentsUtil;
     this.publishCommentUtil = publishCommentUtil;
     this.psUtil = psUtil;
@@ -218,8 +254,9 @@
     this.accountResolver = accountResolver;
     this.email = email;
     this.commentAdded = commentAdded;
-    this.reviewerAdder = reviewerAdder;
-    this.addReviewersEmail = addReviewersEmail;
+    this.reviewerModifier = reviewerModifier;
+    this.metrics = metrics;
+    this.modifyReviewersEmail = modifyReviewersEmail;
     this.notifyResolver = notifyResolver;
     this.workInProgressOpFactory = workInProgressOpFactory;
     this.projectCache = projectCache;
@@ -227,6 +264,7 @@
     this.commentValidators = commentValidators;
     this.onPostReviews = onPostReviews;
     this.replyAttentionSetUpdates = replyAttentionSetUpdates;
+    this.reviewerAdded = reviewerAdded;
     this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
     this.publishPatchSetLevelComment =
         gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
@@ -253,6 +291,7 @@
 
     logger.atFine().log("strict label checking is %s", (strictLabels ? "enabled" : "disabled"));
 
+    metrics.draftHandling.increment(input.drafts == null ? "N/A" : input.drafts.name());
     input.drafts = firstNonNull(input.drafts, DraftHandling.KEEP);
     logger.atFine().log("draft handling = %s", input.drafts);
 
@@ -266,6 +305,9 @@
       input.comments = cleanUpComments(input.comments);
       checkComments(revision, input.comments);
     }
+    if (input.draftIdsToPublish != null) {
+      checkDraftIds(revision, input.draftIdsToPublish, input.drafts);
+    }
     if (input.robotComments != null) {
       input.robotComments = cleanUpComments(input.robotComments);
       checkRobotComments(revision, input.robotComments);
@@ -276,15 +318,15 @@
     }
     logger.atFine().log("notify handling = %s", input.notify);
 
-    Map<String, AddReviewerResult> reviewerJsonResults = null;
-    List<ReviewerAddition> reviewerResults = Lists.newArrayList();
+    Map<String, ReviewerResult> reviewerJsonResults = null;
+    List<ReviewerModification> reviewerResults = Lists.newArrayList();
     boolean hasError = false;
     boolean confirm = false;
     if (input.reviewers != null) {
       reviewerJsonResults = Maps.newHashMap();
-      for (AddReviewerInput reviewerInput : input.reviewers) {
-        ReviewerAddition result =
-            reviewerAdder.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
+      for (ReviewerInput reviewerInput : input.reviewers) {
+        ReviewerModification result =
+            reviewerModifier.prepare(revision.getNotes(), revision.getUser(), reviewerInput, true);
         reviewerJsonResults.put(reviewerInput.reviewer, result.result);
         if (result.result.error != null) {
           logger.atFine().log(
@@ -313,7 +355,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(revision.getChange().getProject(), revision.getUser(), ts)) {
-      Account.Id id = revision.getUser().getAccountId();
+      Account account = revision.getUser().asIdentifiedUser().getAccount();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
         ccOrReviewer = input.labels.values().stream().anyMatch(v -> v != 0);
@@ -326,7 +368,7 @@
         // Check if user was already CCed or reviewing prior to this review.
         ReviewerSet currentReviewers =
             approvalsUtil.getReviewers(revision.getChangeResource().getNotes());
-        ccOrReviewer = currentReviewers.all().contains(id);
+        ccOrReviewer = currentReviewers.all().contains(account.id());
         if (ccOrReviewer) {
           logger.atFine().log("calling user is already cc/reviewer on the change");
         }
@@ -336,10 +378,11 @@
       // updated set of reviewers. Also keep track of whether the user added
       // themselves as a reviewer or to the CC list.
       logger.atFine().log("adding reviewer additions");
-      for (ReviewerAddition reviewerResult : reviewerResults) {
+      for (ReviewerModification reviewerResult : reviewerResults) {
         reviewerResult.op.suppressEmail(); // Send a single batch email below.
+        reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
         bu.addOp(revision.getChange().getId(), reviewerResult.op);
-        if (!ccOrReviewer && reviewerResult.reviewers.contains(id)) {
+        if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
           logger.atFine().log("calling user is explicitly added as reviewer or CC");
           ccOrReviewer = true;
         }
@@ -350,8 +393,10 @@
         // isn't being explicitly added, and isn't voting on any label.
         // Automatically CC them on this change so they receive replies.
         logger.atFine().log("CCing calling user");
-        ReviewerAddition selfAddition = reviewerAdder.ccCurrentUser(revision.getUser(), revision);
+        ReviewerModification selfAddition =
+            reviewerModifier.ccCurrentUser(revision.getUser(), revision);
         selfAddition.op.suppressEmail();
+        selfAddition.op.suppressEvent();
         bu.addOp(revision.getChange().getId(), selfAddition.op);
       }
 
@@ -384,7 +429,7 @@
       bu.addOp(
           revision.getChange().getId(), new Op(projectState, revision.getPatchSet().id(), input));
 
-      // Notify based on ReviewInput, ignoring the notify settings from any AddReviewerInputs.
+      // Notify based on ReviewInput, ignoring the notify settings from any ReviewerInputs.
       NotifyResolver.Result notify = notifyResolver.resolve(input.notify, input.notifyDetails);
       bu.setNotify(notify);
 
@@ -395,12 +440,14 @@
 
       // Re-read change to take into account results of the update.
       ChangeData cd = changeDataFactory.create(revision.getProject(), revision.getChange().getId());
-      for (ReviewerAddition reviewerResult : reviewerResults) {
+      for (ReviewerModification reviewerResult : reviewerResults) {
         reviewerResult.gatherResults(cd);
       }
 
-      // Sending from AddReviewersOp was suppressed so we can send a single batch email here.
+      // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+      // email/event here.
       batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+      batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
     }
 
     return Response.ok(output);
@@ -437,32 +484,76 @@
   private void batchEmailReviewers(
       CurrentUser user,
       Change change,
-      List<ReviewerAddition> reviewerAdditions,
+      List<ReviewerModification> reviewerModifications,
       NotifyResolver.Result notify) {
     try (TraceContext.TraceTimer ignored = newTimer("batchEmailReviewers")) {
       List<Account.Id> to = new ArrayList<>();
       List<Account.Id> cc = new ArrayList<>();
+      List<Account.Id> removed = new ArrayList<>();
       List<Address> toByEmail = new ArrayList<>();
       List<Address> ccByEmail = new ArrayList<>();
-      for (ReviewerAddition addition : reviewerAdditions) {
-        Result reviewAdditionResult = addition.op.getResult();
-        if (addition.state() == ReviewerState.REVIEWER
+      List<Address> removedByEmail = new ArrayList<>();
+      for (ReviewerModification modification : reviewerModifications) {
+        Result reviewAdditionResult = modification.op.getResult();
+        if (modification.state() == ReviewerState.REVIEWER
             && (!reviewAdditionResult.addedReviewers().isEmpty()
                 || !reviewAdditionResult.addedReviewersByEmail().isEmpty())) {
-          to.addAll(addition.reviewers);
-          toByEmail.addAll(addition.reviewersByEmail);
-        } else if (addition.state() == ReviewerState.CC
+          to.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet()));
+          toByEmail.addAll(modification.reviewersByEmail);
+        } else if (modification.state() == ReviewerState.CC
             && (!reviewAdditionResult.addedCCs().isEmpty()
                 || !reviewAdditionResult.addedCCsByEmail().isEmpty())) {
-          cc.addAll(addition.reviewers);
-          ccByEmail.addAll(addition.reviewersByEmail);
+          cc.addAll(modification.reviewers.stream().map(Account::id).collect(toImmutableSet()));
+          ccByEmail.addAll(modification.reviewersByEmail);
+        } else if (modification.state() == ReviewerState.REMOVED
+            && (reviewAdditionResult.deletedReviewer().isPresent()
+                || reviewAdditionResult.deletedReviewerByEmail().isPresent())) {
+          reviewAdditionResult.deletedReviewer().ifPresent(d -> removed.add(d));
+          reviewAdditionResult.deletedReviewerByEmail().ifPresent(d -> removedByEmail.add(d));
         }
       }
-      addReviewersEmail.emailReviewersAsync(
-          user.asIdentifiedUser(), change, to, cc, toByEmail, ccByEmail, notify);
+      modifyReviewersEmail.emailReviewersAsync(
+          user.asIdentifiedUser(),
+          change,
+          to,
+          cc,
+          removed,
+          toByEmail,
+          ccByEmail,
+          removedByEmail,
+          notify);
     }
   }
 
+  private void batchReviewerEvents(
+      CurrentUser user,
+      ChangeData cd,
+      PatchSet patchSet,
+      List<ReviewerModification> reviewerModifications,
+      Timestamp when) {
+    List<AccountState> newlyAddedReviewers = new ArrayList<>();
+
+    // There are no events for CCs and reviewers added/deleted by email.
+    for (ReviewerModification modification : reviewerModifications) {
+      Result reviewerAdditionResult = modification.op.getResult();
+      if (modification.state() == ReviewerState.REVIEWER) {
+        newlyAddedReviewers.addAll(
+            reviewerAdditionResult.addedReviewers().stream()
+                .map(psa -> psa.accountId())
+                .map(accountId -> accountCache.get(accountId))
+                .flatMap(Streams::stream)
+                .collect(toList()));
+      } else if (modification.state() == ReviewerState.REMOVED) {
+        // There is no batch event for reviewer removals, hence fire the event for each
+        // modification that deleted a reviewer immediately.
+        modification.op.sendEvent();
+      }
+    }
+
+    // Fire a batch event for all newly added reviewers.
+    reviewerAdded.fire(cd, patchSet, newlyAddedReviewers, user.asIdentifiedUser().state(), when);
+  }
+
   private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
       throws BadRequestException, AuthException, UnprocessableEntityException,
           PermissionBackendException, IOException, ConfigInvalidException {
@@ -483,8 +574,8 @@
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType type = labelTypes.byLabel(ent.getKey());
-      if (type == null) {
+      Optional<LabelType> type = labelTypes.byLabel(ent.getKey());
+      if (!type.isPresent()) {
         logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
@@ -499,15 +590,15 @@
         logger.atFine().log(
             "skipping on behalf of permission check for label %s"
                 + " because caller is an internal user",
-            type.getName());
+            type.get().getName());
       } else {
         try {
-          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type.get(), ent.getValue()));
         } catch (AuthException e) {
           throw new AuthException(
               String.format(
                   "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                  type.getName(), in.onBehalfOf),
+                  type.get().getName(), in.onBehalfOf),
               e);
         }
       }
@@ -539,8 +630,8 @@
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType lt = labelTypes.byLabel(ent.getKey());
-      if (lt == null) {
+      Optional<LabelType> lt = labelTypes.byLabel(ent.getKey());
+      if (!lt.isPresent()) {
         logger.atFine().log("label %s not found", ent.getKey());
         if (strictLabels) {
           throw new BadRequestException(
@@ -557,7 +648,7 @@
         continue;
       }
 
-      if (lt.getValue(ent.getValue()) == null) {
+      if (lt.get().getValue(ent.getValue()) == null) {
         logger.atFine().log("label value %s not found", ent.getValue());
         if (strictLabels) {
           throw new BadRequestException(
@@ -571,10 +662,10 @@
 
       short val = ent.getValue();
       try {
-        perm.check(new LabelPermission.WithValue(lt, val));
+        perm.check(new LabelPermission.WithValue(lt.get(), val));
       } catch (AuthException e) {
         throw new AuthException(
-            String.format("Applying label \"%s\": %d is restricted", lt.getName(), val), e);
+            String.format("Applying label \"%s\": %d is restricted", lt.get().getName(), val), e);
       }
     }
   }
@@ -631,6 +722,41 @@
     }
   }
 
+  /**
+   * Asserts that the draft IDs to publish are valid, i.e. they exist and belong to the current
+   * user. If the {@code draftHandling} parameter is equal to {@link DraftHandling#PUBLISH}, then
+   * draft IDs should all correspond to the target revision, otherwise we throw a
+   * BadRequestException.
+   */
+  private void checkDraftIds(
+      RevisionResource resource, List<String> draftIds, DraftHandling draftHandling)
+      throws BadRequestException {
+    Map<String, HumanComment> draftsByUuid =
+        commentsUtil.draftByChangeAuthor(resource.getNotes(), resource.getUser().getAccountId())
+            .stream()
+            .collect(Collectors.toMap(c -> c.key.uuid, c -> c));
+    List<String> nonExistingDraftIds =
+        draftIds.stream().filter(id -> !draftsByUuid.containsKey(id)).collect(toList());
+    if (!nonExistingDraftIds.isEmpty()) {
+      throw new BadRequestException("Non-existing draft IDs: " + nonExistingDraftIds);
+    }
+    if (draftHandling == DraftHandling.PUBLISH_ALL_REVISIONS
+        || draftHandling == DraftHandling.KEEP) {
+      return;
+    }
+    List<String> draftsForOtherRevisions =
+        draftIds.stream()
+            .filter(id -> draftsByUuid.get(id).key.patchSetId != resource.getPatchSet().number())
+            .collect(toList());
+    if (!draftsForOtherRevisions.isEmpty()) {
+      throw new BadRequestException(
+          String.format(
+              "Draft comments for other revisions cannot be published when DraftHandling = PUBLISH."
+                  + " (draft IDs: %s)",
+              draftsForOtherRevisions));
+    }
+  }
+
   private Set<String> getAffectedFilePaths(RevisionResource revision)
       throws PatchListNotAvailableException {
     ObjectId newId = revision.getPatchSet().commitId();
@@ -890,7 +1016,7 @@
     private IdentifiedUser user;
     private ChangeNotes notes;
     private PatchSet ps;
-    private ChangeMessage message;
+    private String mailMessage;
     private List<Comment> comments = new ArrayList<>();
     private List<LabelVote> labelDelta = new ArrayList<>();
     private Map<String, Short> approvals = new HashMap<>();
@@ -928,8 +1054,8 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) {
-      if (message == null) {
+    public void postUpdate(PostUpdateContext ctx) {
+      if (mailMessage == null) {
         return;
       }
       NotifyResolver.Result notify = ctx.getNotify(notes.getChangeId());
@@ -941,7 +1067,8 @@
                   notes,
                   ps,
                   user,
-                  message,
+                  mailMessage,
+                  ctx.getWhen(),
                   comments,
                   in.message,
                   labelDelta,
@@ -952,7 +1079,7 @@
               String.format("Repository %s not found", ctx.getProject().get()), ex);
         }
       }
-      String comment = message.getMessage();
+      String comment = mailMessage;
       if (publishPatchSetLevelComment) {
         // TODO(davido): Remove this workaround when patch set level comments are exposed in comment
         // added event. For backwards compatibility, patchset level comment has a higher priority
@@ -968,9 +1095,23 @@
         }
       }
       commentAdded.fire(
-          notes.getChange(), ps, user.state(), comment, approvals, oldApprovals, ctx.getWhen());
+          ctx.getChangeData(notes),
+          ps,
+          user.state(),
+          comment,
+          approvals,
+          oldApprovals,
+          ctx.getWhen());
     }
 
+    /**
+     * Publishes draft and input comments. Input comments are those passed as input in the request
+     * body.
+     *
+     * @param ctx context for performing the change update.
+     * @param newRobotComments robot comments. Used only for validation in this method.
+     * @return true if any input comments where published.
+     */
     private boolean insertComments(ChangeContext ctx, List<RobotComment> newRobotComments)
         throws CommentsRejectedException {
       Map<String, List<CommentInput>> inputComments = in.comments;
@@ -978,28 +1119,71 @@
         inputComments = Collections.emptyMap();
       }
 
-      // HashMap instead of Collections.emptyMap() avoids warning about remove() on immutable
-      // object.
+      // Use HashMap to avoid warnings when calling remove() in resolveInputCommentsAndDrafts().
       Map<String, HumanComment> drafts = new HashMap<>();
-      // If there are inputComments we need the deduplication loop below, so we have to read (and
-      // publish) drafts here.
+
       if (!inputComments.isEmpty() || in.drafts != DraftHandling.KEEP) {
-        if (in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS) {
-          drafts = changeDrafts(ctx);
-        } else {
-          drafts = patchSetDrafts(ctx);
-        }
+        drafts =
+            in.drafts == DraftHandling.PUBLISH_ALL_REVISIONS
+                ? changeDrafts(ctx)
+                : patchSetDrafts(ctx);
       }
 
-      // This will be populated with Comment-s created from inputComments.
-      List<HumanComment> toPublish = new ArrayList<>();
-
+      // Existing published comments
       Set<CommentSetEntry> existingComments =
           in.omitDuplicateComments ? readExistingComments(ctx) : Collections.emptySet();
 
-      // Deduplication:
-      // - Ignore drafts with the same ID as an inputComment here. These are deleted later.
-      // - Swallow comments that already exist.
+      // Input comments should be deduplicated from existing drafts
+      List<HumanComment> inputCommentsToPublish =
+          resolveInputCommentsAndDrafts(inputComments, existingComments, drafts, ctx);
+
+      switch (in.drafts) {
+        case PUBLISH:
+        case PUBLISH_ALL_REVISIONS:
+          Collection<HumanComment> filteredDrafts =
+              in.draftIdsToPublish == null
+                  ? drafts.values()
+                  : drafts.values().stream()
+                      .filter(draft -> in.draftIdsToPublish.contains(draft.key.uuid))
+                      .collect(Collectors.toList());
+
+          validateComments(
+              ctx,
+              Streams.concat(
+                  drafts.values().stream(),
+                  inputCommentsToPublish.stream(),
+                  newRobotComments.stream()));
+          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), filteredDrafts, in.tag);
+          comments.addAll(drafts.values());
+          break;
+        case KEEP:
+          validateComments(
+              ctx, Streams.concat(inputCommentsToPublish.stream(), newRobotComments.stream()));
+          break;
+      }
+      commentsUtil.putHumanComments(
+          ctx.getUpdate(psId), HumanComment.Status.PUBLISHED, inputCommentsToPublish);
+      comments.addAll(inputCommentsToPublish);
+      return !inputCommentsToPublish.isEmpty();
+    }
+
+    /**
+     * Returns the subset of {@code inputComments} that do not have a matching comment (with same
+     * id) neither in {@code existingComments} nor in {@code drafts}.
+     *
+     * <p>Entries in {@code drafts} that have a matching entry in {@code inputComments} will be
+     * removed.
+     *
+     * @param inputComments new comments provided as {@link CommentInput} entries in the API.
+     * @param existingComments existing published comments in the database.
+     * @param drafts existing draft comments in the database. This map can be modified.
+     */
+    private List<HumanComment> resolveInputCommentsAndDrafts(
+        Map<String, List<CommentInput>> inputComments,
+        Set<CommentSetEntry> existingComments,
+        Map<String, HumanComment> drafts,
+        ChangeContext ctx) {
+      List<HumanComment> inputCommentsToPublish = new ArrayList<>();
       for (Map.Entry<String, List<CommentInput>> entry : inputComments.entrySet()) {
         String path = entry.getKey();
         for (CommentInput inputComment : entry.getValue()) {
@@ -1031,32 +1215,10 @@
           if (existingComments.contains(CommentSetEntry.create(comment))) {
             continue;
           }
-          toPublish.add(comment);
+          inputCommentsToPublish.add(comment);
         }
       }
-
-      CommentValidationContext commentValidationCtx =
-          CommentValidationContext.create(
-              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
-      switch (in.drafts) {
-        case PUBLISH:
-        case PUBLISH_ALL_REVISIONS:
-          validateComments(
-              commentValidationCtx,
-              Streams.concat(
-                  drafts.values().stream(), toPublish.stream(), newRobotComments.stream()));
-          publishCommentUtil.publish(ctx, ctx.getUpdate(psId), drafts.values(), in.tag);
-          comments.addAll(drafts.values());
-          break;
-        case KEEP:
-          validateComments(
-              commentValidationCtx, Stream.concat(toPublish.stream(), newRobotComments.stream()));
-          break;
-      }
-      ChangeUpdate changeUpdate = ctx.getUpdate(psId);
-      commentsUtil.putHumanComments(changeUpdate, HumanComment.Status.PUBLISHED, toPublish);
-      comments.addAll(toPublish);
-      return !toPublish.isEmpty();
+      return inputCommentsToPublish;
     }
 
     /**
@@ -1064,8 +1226,11 @@
      * contract of {@link CommentValidator#validateComments(CommentValidationContext,
      * ImmutableList)}.
      */
-    private void validateComments(CommentValidationContext ctx, Stream<Comment> comments)
+    private void validateComments(ChangeContext ctx, Stream<? extends Comment> comments)
         throws CommentsRejectedException {
+      CommentValidationContext commentValidationCtx =
+          CommentValidationContext.create(
+              ctx.getChange().getChangeId(), ctx.getChange().getProject().get());
       String changeMessage = Strings.nullToEmpty(in.message).trim();
       ImmutableList<CommentForValidation> draftsForValidation =
           Stream.concat(
@@ -1088,7 +1253,8 @@
                           changeMessage.length())))
               .collect(toImmutableList());
       ImmutableList<CommentValidationFailure> draftValidationFailures =
-          PublishCommentUtil.findInvalidComments(ctx, commentValidators, draftsForValidation);
+          PublishCommentUtil.findInvalidComments(
+              commentValidationCtx, commentValidators, draftsForValidation);
       if (!draftValidationFailures.isEmpty()) {
         throw new CommentsRejectedException(draftValidationFailures);
       }
@@ -1262,7 +1428,10 @@
       ChangeUpdate update = ctx.getUpdate(psId);
       for (Map.Entry<String, Short> ent : allApprovals.entrySet()) {
         String name = ent.getKey();
-        LabelType lt = requireNonNull(labelTypes.byLabel(name), name);
+        LabelType lt =
+            labelTypes
+                .byLabel(name)
+                .orElseThrow(() -> new IllegalStateException("no label config for " + name));
 
         PatchSetApproval c = current.remove(lt.getName());
         String normName = lt.getName();
@@ -1324,11 +1493,7 @@
       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})
-     */
+    /** Approval is copied over if it doesn't exist in the approvals of the current patch-set. */
     private boolean isApprovalCopiedOver(
         PatchSetApproval patchSetApproval, ChangeNotes changeNotes) {
       return !changeNotes.getApprovals().get(changeNotes.getChange().currentPatchSetId()).stream()
@@ -1358,7 +1523,10 @@
       List<String> disallowed = new ArrayList<>(labelTypes.getLabelTypes().size());
 
       for (PatchSetApproval psa : del) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
+        LabelType lt =
+            labelTypes
+                .byLabel(psa.label())
+                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
         String normName = lt.getName();
         if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
@@ -1370,7 +1538,10 @@
       }
 
       for (PatchSetApproval psa : ups) {
-        LabelType lt = requireNonNull(labelTypes.byLabel(psa.label()));
+        LabelType lt =
+            labelTypes
+                .byLabel(psa.label())
+                .orElseThrow(() -> new IllegalStateException("no label config for " + psa.label()));
         String normName = lt.getName();
         if (!lt.isAllowPostSubmit()) {
           disallowed.add(normName);
@@ -1418,9 +1589,9 @@
           continue;
         }
 
-        LabelType lt = labelTypes.byLabel(a.labelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
+        Optional<LabelType> lt = labelTypes.byLabel(a.labelId());
+        if (lt.isPresent()) {
+          current.put(lt.get().getName(), a);
         } else {
           del.add(a);
         }
@@ -1468,10 +1639,9 @@
         return false;
       }
 
-      message =
-          ChangeMessagesUtil.newMessage(
-              psId, user, ctx.getWhen(), "Patch Set " + psId.get() + ":" + buf, in.tag);
-      cmUtil.addChangeMessage(ctx.getUpdate(psId), message);
+      mailMessage =
+          cmUtil.setChangeMessage(
+              ctx.getUpdate(psId), "Patch Set " + psId.get() + ":" + buf, in.tag);
       return true;
     }
 
diff --git a/java/com/google/gerrit/server/restapi/change/PostReviewers.java b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
index e6a87e9..4691550 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReviewers.java
@@ -15,17 +15,17 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 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.RestCollectionModifyView;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.NotifyResolver;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -39,50 +39,51 @@
 
 @Singleton
 public class PostReviewers
-    implements RestCollectionModifyView<ChangeResource, ReviewerResource, AddReviewerInput> {
+    implements RestCollectionModifyView<ChangeResource, ReviewerResource, ReviewerInput> {
   private final BatchUpdate.Factory updateFactory;
   private final ChangeData.Factory changeDataFactory;
   private final NotifyResolver notifyResolver;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
 
   @Inject
   PostReviewers(
       BatchUpdate.Factory updateFactory,
       ChangeData.Factory changeDataFactory,
       NotifyResolver notifyResolver,
-      ReviewerAdder reviewerAdder) {
+      ReviewerModifier reviewerModifier) {
     this.updateFactory = updateFactory;
     this.changeDataFactory = changeDataFactory;
     this.notifyResolver = notifyResolver;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
   }
 
   @Override
-  public Response<AddReviewerResult> apply(ChangeResource rsrc, AddReviewerInput input)
+  public Response<ReviewerResult> apply(ChangeResource rsrc, ReviewerInput input)
       throws IOException, RestApiException, UpdateException, PermissionBackendException,
           ConfigInvalidException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
 
-    ReviewerAddition addition = reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
-    if (addition.op == null) {
-      return Response.ok(addition.result);
+    ReviewerModification modification =
+        reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), input, true);
+    if (modification.op == null) {
+      return Response.ok(modification.result);
     }
     try (BatchUpdate bu =
         updateFactory.create(rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       bu.setNotify(resolveNotify(rsrc, input));
       Change.Id id = rsrc.getChange().getId();
-      bu.addOp(id, addition.op);
+      bu.addOp(id, modification.op);
       bu.execute();
     }
 
     // Re-read change to take into account results of the update.
-    addition.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
-    return Response.ok(addition.result);
+    modification.gatherResults(changeDataFactory.create(rsrc.getProject(), rsrc.getId()));
+    return Response.ok(modification.result);
   }
 
-  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, AddReviewerInput input)
+  private NotifyResolver.Result resolveNotify(ChangeResource rsrc, ReviewerInput input)
       throws BadRequestException, ConfigInvalidException, IOException {
     NotifyHandling notifyHandling = input.notify;
     if (notifyHandling == null) {
diff --git a/java/com/google/gerrit/server/restapi/change/PutAssignee.java b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
index 7b0d905..dcf616c 100644
--- a/java/com/google/gerrit/server/restapi/change/PutAssignee.java
+++ b/java/com/google/gerrit/server/restapi/change/PutAssignee.java
@@ -15,9 +15,9 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -26,14 +26,14 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.change.ReviewerAdder;
-import com.google.gerrit.server.change.ReviewerAdder.ReviewerAddition;
+import com.google.gerrit.server.change.ReviewerModifier;
+import com.google.gerrit.server.change.ReviewerModifier.ReviewerModification;
 import com.google.gerrit.server.change.SetAssigneeOp;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -53,7 +53,7 @@
   private final BatchUpdate.Factory updateFactory;
   private final AccountResolver accountResolver;
   private final SetAssigneeOp.Factory assigneeFactory;
-  private final ReviewerAdder reviewerAdder;
+  private final ReviewerModifier reviewerModifier;
   private final AccountLoader.Factory accountLoaderFactory;
   private final PermissionBackend permissionBackend;
   private final ApprovalsUtil approvalsUtil;
@@ -63,14 +63,14 @@
       BatchUpdate.Factory updateFactory,
       AccountResolver accountResolver,
       SetAssigneeOp.Factory assigneeFactory,
-      ReviewerAdder reviewerAdder,
+      ReviewerModifier reviewerModifier,
       AccountLoader.Factory accountLoaderFactory,
       PermissionBackend permissionBackend,
       ApprovalsUtil approvalsUtil) {
     this.updateFactory = updateFactory;
     this.accountResolver = accountResolver;
     this.assigneeFactory = assigneeFactory;
-    this.reviewerAdder = reviewerAdder;
+    this.reviewerModifier = reviewerModifier;
     this.accountLoaderFactory = accountLoaderFactory;
     this.permissionBackend = permissionBackend;
     this.approvalsUtil = approvalsUtil;
@@ -104,7 +104,7 @@
 
       ReviewerSet currentReviewers = approvalsUtil.getReviewers(rsrc.getNotes());
       if (!currentReviewers.all().contains(assignee.getAccountId())) {
-        ReviewerAddition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
+        ReviewerModification reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
         reviewersAddition.op.suppressEmail();
         bu.addOp(rsrc.getId(), reviewersAddition.op);
       }
@@ -114,14 +114,14 @@
     }
   }
 
-  private ReviewerAddition addAssigneeAsCC(ChangeResource rsrc, String assignee)
+  private ReviewerModification addAssigneeAsCC(ChangeResource rsrc, String assignee)
       throws IOException, PermissionBackendException, ConfigInvalidException {
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
     reviewerInput.confirmed = true;
     reviewerInput.notify = NotifyHandling.NONE;
-    return reviewerAdder.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
+    return reviewerModifier.prepare(rsrc.getNotes(), rsrc.getUser(), reviewerInput, false);
   }
 
   @Override
diff --git a/java/com/google/gerrit/server/restapi/change/PutDescription.java b/java/com/google/gerrit/server/restapi/change/PutDescription.java
index f442a42..7c54074 100644
--- a/java/com/google/gerrit/server/restapi/change/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/change/PutDescription.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.restapi.change;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.common.DescriptionInput;
 import com.google.gerrit.extensions.restapi.Response;
@@ -102,10 +101,7 @@
             String.format(
                 "Description of patch set %d changed to \"%s\"", psId.get(), newDescription);
       }
-      ChangeMessage cmsg =
-          ChangeMessagesUtil.newMessage(
-              psId, ctx.getUser(), ctx.getWhen(), summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
-      cmUtil.addChangeMessage(update, cmsg);
+      cmUtil.setChangeMessage(update, summary, ChangeMessagesUtil.TAG_SET_DESCRIPTION);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/QueryChanges.java b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
index cbbaa52..2c15bc9 100644
--- a/java/com/google/gerrit/server/restapi/change/QueryChanges.java
+++ b/java/com/google/gerrit/server/restapi/change/QueryChanges.java
@@ -83,8 +83,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListChangesOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListChangesOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/change/Rebase.java b/java/com/google/gerrit/server/restapi/change/Rebase.java
index cfdf04d..2077fb8 100644
--- a/java/com/google/gerrit/server/restapi/change/Rebase.java
+++ b/java/com/google/gerrit/server/restapi/change/Rebase.java
@@ -18,11 +18,9 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 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.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -66,8 +64,6 @@
 @Singleton
 public class Rebase
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
@@ -215,7 +211,7 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
+  public UiAction.Description getDescription(RevisionResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Rebase")
@@ -226,27 +222,13 @@
     if (!(change.isNew() && rsrc.isCurrent())) {
       return description;
     }
-
-    try {
-      if (!projectCache
-          .get(rsrc.getProject())
-          .orElseThrow(illegalState(rsrc.getProject()))
-          .statePermitsWrite()) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache
+        .get(rsrc.getProject())
+        .orElseThrow(illegalState(rsrc.getProject()))
+        .statePermitsWrite()) {
       return description;
     }
-
-    try {
-      if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (patchSetUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
 
@@ -256,14 +238,6 @@
       if (hasOneParent(rw, rsrc.getPatchSet())) {
         enabled = rebaseUtil.canRebase(rsrc.getPatchSet(), change.getDest(), repo, rw);
       }
-    } catch (Exception e) {
-      // Be generous here with the exceptions that we log and swallow. RebaseUtil#canRebase uses the
-      // change index and this UI action is on the critical path of rendering a change details page.
-      // If the index is broken, we log and disable the UI action, but still show the page to the
-      // user.
-      logger.atSevere().withCause(e).log(
-          "Failed to check if patch set can be rebased: %s", rsrc.getPatchSet());
-      return description;
     }
 
     if (rsrc.permissions().testOrFalse(ChangePermission.REBASE)) {
diff --git a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
index a1bd678..53d0f18 100644
--- a/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
+++ b/java/com/google/gerrit/server/restapi/change/ReplyAttentionSetUpdates.java
@@ -19,7 +19,9 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.common.collect.Sets.SetView;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.HumanComment;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
@@ -27,11 +29,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.AddToAttentionSetOp;
 import com.google.gerrit.server.change.AttentionSetUnchangedOp;
 import com.google.gerrit.server.change.CommentThread;
@@ -60,6 +62,7 @@
  * This class is used to update the attention set when performing a review or replying on a change.
  */
 public class ReplyAttentionSetUpdates {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final PermissionBackend permissionBackend;
   private final AddToAttentionSetOp.Factory addToAttentionSetOpFactory;
@@ -123,7 +126,9 @@
       bu.addOp(changeNotes.getChangeId(), new AttentionSetUnchangedOp());
       return;
     }
-    if (serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
+    boolean isReadyForReview = isReadyForReview(changeNotes, input);
+
+    if (isReadyForReview && serviceUserClassifier.isServiceUser(currentUser.getAccountId())) {
       botsWithNegativeLabelsAddOwnerAndUploader(bu, changeNotes, input);
       return;
     }
@@ -131,7 +136,7 @@
     processRules(
         bu,
         changeNotes,
-        isReadyForReview(changeNotes, input),
+        isReadyForReview,
         currentUser,
         getAllNewComments(changeNotes, input, currentUser));
   }
@@ -314,11 +319,15 @@
     AttentionSetUtil.validateInput(add);
     try {
       Account.Id attentionUserId =
-          getAccountIdAndValidateUser(changeNotes, add.user, accountsChangedInCommit);
+          getAccountIdAndValidateUser(
+              changeNotes, add.user, accountsChangedInCommit, AttentionSetUpdate.Operation.ADD);
       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.
+    } catch (AuthException ex) {
+      // adding users without permission to the attention set should fail silently.
+      logger.atFine().log(ex.getMessage());
     }
   }
 
@@ -332,17 +341,25 @@
     AttentionSetUtil.validateInput(remove);
     try {
       Account.Id attentionUserId =
-          getAccountIdAndValidateUser(changeNotes, remove.user, accountsChangedInCommit);
+          getAccountIdAndValidateUser(
+              changeNotes,
+              remove.user,
+              accountsChangedInCommit,
+              AttentionSetUpdate.Operation.REMOVE);
       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.
+    } catch (AuthException ex) {
+      // this should never happen since removing users with permissions should work.
+      logger.atSevere().log(ex.getMessage());
     }
   }
 
-  private Account.Id getAccountId(ChangeNotes changeNotes, String user)
+  private Account.Id getAccountId(
+      ChangeNotes changeNotes, String user, AttentionSetUpdate.Operation operation)
       throws ConfigInvalidException, IOException, UnprocessableEntityException,
-          PermissionBackendException {
+          PermissionBackendException, AuthException {
     Account.Id attentionUserId = accountResolver.resolve(user).asUnique().account().id();
     try {
       permissionBackend
@@ -350,22 +367,29 @@
           .change(changeNotes)
           .check(ChangePermission.READ);
     } catch (AuthException e) {
+      // If the change is private, it is okay to add the user to the attention set since that
+      // person will be granted visibility when a reviewer.
       if (!changeNotes.getChange().isPrivate()) {
-        // If the change is private, it is okay to add the user to the attention set since that
-        // person will be granted visibility when a reviewer.
-        throw new UnprocessableEntityException(
-            "Can't add to attention set: Read not permitted for " + attentionUserId, e);
+
+        // Removing users without access is allowed, adding is not allowed
+        if (operation == AttentionSetUpdate.Operation.ADD) {
+          throw new AuthException(
+              "Can't modify attention set: Read not permitted for " + attentionUserId, e);
+        }
       }
     }
     return attentionUserId;
   }
 
   private Account.Id getAccountIdAndValidateUser(
-      ChangeNotes changeNotes, String user, Set<Account.Id> accountsChangedInCommit)
+      ChangeNotes changeNotes,
+      String user,
+      Set<Account.Id> accountsChangedInCommit,
+      AttentionSetUpdate.Operation operation)
       throws ConfigInvalidException, IOException, PermissionBackendException,
-          UnprocessableEntityException, BadRequestException {
+          UnprocessableEntityException, BadRequestException, AuthException {
     try {
-      Account.Id attentionUserId = getAccountId(changeNotes, user);
+      Account.Id attentionUserId = getAccountId(changeNotes, user, operation);
       if (accountsChangedInCommit.contains(attentionUserId)) {
         throw new BadRequestException(
             String.format(
diff --git a/java/com/google/gerrit/server/restapi/change/Restore.java b/java/com/google/gerrit/server/restapi/change/Restore.java
index 7faf8e0..b2d1d3a 100644
--- a/java/com/google/gerrit/server/restapi/change/Restore.java
+++ b/java/com/google/gerrit/server/restapi/change/Restore.java
@@ -20,9 +20,7 @@
 import com.google.common.flogger.FluentLogger;
 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.exceptions.StorageException;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -47,7 +45,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.Inject;
@@ -113,7 +111,7 @@
 
     private Change change;
     private PatchSet patchSet;
-    private ChangeMessage message;
+    private String mailMessage;
 
     private Op(RestoreInput input) {
       this.input = input;
@@ -132,28 +130,27 @@
       change.setLastUpdatedOn(ctx.getWhen());
       update.setStatus(change.getStatus());
 
-      message = newMessage(ctx);
-      cmUtil.addChangeMessage(update, message);
+      mailMessage = cmUtil.setChangeMessage(ctx, commentMessage(), ChangeMessagesUtil.TAG_RESTORE);
       return true;
     }
 
-    private ChangeMessage newMessage(ChangeContext ctx) {
+    private String commentMessage() {
       StringBuilder msg = new StringBuilder();
       msg.append("Restored");
       if (!Strings.nullToEmpty(input.message).trim().isEmpty()) {
         msg.append("\n\n");
         msg.append(input.message.trim());
       }
-      return ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_RESTORE);
+      return msg.toString();
     }
 
     @Override
-    public void postUpdate(Context ctx) {
+    public void postUpdate(PostUpdateContext ctx) {
       try {
         ReplyToChangeSender emailSender =
             restoredSenderFactory.create(ctx.getProject(), change.getId());
         emailSender.setFrom(ctx.getAccountId());
-        emailSender.setChangeMessage(message.getMessage(), ctx.getWhen());
+        emailSender.setChangeMessage(mailMessage, ctx.getWhen());
         emailSender.setMessageId(
             messageIdGenerator.fromChangeUpdate(ctx.getRepoView(), change.currentPatchSetId()));
         emailSender.send();
@@ -161,12 +158,16 @@
         logger.atSevere().withCause(e).log("Cannot email update for change %s", change.getId());
       }
       changeRestored.fire(
-          change, patchSet, ctx.getAccount(), Strings.emptyToNull(input.message), ctx.getWhen());
+          ctx.getChangeData(change),
+          patchSet,
+          ctx.getAccount(),
+          Strings.emptyToNull(input.message),
+          ctx.getWhen());
     }
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource rsrc) {
+  public UiAction.Description getDescription(ChangeResource rsrc) throws IOException {
     UiAction.Description description =
         new UiAction.Description()
             .setLabel("Restore")
@@ -177,27 +178,12 @@
     if (!change.isAbandoned()) {
       return description;
     }
-
-    try {
-      if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
+    if (!projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsRead).orElse(false)) {
       return description;
     }
-
-    try {
-      if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
-        return description;
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if the current patch set of change %s is locked", change.getId());
+    if (psUtil.isPatchSetLocked(rsrc.getNotes())) {
       return description;
     }
-
     boolean visible = rsrc.permissions().testOrFalse(ChangePermission.RESTORE);
     return description.setVisible(visible);
   }
diff --git a/java/com/google/gerrit/server/restapi/change/Revert.java b/java/com/google/gerrit/server/restapi/change/Revert.java
index fd4a13e..8d48c88 100644
--- a/java/com/google/gerrit/server/restapi/change/Revert.java
+++ b/java/com/google/gerrit/server/restapi/change/Revert.java
@@ -19,10 +19,8 @@
 import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.flogger.FluentLogger;
 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.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -55,8 +53,6 @@
 @Singleton
 public class Revert
     implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
   private final PermissionBackend permissionBackend;
   private final PatchSetUtil psUtil;
   private final ChangeJson.Factory json;
@@ -114,14 +110,8 @@
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
     Change change = rsrc.getChange();
-    boolean projectStatePermitsWrite = false;
-    try {
-      projectStatePermitsWrite =
-          projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log(
-          "Failed to check if project state permits write: %s", rsrc.getProject());
-    }
+    boolean projectStatePermitsWrite =
+        projectCache.get(rsrc.getProject()).map(ProjectState::statePermitsWrite).orElse(false);
     return new UiAction.Description()
         .setLabel("Revert")
         .setTitle("Revert the change")
diff --git a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
index cb91faa..8bde6e7 100644
--- a/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
+++ b/java/com/google/gerrit/server/restapi/change/RevertSubmission.java
@@ -28,8 +28,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
-import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
@@ -75,7 +73,7 @@
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -260,7 +258,7 @@
       Project.NameKey project = projectAndBranch.project();
       cherryPickInput.destination = projectAndBranch.branch();
       if (revertInput.workInProgress) {
-        cherryPickInput.notify = NotifyHandling.OWNER;
+        cherryPickInput.notify = firstNonNull(cherryPickInput.notify, NotifyHandling.OWNER);
       }
       Collection<ChangeData> changesInProjectAndBranch =
           changesPerProjectAndBranch.get(projectAndBranch);
@@ -614,10 +612,10 @@
     }
 
     @Override
-    public void postUpdate(Context ctx) throws Exception {
+    public void postUpdate(PostUpdateContext ctx) throws Exception {
       changeReverted.fire(
-          change,
-          changeNotesFactory.createChecked(ctx.getProject(), revertChangeId).getChange(),
+          ctx.getChangeData(change),
+          ctx.getChangeData(changeNotesFactory.createChecked(ctx.getProject(), revertChangeId)),
           ctx.getWhen());
       try {
         RevertedSender emailSender = revertedSenderFactory.create(ctx.getProject(), change.getId());
@@ -647,14 +645,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws Exception {
-      Change change = ctx.getChange();
-      PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx,
-              "Created a revert of this change as I" + computedChangeId.getName(),
-              ChangeMessagesUtil.TAG_REVERT);
-      cmUtil.addChangeMessage(ctx.getUpdate(patchSetId), changeMessage);
+      cmUtil.setChangeMessage(
+          ctx,
+          "Created a revert of this change as I" + computedChangeId.getName(),
+          ChangeMessagesUtil.TAG_REVERT);
       return true;
     }
   }
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
index d80ab696..7f7c1ad 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewerRecommender.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.index.query.QueryParseException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.FanOutExecutor;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.change.SuggestedReviewer;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -216,7 +216,7 @@
 
       for (ChangeData cd : result) {
         for (Account.Id reviewer : cd.reviewers().all()) {
-          if (Strings.isNullOrEmpty(query) || accountMatchesQuery(reviewer, query)) {
+          if (accountMatchesQuery(reviewer, query)) {
             suggestions
                 .computeIfAbsent(reviewer, (ignored) -> new MutableDouble(0))
                 .add(baseWeight);
@@ -234,7 +234,8 @@
   private boolean accountMatchesQuery(Account.Id id, String query) {
     Optional<Account> account = accountCache.get(id).map(AccountState::account);
     if (account.isPresent() && account.get().isActive()) {
-      if ((account.get().fullName() != null && account.get().fullName().startsWith(query))
+      if (Strings.isNullOrEmpty(query)
+          || (account.get().fullName() != null && account.get().fullName().startsWith(query))
           || (account.get().preferredEmail() != null
               && account.get().preferredEmail().startsWith(query))) {
         return true;
diff --git a/java/com/google/gerrit/server/restapi/change/Reviewers.java b/java/com/google/gerrit/server/restapi/change/Reviewers.java
index 4bfcf14..9da7c88 100644
--- a/java/com/google/gerrit/server/restapi/change/Reviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/Reviewers.java
@@ -22,8 +22,8 @@
 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.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.inject.Inject;
diff --git a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
index 38be27e..d6c4c51 100644
--- a/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
+++ b/java/com/google/gerrit/server/restapi/change/ReviewersUtil.java
@@ -52,7 +52,7 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.account.ServiceUserClassifier;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
@@ -415,7 +415,7 @@
     int maxAllowedWithoutConfirmation = suggestReviewers.getMaxAllowedWithoutConfirmation();
     logger.atFine().log("maxAllowedWithoutConfirmation: " + maxAllowedWithoutConfirmation);
 
-    if (!ReviewerAdder.isLegalReviewerGroup(group.getUUID())) {
+    if (!ReviewerModifier.isLegalReviewerGroup(group.getUUID())) {
       logger.atFine().log("Ignore group %s that is not legal as reviewer", group.getUUID());
       return result;
     }
diff --git a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
index 2651ab5..97383cda 100644
--- a/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/RevisionReviewers.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.restapi.account.AccountsCollection;
diff --git a/java/com/google/gerrit/server/restapi/change/Submit.java b/java/com/google/gerrit/server/restapi/change/Submit.java
index ba0720a..876c92c 100644
--- a/java/com/google/gerrit/server/restapi/change/Submit.java
+++ b/java/com/google/gerrit/server/restapi/change/Submit.java
@@ -102,14 +102,6 @@
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
 
-  public static class Output {
-    transient Change change;
-
-    private Output(Change c) {
-      change = c;
-    }
-  }
-
   private final GitRepositoryManager repoManager;
   private final PermissionBackend permissionBackend;
   private final Provider<MergeOp> mergeOpProvider;
@@ -125,6 +117,7 @@
   private final Provider<InternalChangeQuery> queryProvider;
   private final PatchSetUtil psUtil;
   private final ProjectCache projectCache;
+  private final ChangeJson.Factory json;
 
   @Inject
   Submit(
@@ -136,7 +129,8 @@
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ChangeJson.Factory json) {
     this.repoManager = repoManager;
     this.permissionBackend = permissionBackend;
     this.mergeOpProvider = mergeOpProvider;
@@ -170,10 +164,11 @@
     this.queryProvider = queryProvider;
     this.psUtil = psUtil;
     this.projectCache = projectCache;
+    this.json = json;
   }
 
   @Override
-  public Response<Output> apply(RevisionResource rsrc, SubmitInput input)
+  public Response<ChangeInfo> apply(RevisionResource rsrc, SubmitInput input)
       throws RestApiException, RepositoryNotFoundException, IOException, PermissionBackendException,
           UpdateException, ConfigInvalidException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
@@ -189,12 +184,11 @@
         .orElseThrow(illegalState(rsrc.getProject()))
         .checkStatePermitsWrite();
 
-    return mergeChange(rsrc, submitter, input);
+    return Response.ok(json.noOptions().format(mergeChange(rsrc, submitter, input)));
   }
 
   @UsedAt(UsedAt.Project.GOOGLE)
-  public Response<Output> mergeChange(
-      RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
       throws RestApiException, IOException, UpdateException, ConfigInvalidException,
           PermissionBackendException {
     Change change = rsrc.getChange();
@@ -215,7 +209,7 @@
 
       updatedChange = op.merge(change, submitter, true, input, false);
       if (updatedChange.isMerged()) {
-        return Response.ok(new Output(updatedChange));
+        return updatedChange;
       }
 
       throw new IllegalStateException(
@@ -290,22 +284,17 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
+  public UiAction.Description getDescription(RevisionResource resource)
+      throws IOException, PermissionBackendException {
     Change change = resource.getChange();
     if (!change.isNew() || !resource.isCurrent()) {
       return null; // submit not visible
     }
-
-    try {
-      if (!projectCache
-          .get(resource.getProject())
-          .map(ProjectState::statePermitsWrite)
-          .orElse(false)) {
-        return null; // submit not visible
-      }
-    } catch (StorageException e) {
-      logger.atSevere().withCause(e).log("Error checking if change is submittable");
-      throw new StorageException("Could not determine problems for the change", e);
+    if (!projectCache
+        .get(resource.getProject())
+        .map(ProjectState::statePermitsWrite)
+        .orElse(false)) {
+      return null; // submit not visible
     }
 
     ChangeData cd = resource.getChangeResource().getChangeData();
@@ -315,13 +304,7 @@
       return null; // submit not visible
     }
 
-    ChangeSet cs;
-    try {
-      cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
-    } catch (IOException | PermissionBackendException e) {
-      throw new StorageException("Could not determine complete set of changes to be submitted", e);
-    }
-
+    ChangeSet cs = mergeSuperSet.get().completeChangeSet(cd.change(), resource.getUser());
     String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
@@ -471,13 +454,11 @@
 
   public static class CurrentRevision implements RestModifyView<ChangeResource, SubmitInput> {
     private final Submit submit;
-    private final ChangeJson.Factory json;
     private final PatchSetUtil psUtil;
 
     @Inject
-    CurrentRevision(Submit submit, ChangeJson.Factory json, PatchSetUtil psUtil) {
+    CurrentRevision(Submit submit, PatchSetUtil psUtil) {
       this.submit = submit;
-      this.json = json;
       this.psUtil = psUtil;
     }
 
@@ -488,8 +469,7 @@
         throw new ResourceConflictException("current revision is missing");
       }
 
-      Response<Output> response = submit.apply(new RevisionResource(rsrc, ps), input);
-      return Response.ok(json.noOptions().format(response.value().change));
+      return submit.apply(new RevisionResource(rsrc, ps), input);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
index 71ff493..74f5290 100644
--- a/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
+++ b/java/com/google/gerrit/server/restapi/change/SuggestReviewers.java
@@ -18,7 +18,7 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.common.AccountVisibility;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.server.config.ConfigKey;
 import com.google.gerrit.server.config.GerritConfigListener;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -99,12 +99,13 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed", ReviewerAdder.DEFAULT_MAX_REVIEWERS);
+    this.maxAllowed =
+        cfg.getInt("addreviewer", "maxAllowed", ReviewerModifier.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation =
         cfg.getInt(
             "addreviewer",
             "maxWithoutConfirmation",
-            ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
+            ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
 
     logger.atFine().log("AccountVisibility: %s", av.name());
 
diff --git a/java/com/google/gerrit/server/restapi/change/Votes.java b/java/com/google/gerrit/server/restapi/change/Votes.java
index d899002..0f4b960 100644
--- a/java/com/google/gerrit/server/restapi/change/Votes.java
+++ b/java/com/google/gerrit/server/restapi/change/Votes.java
@@ -24,7 +24,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.inject.Inject;
@@ -83,9 +83,7 @@
           approvalsUtil.byPatchSetUser(
               rsrc.getChangeResource().getNotes(),
               rsrc.getChange().currentPatchSetId(),
-              rsrc.getReviewerUser().getAccountId(),
-              null,
-              null);
+              rsrc.getReviewerUser().getAccountId());
       for (PatchSetApproval psa : byPatchSetUser) {
         votes.put(psa.label(), psa.value());
       }
diff --git a/java/com/google/gerrit/server/restapi/config/Module.java b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
similarity index 97%
rename from java/com/google/gerrit/server/restapi/config/Module.java
rename to java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
index 78a7f48..427ff84 100644
--- a/java/com/google/gerrit/server/restapi/config/Module.java
+++ b/java/com/google/gerrit/server/restapi/config/ConfigRestApiModule.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.config.CapabilityResource;
 import com.google.gerrit.server.config.TopMenuResource;
 
-public class Module extends RestApiModule {
+public class ConfigRestApiModule extends RestApiModule {
   @Override
   protected void configure() {
     DynamicMap.mapOf(binder(), CapabilityResource.CAPABILITY_KIND);
diff --git a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
index c3dd1b5..ae11d71 100644
--- a/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
+++ b/java/com/google/gerrit/server/restapi/config/GetServerInfo.java
@@ -68,7 +68,6 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
-import java.util.Optional;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.lib.Config;
 
@@ -222,13 +221,6 @@
     info.showAssigneeInChangesTable =
         toBoolean(
             config.getBoolean("change", "showAssigneeInChangesTable", false) && hasAssigneeInIndex);
-    info.replyTooltip =
-        Optional.ofNullable(config.getString("change", null, "replyTooltip"))
-                .orElse("Reply and score")
-            + " (Shortcut: a)";
-    info.replyLabel =
-        Optional.ofNullable(config.getString("change", null, "replyLabel")).orElse("Reply")
-            + "\u2026";
     info.updateDelay =
         (int) ConfigUtil.getTimeUnit(config, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = toBoolean(MergeSuperSet.wholeTopicEnabled(config));
diff --git a/java/com/google/gerrit/server/restapi/group/AddMembers.java b/java/com/google/gerrit/server/restapi/group/AddMembers.java
index 700a2ab..4cb2f47 100644
--- a/java/com/google/gerrit/server/restapi/group/AddMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/AddMembers.java
@@ -46,8 +46,8 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.inject.Inject;
@@ -94,6 +94,7 @@
   private final AccountCache accountCache;
   private final AccountLoader.Factory infoFactory;
   private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final AuthRequest.Factory authRequestFactory;
 
   @Inject
   AddMembers(
@@ -102,13 +103,15 @@
       AccountResolver accountResolver,
       AccountCache accountCache,
       AccountLoader.Factory infoFactory,
-      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      AuthRequest.Factory authRequestFactory) {
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
     this.accountResolver = accountResolver;
     this.accountCache = accountCache;
     this.infoFactory = infoFactory;
     this.groupsUpdateProvider = groupsUpdateProvider;
+    this.authRequestFactory = authRequestFactory;
   }
 
   @Override
@@ -177,11 +180,11 @@
 
   public void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> newMemberIds)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.union(memberIds, newMemberIds))
             .build();
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 
   private Optional<Account> createAccountByLdap(String user) throws IOException {
@@ -190,7 +193,7 @@
     }
 
     try {
-      AuthRequest req = AuthRequest.forUser(user);
+      AuthRequest req = authRequestFactory.createForUser(user);
       req.setSkipAuthentication(true);
       return accountCache
           .get(accountManager.authenticate(req).getAccountId())
diff --git a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
index 23fa73d..df854ecd2c 100644
--- a/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/AddSubgroups.java
@@ -35,8 +35,8 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
 import com.google.inject.Inject;
@@ -124,11 +124,11 @@
   private void addSubgroups(
       AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> newSubgroupUuids)
       throws NoSuchGroupException, IOException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(subgroupUuids -> Sets.union(subgroupUuids, newSubgroupUuids))
             .build();
-    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupDelta);
   }
 
   @Singleton
diff --git a/java/com/google/gerrit/server/restapi/group/CreateGroup.java b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
index 0ec63ba..ee86010 100644
--- a/java/com/google/gerrit/server/restapi/group/CreateGroup.java
+++ b/java/com/google/gerrit/server/restapi/group/CreateGroup.java
@@ -48,9 +48,9 @@
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.plugincontext.PluginSetContext;
@@ -219,19 +219,19 @@
             .setNameKey(createGroupArgs.getGroup())
             .setId(groupId)
             .build();
-    InternalGroupUpdate.Builder groupUpdateBuilder =
-        InternalGroupUpdate.builder().setVisibleToAll(createGroupArgs.visibleToAll);
+    GroupDelta.Builder groupDeltaBuilder =
+        GroupDelta.builder().setVisibleToAll(createGroupArgs.visibleToAll);
     if (createGroupArgs.ownerGroupUuid != null) {
       Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupUuid);
-      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(groupUpdateBuilder::setOwnerGroupUUID);
+      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(groupDeltaBuilder::setOwnerGroupUUID);
     }
     if (createGroupArgs.groupDescription != null) {
-      groupUpdateBuilder.setDescription(createGroupArgs.groupDescription);
+      groupDeltaBuilder.setDescription(createGroupArgs.groupDescription);
     }
-    groupUpdateBuilder.setMemberModification(
+    groupDeltaBuilder.setMemberModification(
         members -> ImmutableSet.copyOf(createGroupArgs.initialMembers));
     try {
-      return groupsUpdateProvider.get().createGroup(groupCreation, groupUpdateBuilder.build());
+      return groupsUpdateProvider.get().createGroup(groupCreation, groupDeltaBuilder.build());
     } catch (DuplicateKeyException e) {
       throw new ResourceConflictException(
           "group '" + createGroupArgs.getGroupName() + "' already exists", e);
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
index fa52a79..2386fce 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteMembers.java
@@ -31,8 +31,8 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.MemberResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.restapi.group.AddMembers.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -86,11 +86,11 @@
 
   private void removeGroupMembers(AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
       throws IOException, NoSuchGroupException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.difference(memberIds, accountIds))
             .build();
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 
   @Singleton
diff --git a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
index fe67635..bc63035 100644
--- a/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
+++ b/java/com/google/gerrit/server/restapi/group/DeleteSubgroups.java
@@ -30,8 +30,8 @@
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.SubgroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.restapi.group.AddSubgroups.Input;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -86,12 +86,12 @@
   private void removeSubgroups(
       AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> removedSubgroupUuids)
       throws NoSuchGroupException, IOException, ConfigInvalidException {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroupUuids -> Sets.difference(subgroupUuids, removedSubgroupUuids))
             .build();
-    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupUpdate);
+    groupsUpdateProvider.get().updateGroup(parentGroupUuid, groupDelta);
   }
 
   @Singleton
diff --git a/java/com/google/gerrit/server/restapi/group/Module.java b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
similarity index 98%
rename from java/com/google/gerrit/server/restapi/group/Module.java
rename to java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
index 45ac411..8024862 100644
--- a/java/com/google/gerrit/server/restapi/group/Module.java
+++ b/java/com/google/gerrit/server/restapi/group/GroupRestApiModule.java
@@ -32,7 +32,7 @@
 import com.google.gerrit.server.restapi.group.DeleteSubgroups.DeleteSubgroup;
 import com.google.inject.Provides;
 
-public class Module extends RestApiModule {
+public class GroupRestApiModule extends RestApiModule {
 
   @Override
   protected void configure() {
diff --git a/java/com/google/gerrit/server/restapi/group/ListGroups.java b/java/com/google/gerrit/server/restapi/group/ListGroups.java
index 96402be..854f091 100644
--- a/java/com/google/gerrit/server/restapi/group/ListGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/ListGroups.java
@@ -186,8 +186,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListGroupsOption.class, Integer.parseInt(hex, 16)));
+  void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListGroupsOption.class, hex));
   }
 
   @Option(
diff --git a/java/com/google/gerrit/server/restapi/group/PutDescription.java b/java/com/google/gerrit/server/restapi/group/PutDescription.java
index 942e680..0c3ff06 100644
--- a/java/com/google/gerrit/server/restapi/group/PutDescription.java
+++ b/java/com/google/gerrit/server/restapi/group/PutDescription.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -61,10 +61,9 @@
     String newDescription = Strings.nullToEmpty(input.description);
     if (!Objects.equals(currentDescription, newDescription)) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      InternalGroupUpdate groupUpdate =
-          InternalGroupUpdate.builder().setDescription(newDescription).build();
+      GroupDelta groupDelta = GroupDelta.builder().setDescription(newDescription).build();
       try {
-        groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+        groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
       } catch (NoSuchGroupException e) {
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/group/PutName.java b/java/com/google/gerrit/server/restapi/group/PutName.java
index acdae33..5246ade 100644
--- a/java/com/google/gerrit/server/restapi/group/PutName.java
+++ b/java/com/google/gerrit/server/restapi/group/PutName.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -74,10 +74,9 @@
       throws ResourceConflictException, ResourceNotFoundException, IOException,
           ConfigInvalidException {
     AccountGroup.UUID groupUuid = group.getGroupUUID();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey(newName)).build();
+    GroupDelta groupDelta = GroupDelta.builder().setName(AccountGroup.nameKey(newName)).build();
     try {
-      groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+      groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
     } catch (NoSuchGroupException e) {
       throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
     } catch (DuplicateKeyException e) {
diff --git a/java/com/google/gerrit/server/restapi/group/PutOptions.java b/java/com/google/gerrit/server/restapi/group/PutOptions.java
index 748861e..926203f 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOptions.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOptions.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -61,10 +61,9 @@
 
     if (internalGroup.isVisibleToAll() != input.visibleToAll) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      InternalGroupUpdate groupUpdate =
-          InternalGroupUpdate.builder().setVisibleToAll(input.visibleToAll).build();
+      GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(input.visibleToAll).build();
       try {
-        groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+        groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
       } catch (NoSuchGroupException e) {
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/group/PutOwner.java b/java/com/google/gerrit/server/restapi/group/PutOwner.java
index 96ce9e4..45acd68 100644
--- a/java/com/google/gerrit/server/restapi/group/PutOwner.java
+++ b/java/com/google/gerrit/server/restapi/group/PutOwner.java
@@ -29,8 +29,8 @@
 import com.google.gerrit.server.UserInitiated;
 import com.google.gerrit.server.group.GroupResolver;
 import com.google.gerrit.server.group.GroupResource;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -72,10 +72,9 @@
     GroupDescription.Basic owner = groupResolver.parse(input.owner);
     if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
       AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
-      InternalGroupUpdate groupUpdate =
-          InternalGroupUpdate.builder().setOwnerGroupUUID(owner.getGroupUUID()).build();
+      GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(owner.getGroupUUID()).build();
       try {
-        groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+        groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
       } catch (NoSuchGroupException e) {
         throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid), e);
       }
diff --git a/java/com/google/gerrit/server/restapi/group/QueryGroups.java b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
index 26e8459..befccfe 100644
--- a/java/com/google/gerrit/server/restapi/group/QueryGroups.java
+++ b/java/com/google/gerrit/server/restapi/group/QueryGroups.java
@@ -80,8 +80,8 @@
   }
 
   @Option(name = "-O", usage = "Output option flags, in hex")
-  public void setOptionFlagsHex(String hex) {
-    options.addAll(ListOption.fromBits(ListGroupsOption.class, Integer.parseInt(hex, 16)));
+  public void setOptionFlagsHex(String hex) throws BadRequestException {
+    options.addAll(ListOption.fromHexString(ListGroupsOption.class, hex));
   }
 
   @Inject
diff --git a/java/com/google/gerrit/server/restapi/project/CheckAccess.java b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
index 37616cd..5c2f932 100644
--- a/java/com/google/gerrit/server/restapi/project/CheckAccess.java
+++ b/java/com/google/gerrit/server/restapi/project/CheckAccess.java
@@ -95,7 +95,6 @@
       } catch (AuthException e) {
         return Response.ok(
             createInfo(
-                traceContext,
                 HttpServletResponse.SC_FORBIDDEN,
                 String.format("user %s cannot see project %s", match, rsrc.getName())));
       }
@@ -126,7 +125,6 @@
         } catch (AuthException e) {
           return Response.ok(
               createInfo(
-                  traceContext,
                   HttpServletResponse.SC_FORBIDDEN,
                   String.format(
                       "user %s lacks permission %s for %s in project %s",
@@ -141,15 +139,15 @@
           }
         }
       }
-      return Response.ok(createInfo(traceContext, HttpServletResponse.SC_OK, message));
+      return Response.ok(createInfo(HttpServletResponse.SC_OK, message));
     }
   }
 
-  private AccessCheckInfo createInfo(TraceContext traceContext, int statusCode, String message) {
+  private AccessCheckInfo createInfo(int statusCode, String message) {
     AccessCheckInfo info = new AccessCheckInfo();
     info.status = statusCode;
     info.message = message;
-    info.debugLogs = traceContext.getAclLogRecords();
+    info.debugLogs = TraceContext.getAclLogRecords();
     if (info.debugLogs.isEmpty()) {
       info.debugLogs =
           ImmutableList.of("Found no rules that apply, so defaulting to no permission");
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
index 033463c..09951b2 100644
--- a/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/CommitsCollection.java
@@ -34,8 +34,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.Reachable;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.CommitPredicate;
-import com.google.gerrit.server.query.change.ProjectPredicate;
+import com.google.gerrit.server.query.change.ChangePredicates;
 import com.google.gerrit.server.update.RetryHelper;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -117,14 +116,14 @@
   }
 
   /**
-   * @return true if {@code commit} is visible to the caller and {@code commit} is reachable from
-   *     the given branch.
+   * Returns true if {@code commit} is visible to the caller and {@code commit} is reachable from
+   * the given branch.
    */
   public boolean canRead(ProjectState state, Repository repo, RevCommit commit, Ref ref) {
     return reachable.fromRefs(state.getNameKey(), repo, commit, ImmutableList.of(ref));
   }
 
-  /** @return true if {@code commit} is visible to the caller. */
+  /** Returns true if {@code commit} is visible to the caller. */
   public boolean canRead(ProjectState state, Repository repo, RevCommit commit) throws IOException {
     Project.NameKey project = state.getNameKey();
     if (indexes.getSearchIndex() == null) {
@@ -149,10 +148,10 @@
     // branches to check, by seeing if its parents were associated to changes.
     Predicate<ChangeData> pred =
         Predicate.and(
-            new ProjectPredicate(project.get()),
+            ChangePredicates.project(project),
             Predicate.or(
                 Arrays.stream(commit.getParents())
-                    .map(parent -> new CommitPredicate(parent.getId().getName()))
+                    .map(parent -> ChangePredicates.commitPrefix(parent.getId().getName()))
                     .collect(toImmutableList())));
     changes =
         retryHelper
diff --git a/java/com/google/gerrit/server/restapi/project/CommitsIncludedInRefs.java b/java/com/google/gerrit/server/restapi/project/CommitsIncludedInRefs.java
new file mode 100644
index 0000000..05ec28e
--- /dev/null
+++ b/java/com/google/gerrit/server/restapi/project/CommitsIncludedInRefs.java
@@ -0,0 +1,85 @@
+// 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.restapi.project;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.IncludedInRefs;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import org.kohsuke.args4j.Option;
+
+public class CommitsIncludedInRefs implements RestReadView<ProjectResource> {
+  protected final IncludedInRefs includedInRefs;
+
+  protected Set<String> commits = new HashSet<>();
+  protected Set<String> refs = new HashSet<>();
+
+  @Option(
+      name = "--commit",
+      aliases = {"-c"},
+      required = true,
+      metaVar = "COMMIT",
+      usage = "commit sha1")
+  protected void addCommit(String commit) {
+    commits.add(commit);
+  }
+
+  @Option(
+      name = "--ref",
+      aliases = {"-r"},
+      required = true,
+      metaVar = "REF",
+      usage = "full ref name")
+  protected void addRef(String ref) {
+    refs.add(ref);
+  }
+
+  public void addCommits(Collection<String> commits) {
+    this.commits.addAll(commits);
+  }
+
+  public void addRefs(Collection<String> refs) {
+    this.refs.addAll(refs);
+  }
+
+  @Inject
+  public CommitsIncludedInRefs(IncludedInRefs includedInRefs) {
+    this.includedInRefs = includedInRefs;
+  }
+
+  @Override
+  public Response<Map<String, Set<String>>> apply(ProjectResource resource)
+      throws ResourceConflictException, BadRequestException, IOException,
+          PermissionBackendException, ResourceNotFoundException, AuthException {
+    if (commits.isEmpty()) {
+      throw new BadRequestException("commit is required");
+    }
+    if (refs.isEmpty()) {
+      throw new BadRequestException("ref is required");
+    }
+    return Response.ok(includedInRefs.apply(resource.getNameKey(), commits, refs));
+  }
+}
diff --git a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
index eceab43..92038b0 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateAccessChange.java
@@ -30,7 +30,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
diff --git a/java/com/google/gerrit/server/restapi/project/CreateLabel.java b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
index 025ff50..e3f293a 100644
--- a/java/com/google/gerrit/server/restapi/project/CreateLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/CreateLabel.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestCollectionCreateView;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,6 +53,7 @@
   private final MetaDataUpdate.User updateFactory;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
   @Inject
   public CreateLabel(
@@ -58,12 +61,14 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.User updateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalQueryBuilder approvalQueryBuilder) {
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -166,6 +171,23 @@
       labelType.setCopyAnyScore(input.copyAnyScore);
     }
 
+    if (input.copyCondition != null) {
+      try {
+        approvalQueryBuilder.parse(input.copyCondition);
+      } catch (QueryParseException e) {
+        throw new BadRequestException(
+            "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
+            e);
+      }
+      if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+        throw new BadRequestException("can't set and unset copyCondition in the same request");
+      }
+      labelType.setCopyCondition(Strings.emptyToNull(input.copyCondition));
+    }
+    if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+      labelType.setCopyCondition(null);
+    }
+
     if (input.copyMinScore != null) {
       labelType.setCopyMinScore(input.copyMinScore);
     }
diff --git a/java/com/google/gerrit/server/restapi/project/DeleteRef.java b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
index 4e13ba9..60405a6 100644
--- a/java/com/google/gerrit/server/restapi/project/DeleteRef.java
+++ b/java/com/google/gerrit/server/restapi/project/DeleteRef.java
@@ -86,8 +86,6 @@
    *
    * @param projectState the {@code ProjectState} of the project containing the target ref.
    * @param ref the ref to be deleted.
-   * @throws IOException
-   * @throws ResourceConflictException
    */
   public void deleteSingleRef(ProjectState projectState, String ref)
       throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
@@ -100,8 +98,6 @@
    * @param projectState the {@code ProjectState} of the project containing the target ref.
    * @param ref the ref to be deleted.
    * @param prefix the prefix of the ref.
-   * @throws IOException
-   * @throws ResourceConflictException
    */
   public void deleteSingleRef(ProjectState projectState, String ref, @Nullable String prefix)
       throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
@@ -161,9 +157,6 @@
    * @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 to add to abbreviated refs, eg. "refs/heads/".
-   * @throws IOException
-   * @throws ResourceConflictException
-   * @throws PermissionBackendException
    */
   public void deleteMultipleRefs(
       ProjectState projectState, ImmutableSet<String> refsToDelete, String prefix)
diff --git a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
index 7bee2f2..6d054bd 100644
--- a/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/FilesInCommitCollection.java
@@ -77,6 +77,10 @@
   }
 
   public static final class ListFiles implements RestReadView<CommitResource> {
+    /**
+     * The 1-based parent number. If zero, the default base commit will be used, which is the only
+     * parent for commits having one parent or the auto-merge commit otherwise.
+     */
     @Option(name = "--parent", metaVar = "parent-number")
     int parentNum;
 
@@ -97,8 +101,7 @@
         throws ResourceConflictException, PatchListNotAvailableException {
       RevCommit commit = resource.getCommit();
       return Response.ok(
-          fileInfoJson.getFileInfoMap(
-              resource.getProjectState().getNameKey(), commit, parentNum - 1));
+          fileInfoJson.getFileInfoMap(resource.getProjectState().getNameKey(), commit, parentNum));
     }
   }
 }
diff --git a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
index abd47e9..7457eb7 100644
--- a/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
+++ b/java/com/google/gerrit/server/restapi/project/GarbageCollect.java
@@ -18,6 +18,7 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.common.data.GarbageCollectionResult.GcError;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -125,7 +126,7 @@
           GarbageCollectionResult result = runGC(project, input, progressWriter);
           String msg = "Garbage collection completed successfully.";
           if (result.hasErrors()) {
-            for (GarbageCollectionResult.Error e : result.getErrors()) {
+            for (GcError e : result.getErrors()) {
               switch (e.getType()) {
                 case REPOSITORY_NOT_FOUND:
                   msg = "Error: project \"" + e.getProjectName() + "\" not found.";
diff --git a/java/com/google/gerrit/server/restapi/project/IndexChanges.java b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
index 45a6616..6ad0005 100644
--- a/java/com/google/gerrit/server/restapi/project/IndexChanges.java
+++ b/java/com/google/gerrit/server/restapi/project/IndexChanges.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.git.MultiProgressMonitor.TaskKind;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -40,15 +41,18 @@
 @Singleton
 public class IndexChanges implements RestModifyView<ProjectResource, Input> {
 
+  private final MultiProgressMonitor.Factory multiProgressMonitorFactory;
   private final Provider<AllChangesIndexer> allChangesIndexerProvider;
   private final ChangeIndexer indexer;
   private final ListeningExecutorService executor;
 
   @Inject
   IndexChanges(
+      MultiProgressMonitor.Factory multiProgressMonitorFactory,
       Provider<AllChangesIndexer> allChangesIndexerProvider,
       ChangeIndexer indexer,
       @IndexExecutor(BATCH) ListeningExecutorService executor) {
+    this.multiProgressMonitorFactory = multiProgressMonitorFactory;
     this.allChangesIndexerProvider = allChangesIndexerProvider;
     this.indexer = indexer;
     this.executor = executor;
@@ -58,7 +62,8 @@
   public Response.Accepted apply(ProjectResource resource, Input input) {
     Project.NameKey project = resource.getNameKey();
     Task mpt =
-        new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
+        multiProgressMonitorFactory
+            .create(ByteStreams.nullOutputStream(), TaskKind.INDEXING, "Reindexing project")
             .beginSubTask("", MultiProgressMonitor.UNKNOWN);
     AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
     allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
diff --git a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
index 0f49e63..11d8b19 100644
--- a/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
+++ b/java/com/google/gerrit/server/restapi/project/LabelDefinitionInputParser.java
@@ -28,7 +28,6 @@
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Map.Entry;
 import java.util.Optional;
 import java.util.Set;
 
@@ -43,7 +42,7 @@
       throws BadRequestException {
     List<LabelValue> valueList = new ArrayList<>();
     Set<Short> allValues = new HashSet<>();
-    for (Entry<String, String> e : values.entrySet()) {
+    for (Map.Entry<String, String> e : values.entrySet()) {
       short value;
       try {
         value = Shorts.checkedCast(PermissionRule.parseInt(e.getKey().trim()));
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index c4ae33a..4d8005b 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -490,7 +490,7 @@
                 continue;
               }
 
-              List<Ref> refs = retrieveBranchRefs(e);
+              List<Ref> refs = retrieveBranchRefs(e, git);
               if (!hasValidRef(refs)) {
                 continue;
               }
@@ -578,17 +578,12 @@
     }
   }
 
-  private List<Ref> retrieveBranchRefs(ProjectState e) throws PermissionBackendException {
-    boolean canReadAllRefs = e.statePermitsRead();
-    if (canReadAllRefs) {
-      try {
-        permissionBackend.user(currentUser).project(e.getNameKey()).check(ProjectPermission.READ);
-      } catch (AuthException exp) {
-        canReadAllRefs = false;
-      }
+  private List<Ref> retrieveBranchRefs(ProjectState e, Repository git) {
+    if (!e.statePermitsRead()) {
+      return ImmutableList.of();
     }
 
-    return getBranchRefs(e.getNameKey(), canReadAllRefs);
+    return getBranchRefs(e.getNameKey(), git);
   }
 
   private void addParentProjectInfo(
@@ -708,15 +703,13 @@
     stdout.flush();
   }
 
-  private List<Ref> getBranchRefs(Project.NameKey projectName, boolean canReadAllRefs) {
+  private List<Ref> getBranchRefs(Project.NameKey projectName, Repository git) {
     Ref[] result = new Ref[showBranch.size()];
-    try (Repository git = repoManager.openRepository(projectName)) {
+    try {
       PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
       for (int i = 0; i < showBranch.size(); i++) {
         Ref ref = git.findRef(showBranch.get(i));
-        if (all && canReadAllRefs) {
-          result[i] = ref;
-        } else if (ref != null && ref.getObjectId() != null) {
+        if (ref != null && ref.getObjectId() != null) {
           try {
             perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
             result[i] = ref;
diff --git a/java/com/google/gerrit/server/restapi/project/PostLabels.java b/java/com/google/gerrit/server/restapi/project/PostLabels.java
index b72e10f..6cb912e 100644
--- a/java/com/google/gerrit/server/restapi/project/PostLabels.java
+++ b/java/com/google/gerrit/server/restapi/project/PostLabels.java
@@ -37,7 +37,7 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Map.Entry;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** REST endpoint that allows to add, update and delete label definitions in a batch. */
@@ -118,7 +118,7 @@
       }
 
       if (input.update != null && !input.update.isEmpty()) {
-        for (Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
+        for (Map.Entry<String, LabelDefinitionInput> e : input.update.entrySet()) {
           LabelType labelType = config.getLabelSections().get(e.getKey().trim());
           if (labelType == null) {
             throw new UnprocessableEntityException(String.format("label %s not found", e.getKey()));
diff --git a/java/com/google/gerrit/server/restapi/project/Module.java b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
similarity index 97%
rename from java/com/google/gerrit/server/restapi/project/Module.java
rename to java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
index 9217077..e50a494 100644
--- a/java/com/google/gerrit/server/restapi/project/Module.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectRestApiModule.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.restapi.change.CherryPickCommit;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 
-public class Module extends RestApiModule {
+public class ProjectRestApiModule extends RestApiModule {
 
   @Override
   protected void configure() {
@@ -99,6 +99,7 @@
     get(FILE_KIND, "content").to(GetContent.class);
 
     child(PROJECT_KIND, "commits").to(CommitsCollection.class);
+    get(PROJECT_KIND, "commits:in").to(CommitsIncludedInRefs.class);
     get(COMMIT_KIND).to(GetCommit.class);
     get(COMMIT_KIND, "in").to(CommitIncludedIn.class);
     child(COMMIT_KIND, "files").to(FilesInCommitCollection.class);
diff --git a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
index efc739c..6174798 100644
--- a/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
+++ b/java/com/google/gerrit/server/restapi/project/ProjectsCollection.java
@@ -105,7 +105,6 @@
    * @throws RestApiException thrown if the project ID cannot be resolved or if the project is not
    *     visible to the calling user
    * @throws IOException thrown when there is an error.
-   * @throws PermissionBackendException
    */
   public ProjectResource parse(String id)
       throws RestApiException, IOException, PermissionBackendException {
@@ -121,7 +120,6 @@
    * @throws RestApiException thrown if the project ID cannot be resolved or if the project is not
    *     visible to the calling user and checkVisibility is true.
    * @throws IOException thrown when there is an error.
-   * @throws PermissionBackendException
    */
   public ProjectResource parse(String id, boolean checkAccess)
       throws RestApiException, IOException, PermissionBackendException {
diff --git a/java/com/google/gerrit/server/restapi/project/PutConfig.java b/java/com/google/gerrit/server/restapi/project/PutConfig.java
index 0770bda..30d667c 100644
--- a/java/com/google/gerrit/server/restapi/project/PutConfig.java
+++ b/java/com/google/gerrit/server/restapi/project/PutConfig.java
@@ -53,7 +53,6 @@
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.ProjectState.Factory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -91,7 +90,7 @@
       @EnableSignedPush boolean serverEnableSignedPush,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       ProjectCache projectCache,
-      Factory projectStateFactory,
+      ProjectState.Factory projectStateFactory,
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
diff --git a/java/com/google/gerrit/server/restapi/project/SetLabel.java b/java/com/google/gerrit/server/restapi/project/SetLabel.java
index 34b6812..801fac7 100644
--- a/java/com/google/gerrit/server/restapi/project/SetLabel.java
+++ b/java/com/google/gerrit/server/restapi/project/SetLabel.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.project.LabelResource;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -45,6 +47,7 @@
   private final MetaDataUpdate.User updateFactory;
   private final ProjectConfig.Factory projectConfigFactory;
   private final ProjectCache projectCache;
+  private final ApprovalQueryBuilder approvalQueryBuilder;
 
   @Inject
   public SetLabel(
@@ -52,12 +55,14 @@
       PermissionBackend permissionBackend,
       MetaDataUpdate.User updateFactory,
       ProjectConfig.Factory projectConfigFactory,
-      ProjectCache projectCache) {
+      ProjectCache projectCache,
+      ApprovalQueryBuilder approvalQueryBuilder) {
     this.user = user;
     this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.projectConfigFactory = projectConfigFactory;
     this.projectCache = projectCache;
+    this.approvalQueryBuilder = approvalQueryBuilder;
   }
 
   @Override
@@ -174,6 +179,26 @@
       dirty = true;
     }
 
+    input.copyCondition = Strings.emptyToNull(input.copyCondition);
+    if (input.copyCondition != null) {
+      try {
+        approvalQueryBuilder.parse(input.copyCondition);
+      } catch (QueryParseException e) {
+        throw new BadRequestException(
+            "unable to parse copy condition. got: " + input.copyCondition + ". " + e.getMessage(),
+            e);
+      }
+      labelTypeBuilder.setCopyCondition(input.copyCondition);
+      dirty = true;
+      if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+        throw new BadRequestException("can't set and unset copyCondition in the same request");
+      }
+    }
+    if (Boolean.TRUE.equals(input.unsetCopyCondition)) {
+      labelTypeBuilder.setCopyCondition(null);
+      dirty = true;
+    }
+
     if (input.copyAnyScore != null) {
       labelTypeBuilder.setCopyAnyScore(input.copyAnyScore);
       dirty = true;
diff --git a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
index e670dc2..9f64863 100644
--- a/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
+++ b/java/com/google/gerrit/server/rules/DefaultSubmitRule.java
@@ -16,19 +16,14 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.gerrit.server.project.ProjectCache.illegalState;
 
-import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -44,9 +39,7 @@
  */
 @Singleton
 public final class DefaultSubmitRule implements SubmitRule {
-  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
-
-  public static class Module extends FactoryModule {
+  public static class DefaultSubmitRuleModule extends FactoryModule {
     @Override
     public void configure() {
       bind(SubmitRule.class)
@@ -55,24 +48,8 @@
     }
   }
 
-  private final ProjectCache projectCache;
-
-  @Inject
-  DefaultSubmitRule(ProjectCache projectCache) {
-    this.projectCache = projectCache;
-  }
-
   @Override
   public Optional<SubmitRecord> evaluate(ChangeData cd) {
-    ProjectState projectState =
-        projectCache.get(cd.project()).orElseThrow(illegalState(cd.project()));
-
-    // In case at least one project has a rules.pl file, we let Prolog handle it.
-    // The Prolog rules engine will also handle the labels for us.
-    if (projectState.hasPrologRules()) {
-      return Optional.empty();
-    }
-
     SubmitRecord submitRecord = new SubmitRecord();
     submitRecord.status = SubmitRecord.Status.OK;
 
diff --git a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
index 08335df..216405d 100644
--- a/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
+++ b/java/com/google/gerrit/server/rules/IgnoreSelfApprovalRule.java
@@ -38,7 +38,7 @@
  */
 @Singleton
 public class IgnoreSelfApprovalRule implements SubmitRule {
-  public static class Module extends AbstractModule {
+  public static class IgnoreSelfApprovalRuleModule extends AbstractModule {
     @Override
     public void configure() {
       bind(SubmitRule.class)
diff --git a/java/com/google/gerrit/server/rules/PrologEnvironment.java b/java/com/google/gerrit/server/rules/PrologEnvironment.java
index 1a563ad..bc0bb1a 100644
--- a/java/com/google/gerrit/server/rules/PrologEnvironment.java
+++ b/java/com/google/gerrit/server/rules/PrologEnvironment.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
@@ -143,7 +143,7 @@
     for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
       try {
         i.next().run();
-      } catch (Throwable err) {
+      } catch (Exception err) {
         logger.atSevere().withCause(err).log("Failed to execute cleanup for PrologEnvironment");
       }
       i.remove();
@@ -173,7 +173,7 @@
     private final PermissionBackend permissionBackend;
     private final GitRepositoryManager repositoryManager;
     private final PluginConfigFactory pluginConfigFactory;
-    private final PatchListCache patchListCache;
+    private final DiffOperations diffOperations;
     private final PatchSetInfoFactory patchSetInfoFactory;
     private final IdentifiedUser.GenericFactory userFactory;
     private final Provider<AnonymousUser> anonymousUser;
@@ -188,7 +188,7 @@
         PermissionBackend permissionBackend,
         GitRepositoryManager repositoryManager,
         PluginConfigFactory pluginConfigFactory,
-        PatchListCache patchListCache,
+        DiffOperations diffOperations,
         PatchSetInfoFactory patchSetInfoFactory,
         IdentifiedUser.GenericFactory userFactory,
         Provider<AnonymousUser> anonymousUser,
@@ -199,7 +199,7 @@
       this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
       this.pluginConfigFactory = pluginConfigFactory;
-      this.patchListCache = patchListCache;
+      this.diffOperations = diffOperations;
       this.patchSetInfoFactory = patchSetInfoFactory;
       this.userFactory = userFactory;
       this.anonymousUser = anonymousUser;
@@ -237,8 +237,8 @@
       return pluginConfigFactory;
     }
 
-    public PatchListCache getPatchListCache() {
-      return patchListCache;
+    public DiffOperations getDiffOperations() {
+      return diffOperations;
     }
 
     public PatchSetInfoFactory getPatchSetInfoFactory() {
diff --git a/java/com/google/gerrit/server/rules/PrologModule.java b/java/com/google/gerrit/server/rules/PrologModule.java
index 37dbba6..5cf4220 100644
--- a/java/com/google/gerrit/server/rules/PrologModule.java
+++ b/java/com/google/gerrit/server/rules/PrologModule.java
@@ -17,12 +17,13 @@
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.server.rules.RulesCache.RulesCacheModule;
 
 public class PrologModule extends FactoryModule {
   @Override
   protected void configure() {
     install(new EnvironmentModule());
-    install(new RulesCache.Module());
+    install(new RulesCacheModule());
     bind(PrologEnvironment.Args.class);
     factory(PrologRuleEvaluator.Factory.class);
 
diff --git a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
index 95e0829..77b64e8 100644
--- a/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
+++ b/java/com/google/gerrit/server/rules/PrologRuleEvaluator.java
@@ -158,6 +158,8 @@
       return ruleError("Error looking up change " + cd.getId(), e);
     }
 
+    logger.atFine().log("input approvals: %s", cd.approvals());
+
     List<Term> results;
     try {
       results =
@@ -178,7 +180,9 @@
               getSubmitRuleName(), cd.getId(), projectState.getName()));
     }
 
-    return resultsToSubmitRecord(getSubmitRule(), results);
+    SubmitRecord submitRecord = resultsToSubmitRecord(getSubmitRule(), results);
+    logger.atFine().log("submit record: %s", submitRecord);
+    return submitRecord;
   }
 
   private String getSubmitRuleName() {
@@ -320,6 +324,7 @@
       logger.atSevere().withCause(e).log(err);
       return createRuleError(DEFAULT_MSG);
     }
+    logger.atFine().log("rule error: %s", err);
     return createRuleError(err);
   }
 
diff --git a/java/com/google/gerrit/server/rules/RulesCache.java b/java/com/google/gerrit/server/rules/RulesCache.java
index 8b72714..706804a 100644
--- a/java/com/google/gerrit/server/rules/RulesCache.java
+++ b/java/com/google/gerrit/server/rules/RulesCache.java
@@ -72,7 +72,7 @@
  */
 @Singleton
 public class RulesCache {
-  public static class Module extends CacheModule {
+  public static class RulesCacheModule extends CacheModule {
     @Override
     protected void configure() {
       cache(RulesCache.CACHE_NAME, ObjectId.class, PrologMachineCopy.class)
diff --git a/java/com/google/gerrit/server/rules/StoredValues.java b/java/com/google/gerrit/server/rules/StoredValues.java
index 1e08a24..fd66a3a 100644
--- a/java/com/google/gerrit/server/rules/StoredValues.java
+++ b/java/com/google/gerrit/server/rules/StoredValues.java
@@ -14,14 +14,11 @@
 
 package com.google.gerrit.server.rules;
 
-import static com.google.gerrit.server.rules.StoredValue.create;
-
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -30,10 +27,9 @@
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
-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.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -46,11 +42,13 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 public final class StoredValues {
-  public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
-  public static final StoredValue<AccountCache> ACCOUNT_CACHE = create(AccountCache.class);
-  public static final StoredValue<Emails> EMAILS = create(Emails.class);
-  public static final StoredValue<ChangeData> CHANGE_DATA = create(ChangeData.class);
-  public static final StoredValue<ProjectState> PROJECT_STATE = create(ProjectState.class);
+  public static final StoredValue<Accounts> ACCOUNTS = StoredValue.create(Accounts.class);
+  public static final StoredValue<AccountCache> ACCOUNT_CACHE =
+      StoredValue.create(AccountCache.class);
+  public static final StoredValue<Emails> EMAILS = StoredValue.create(Emails.class);
+  public static final StoredValue<ChangeData> CHANGE_DATA = StoredValue.create(ChangeData.class);
+  public static final StoredValue<ProjectState> PROJECT_STATE =
+      StoredValue.create(ProjectState.class);
 
   public static Change getChange(Prolog engine) throws SystemException {
     ChangeData cd = CHANGE_DATA.get(engine);
@@ -87,24 +85,27 @@
         }
       };
 
-  public static final StoredValue<PatchList> PATCH_LIST =
-      new StoredValue<PatchList>() {
+  public static final StoredValue<Map<String, FileDiffOutput>> DIFF_LIST =
+      new StoredValue<Map<String, FileDiffOutput>>() {
         @Override
-        public PatchList createValue(Prolog engine) {
+        public Map<String, FileDiffOutput> createValue(Prolog engine) {
           PrologEnvironment env = (PrologEnvironment) engine.control;
           PatchSet ps = getPatchSet(engine);
-          PatchListCache plCache = env.getArgs().getPatchListCache();
+          DiffOperations diffOperations = env.getArgs().getDiffOperations();
           Change change = getChange(engine);
           Project.NameKey project = change.getProject();
-          Whitespace ws = Whitespace.IGNORE_NONE;
-          PatchListKey plKey = PatchListKey.againstDefaultBase(ps.commitId(), ws);
-          PatchList patchList;
+          Map<String, FileDiffOutput> diffList;
           try {
-            patchList = plCache.get(plKey, project);
-          } catch (PatchListNotAvailableException e) {
-            throw new SystemException(String.format("Cannot create %s: %s", plKey, e.getMessage()));
+            diffList =
+                diffOperations.listModifiedFilesAgainstParent(
+                    project, ps.commitId(), /* parentNum= */ 0);
+          } catch (DiffNotAvailableException e) {
+            throw new SystemException(
+                String.format(
+                    "Cannot create modified files for project %s, commit Id %s: %s",
+                    project, ps.commitId(), e.getMessage()));
           }
-          return patchList;
+          return diffList;
         }
       };
 
diff --git a/java/com/google/gerrit/server/schema/AllUsersCreator.java b/java/com/google/gerrit/server/schema/AllUsersCreator.java
index 3588860..f2fe7f6 100644
--- a/java/com/google/gerrit/server/schema/AllUsersCreator.java
+++ b/java/com/google/gerrit/server/schema/AllUsersCreator.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.project.ProjectConfig.Factory;
 import com.google.gerrit.server.project.RefPattern;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -62,7 +61,7 @@
       AllUsersName allUsersName,
       SystemGroupBackend systemGroupBackend,
       @GerritPersonIdent PersonIdent serverUser,
-      Factory projectConfigFactory) {
+      ProjectConfig.Factory projectConfigFactory) {
     this.mgr = mgr;
     this.allUsersName = allUsersName;
     this.serverUser = serverUser;
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index 49dcc46..189d448 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -66,10 +66,10 @@
   private static final String POSTGRESQL = "postgresql";
   private static final String URL = "url";
 
-  public static class Module extends LifecycleModule {
+  public static class JdbcAccountPatchReviewStoreModule extends LifecycleModule {
     private final Config cfg;
 
-    public Module(Config cfg) {
+    public JdbcAccountPatchReviewStoreModule(Config cfg) {
       this.cfg = cfg;
     }
 
diff --git a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
index 868e7ea..3bf9d02 100644
--- a/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
+++ b/java/com/google/gerrit/server/schema/ProjectConfigSchemaUpdate.java
@@ -18,12 +18,11 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.PermissionRule;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.server.config.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.git.meta.VersionedMetaData;
 import com.google.gerrit.server.project.ProjectConfig;
@@ -31,6 +30,7 @@
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
@@ -40,32 +40,30 @@
 
 public class ProjectConfigSchemaUpdate extends VersionedMetaData {
   public static class Factory {
-    private final SitePaths sitePaths;
     private final AllProjectsName allProjectsName;
+    private final AllProjectsConfigProvider allProjectsConfigProvider;
 
     @Inject
-    Factory(SitePaths sitePaths, AllProjectsName allProjectsName) {
-      this.sitePaths = sitePaths;
+    Factory(AllProjectsName allProjectsName, AllProjectsConfigProvider allProjectsConfigProvider) {
       this.allProjectsName = allProjectsName;
+      this.allProjectsConfigProvider = allProjectsConfigProvider;
     }
 
     ProjectConfigSchemaUpdate read(MetaDataUpdate update)
         throws IOException, ConfigInvalidException {
       ProjectConfigSchemaUpdate r =
-          new ProjectConfigSchemaUpdate(
-              update,
-              ProjectConfig.Factory.getBaseConfig(sitePaths, allProjectsName, allProjectsName));
+          new ProjectConfigSchemaUpdate(update, allProjectsConfigProvider.get(allProjectsName));
       r.load(update);
       return r;
     }
   }
 
   private final MetaDataUpdate update;
-  @Nullable private final StoredConfig baseConfig;
+  private final Optional<StoredConfig> baseConfig;
   private Config config;
   private boolean updated;
 
-  private ProjectConfigSchemaUpdate(MetaDataUpdate update, @Nullable StoredConfig baseConfig) {
+  private ProjectConfigSchemaUpdate(MetaDataUpdate update, Optional<StoredConfig> baseConfig) {
     this.update = update;
     this.baseConfig = baseConfig;
   }
@@ -77,8 +75,8 @@
 
   @Override
   protected void onLoad() throws IOException, ConfigInvalidException {
-    if (baseConfig != null) {
-      baseConfig.load();
+    if (baseConfig.isPresent()) {
+      baseConfig.get().load();
     }
     config = readConfig(ProjectConfig.PROJECT_CONFIG, baseConfig);
   }
diff --git a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
index bda3dc4..26ae4a8 100644
--- a/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
+++ b/java/com/google/gerrit/server/schema/SchemaCreatorImpl.java
@@ -32,9 +32,9 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupNameNotes;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.notedb.Sequences;
@@ -132,10 +132,10 @@
       Sequences seqs, Repository allUsersRepo, GroupReference groupReference)
       throws IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("Gerrit Site Administrators").build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setDescription("Gerrit Site Administrators").build();
 
-    createGroup(allUsersRepo, groupCreation, groupUpdate);
+    createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
   private void createBatchUsersGroup(
@@ -145,24 +145,24 @@
       AccountGroup.UUID adminsGroupUuid)
       throws IOException, ConfigInvalidException {
     InternalGroupCreation groupCreation = getGroupCreation(seqs, groupReference);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setDescription("Users who perform batch actions on Gerrit")
             .setOwnerGroupUUID(adminsGroupUuid)
             .build();
 
-    createGroup(allUsersRepo, groupCreation, groupUpdate);
+    createGroup(allUsersRepo, groupCreation, groupDelta);
   }
 
   private void createGroup(
-      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      Repository allUsersRepo, InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws ConfigInvalidException, IOException {
-    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupUpdate);
+    InternalGroup createdGroup = createGroupInNoteDb(allUsersRepo, groupCreation, groupDelta);
     index(createdGroup);
   }
 
   private InternalGroup createGroupInNoteDb(
-      Repository allUsersRepo, InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+      Repository allUsersRepo, InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws ConfigInvalidException, IOException, DuplicateKeyException {
     // This method is only executed on a new server which doesn't have any accounts or groups.
     AuditLogFormatter auditLogFormatter =
@@ -170,9 +170,9 @@
 
     GroupConfig groupConfig =
         GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
-    AccountGroup.NameKey groupName = groupUpdate.getName().orElseGet(groupCreation::getNameKey);
+    AccountGroup.NameKey groupName = groupDelta.getName().orElseGet(groupCreation::getNameKey);
     GroupNameNotes groupNameNotes =
         GroupNameNotes.forNewGroup(
             allUsersName, allUsersRepo, groupCreation.getGroupUUID(), groupName);
diff --git a/java/com/google/gerrit/server/schema/Schema_184.java b/java/com/google/gerrit/server/schema/Schema_184.java
index 85d4740..436c57b 100644
--- a/java/com/google/gerrit/server/schema/Schema_184.java
+++ b/java/com/google/gerrit/server/schema/Schema_184.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.group.db.AuditLogFormatter;
 import com.google.gerrit.server.group.db.GroupConfig;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupNameNotes;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import java.io.IOException;
@@ -64,8 +64,8 @@
       GroupConfig groupConfig =
           GroupConfig.loadForGroup(
               args.allUsers, allUsersRepo, nonInteractiveUsers.get().getUUID());
-      groupConfig.setGroupUpdate(
-          InternalGroupUpdate.builder().setName(newName).build(),
+      groupConfig.setGroupDelta(
+          GroupDelta.builder().setName(newName).build(),
           AuditLogFormatter.createPartiallyWorkingFallBack());
       commit(args.allUsers, args.serverUser, allUsersRepo, groupConfig, newNameNotes);
       index(
diff --git a/java/com/google/gerrit/server/securestore/SecureStore.java b/java/com/google/gerrit/server/securestore/SecureStore.java
index b5aebee..b53e38c 100644
--- a/java/com/google/gerrit/server/securestore/SecureStore.java
+++ b/java/com/google/gerrit/server/securestore/SecureStore.java
@@ -39,13 +39,7 @@
     public final String section;
     public final String subsection;
 
-    /**
-     * Creates EntryKey.
-     *
-     * @param section
-     * @param subsection
-     * @param name
-     */
+    /** Creates EntryKey */
     public EntryKey(String section, String subsection, String name) {
       this.name = name;
       this.section = section;
@@ -57,9 +51,6 @@
    * Extract decrypted value of stored property from SecureStore or {@code null} when property was
    * not found.
    *
-   * @param section
-   * @param subsection
-   * @param name
    * @return decrypted String value or {@code null} if not found
    */
   public final String get(String section, String subsection, String name) {
@@ -74,10 +65,6 @@
    * Extract decrypted value of stored plugin config property from SecureStore or {@code null} when
    * property was not found.
    *
-   * @param pluginName
-   * @param section
-   * @param subsection
-   * @param name
    * @return decrypted String value or {@code null} if not found
    */
   public final String getForPlugin(
@@ -93,10 +80,6 @@
    * Extract list of plugin config values from SecureStore and decrypt every value in that list, or
    * {@code null} when property was not found.
    *
-   * @param pluginName
-   * @param section
-   * @param subsection
-   * @param name
    * @return decrypted list of string values or {@code null}
    */
   public abstract String[] getListForPlugin(
@@ -106,9 +89,6 @@
    * Extract list of values from SecureStore and decrypt every value in that list or {@code null}
    * when property was not found.
    *
-   * @param section
-   * @param subsection
-   * @param name
    * @return decrypted list of string values or {@code null}
    */
   public abstract String[] getList(String section, String subsection, String name);
@@ -118,9 +98,6 @@
    *
    * <p>This method is responsible for encrypting value and storing it.
    *
-   * @param section
-   * @param subsection
-   * @param name
    * @param value plain text value
    */
   public final void set(String section, String subsection, String name, String value) {
@@ -132,26 +109,19 @@
    *
    * <p>This method is responsible for encrypting all values in the list and storing them.
    *
-   * @param section
-   * @param subsection
-   * @param name
    * @param values list of plain text values
    */
   public abstract void setList(String section, String subsection, String name, List<String> values);
 
   /**
    * Remove value for given {@code section}, {@code subsection} and {@code name} from SecureStore.
-   *
-   * @param section
-   * @param subsection
-   * @param name
    */
   public abstract void unset(String section, String subsection, String name);
 
-  /** @return list of stored entries. */
+  /** Returns list of stored entries. */
   public abstract Iterable<EntryKey> list();
 
-  /** @return <code>true</code> if currently loaded values are outdated */
+  /** Returns <code>true</code> if currently loaded values are outdated */
   public abstract boolean isOutdated();
 
   /** Reload the values */
diff --git a/java/com/google/gerrit/server/submit/CommitMergeStatus.java b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
index bf8b840..4638bfa 100644
--- a/java/com/google/gerrit/server/submit/CommitMergeStatus.java
+++ b/java/com/google/gerrit/server/submit/CommitMergeStatus.java
@@ -77,7 +77,14 @@
   EMPTY_COMMIT(
       "Change could not be merged because the commit is empty.\n"
           + "\n"
-          + "Project policy requires all commits to contain modifications to at least one file.");
+          + "Project policy requires all commits to contain modifications to at least one file."),
+
+  FAST_FORWARD_INDEPENDENT_CHANGES(
+      "Change could not be merged because the submission has two independent changes "
+          + "with the same destination branch.\n"
+          + "\n"
+          + "Independent changes can't be submitted to the same destination branch with "
+          + "FAST_FORWARD_ONLY submit strategy");
 
   private final String description;
 
diff --git a/java/com/google/gerrit/server/submit/EmailMerge.java b/java/com/google/gerrit/server/submit/EmailMerge.java
index 109c9c3..3d38f6c 100644
--- a/java/com/google/gerrit/server/submit/EmailMerge.java
+++ b/java/com/google/gerrit/server/submit/EmailMerge.java
@@ -16,7 +16,6 @@
 
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
-import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -42,7 +41,7 @@
     EmailMerge create(
         Project.NameKey project,
         Change change,
-        Account.Id submitter,
+        IdentifiedUser submitter,
         NotifyResolver.Result notify,
         RepoView repoView,
         String stickyApprovalDiff);
@@ -51,12 +50,11 @@
   private final ExecutorService sendEmailsExecutor;
   private final MergedSender.Factory mergedSenderFactory;
   private final ThreadLocalRequestContext requestContext;
-  private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final MessageIdGenerator messageIdGenerator;
 
   private final Project.NameKey project;
   private final Change change;
-  private final Account.Id submitter;
+  private final IdentifiedUser submitter;
   private final NotifyResolver.Result notify;
   private final RepoView repoView;
   private final String stickyApprovalDiff;
@@ -66,18 +64,16 @@
       @SendEmailExecutor ExecutorService executor,
       MergedSender.Factory mergedSenderFactory,
       ThreadLocalRequestContext requestContext,
-      IdentifiedUser.GenericFactory identifiedUserFactory,
       MessageIdGenerator messageIdGenerator,
       @Assisted Project.NameKey project,
       @Assisted Change change,
-      @Assisted @Nullable Account.Id submitter,
+      @Assisted @Nullable IdentifiedUser submitter,
       @Assisted NotifyResolver.Result notify,
       @Assisted RepoView repoView,
       @Assisted String stickyApprovalDiff) {
     this.sendEmailsExecutor = executor;
     this.mergedSenderFactory = mergedSenderFactory;
     this.requestContext = requestContext;
-    this.identifiedUserFactory = identifiedUserFactory;
     this.messageIdGenerator = messageIdGenerator;
     this.project = project;
     this.change = change;
@@ -99,7 +95,7 @@
       MergedSender emailSender =
           mergedSenderFactory.create(project, change.getId(), Optional.of(stickyApprovalDiff));
       if (submitter != null) {
-        emailSender.setFrom(submitter);
+        emailSender.setFrom(submitter.getAccountId());
       }
       emailSender.setNotify(notify);
       emailSender.setMessageId(
@@ -120,7 +116,7 @@
   @Override
   public CurrentUser getUser() {
     if (submitter != null) {
-      return identifiedUserFactory.create(submitter).getRealUser();
+      return submitter;
     }
     throw new OutOfScopeException("No user on email thread");
   }
diff --git a/java/com/google/gerrit/server/submit/FastForwardOnly.java b/java/com/google/gerrit/server/submit/FastForwardOnly.java
index 176b063..8a30898 100644
--- a/java/com/google/gerrit/server/submit/FastForwardOnly.java
+++ b/java/com/google/gerrit/server/submit/FastForwardOnly.java
@@ -14,11 +14,15 @@
 
 package com.google.gerrit.server.submit;
 
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.update.RepoContext;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Map;
 
 public class FastForwardOnly extends SubmitStrategy {
   FastForwardOnly(SubmitStrategy.Arguments args) {
@@ -28,6 +32,21 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge) {
     List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
+
+    Map<BranchNameKey, CodeReviewCommit> branchToCommit = new HashMap<>();
+    for (CodeReviewCommit codeReviewCommit : sorted) {
+      BranchNameKey branchNameKey = codeReviewCommit.change().getDest();
+      CodeReviewCommit otherCommitInBranch = branchToCommit.get(branchNameKey);
+      if (otherCommitInBranch == null) {
+        branchToCommit.put(branchNameKey, codeReviewCommit);
+      } else {
+        // we found another change with the same destination branch.
+        codeReviewCommit.setStatusCode(CommitMergeStatus.FAST_FORWARD_INDEPENDENT_CHANGES);
+        otherCommitInBranch.setStatusCode(CommitMergeStatus.FAST_FORWARD_INDEPENDENT_CHANGES);
+        return ImmutableList.of();
+      }
+    }
+
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
diff --git a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
index 0b05607..89ba1fa 100644
--- a/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
+++ b/java/com/google/gerrit/server/submit/LocalMergeSuperSetComputation.java
@@ -22,20 +22,15 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitTypeRecord;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.client.SubmitType;
 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.permissions.ChangePermission;
-import com.google.gerrit.server.permissions.PermissionBackend;
-import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
@@ -64,7 +59,7 @@
 public class LocalMergeSuperSetComputation implements MergeSuperSetComputation {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends AbstractModule {
+  public static class LocalMergeSuperSetComputationModule extends AbstractModule {
     @Override
     protected void configure() {
       DynamicItem.bind(binder(), MergeSuperSetComputation.class)
@@ -84,21 +79,15 @@
     abstract ImmutableSet<String> hashes();
   }
 
-  private final PermissionBackend permissionBackend;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Map<QueryKey, ImmutableList<ChangeData>> queryCache;
   private final Map<BranchNameKey, Optional<RevCommit>> heads;
-  private final ProjectCache projectCache;
   private final ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory;
 
   @Inject
   LocalMergeSuperSetComputation(
-      PermissionBackend permissionBackend,
       Provider<InternalChangeQuery> queryProvider,
-      ProjectCache projectCache,
       ChangeIsVisibleToPredicate.Factory changeIsVisibleToPredicateFactory) {
-    this.projectCache = projectCache;
-    this.permissionBackend = permissionBackend;
     this.queryProvider = queryProvider;
     this.queryCache = new HashMap<>();
     this.heads = new HashMap<>();
@@ -107,51 +96,46 @@
 
   @Override
   public ChangeSet completeWithoutTopic(
-      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user)
-      throws IOException, PermissionBackendException {
+      MergeOpRepoManager orm, ChangeSet changeSet, CurrentUser user) throws IOException {
     Collection<ChangeData> visibleChanges = new ArrayList<>();
     Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
     // For each target branch we run a separate rev walk to find open changes
     // reachable from changes already in the merge super set.
-    ImmutableListMultimap<BranchNameKey, ChangeData> bc =
-        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges()));
-    for (BranchNameKey b : bc.keySet()) {
-      OpenRepo or = getRepo(orm, b.project());
+    ImmutableSet<BranchNameKey> branches =
+        byBranch(Iterables.concat(changeSet.changes(), changeSet.nonVisibleChanges())).keySet();
+    ImmutableListMultimap<BranchNameKey, ChangeData> visibleChangesPerBranch =
+        byBranch(changeSet.changes());
+    ImmutableListMultimap<BranchNameKey, ChangeData> nonVisibleChangesPerBranch =
+        byBranch(changeSet.nonVisibleChanges());
+
+    for (BranchNameKey branchNameKey : branches) {
+      OpenRepo or = getRepo(orm, branchNameKey.project());
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
-      for (ChangeData cd : bc.get(b)) {
-        boolean visible = isVisible(changeSet, cd, user);
 
+      for (ChangeData cd : visibleChangesPerBranch.get(branchNameKey)) {
         if (submitType(cd) == SubmitType.CHERRY_PICK) {
-          if (visible) {
-            visibleChanges.add(cd);
-          } else {
-            nonVisibleChanges.add(cd);
-          }
-
-          continue;
-        }
-
-        // Get the underlying git commit object
-        RevCommit commit = or.rw.parseCommit(cd.currentPatchSet().commitId());
-
-        // Always include the input, even if merged. This allows
-        // SubmitStrategyOp to correct the situation later, assuming it gets
-        // returned by byCommitsOnBranchNotMerged below.
-        if (visible) {
-          visibleCommits.add(commit);
+          visibleChanges.add(cd);
         } else {
-          nonVisibleCommits.add(commit);
+          visibleCommits.add(or.rw.parseCommit(cd.currentPatchSet().commitId()));
+        }
+      }
+      for (ChangeData cd : nonVisibleChangesPerBranch.get(branchNameKey)) {
+        if (submitType(cd) == SubmitType.CHERRY_PICK) {
+          nonVisibleChanges.add(cd);
+        } else {
+          nonVisibleCommits.add(or.rw.parseCommit(cd.currentPatchSet().commitId()));
         }
       }
 
       Set<String> visibleHashes =
-          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, b);
-      Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
+          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, branchNameKey);
+      Set<String> nonVisibleHashes =
+          walkChangesByHashes(nonVisibleCommits, visibleHashes, or, branchNameKey);
 
       ChangeSet partialSet =
-          byCommitsOnBranchNotMerged(or, b, visibleHashes, nonVisibleHashes, user);
+          byCommitsOnBranchNotMerged(or, branchNameKey, visibleHashes, nonVisibleHashes, user);
       Iterables.addAll(visibleChanges, partialSet.changes());
       Iterables.addAll(nonVisibleChanges, partialSet.nonVisibleChanges());
     }
@@ -179,26 +163,6 @@
     }
   }
 
-  private boolean isVisible(ChangeSet changeSet, ChangeData cd, CurrentUser user)
-      throws PermissionBackendException {
-    boolean statePermitsRead =
-        projectCache.get(cd.project()).map(ProjectState::statePermitsRead).orElse(false);
-    boolean visible = statePermitsRead && changeSet.ids().contains(cd.getId());
-    if (!visible) {
-      return false;
-    }
-
-    try {
-      permissionBackend.user(user).change(cd).check(ChangePermission.READ);
-      return true;
-    } catch (AuthException e) {
-      // We thought the change was visible, but it isn't.
-      // This can happen if the ACL changes during the
-      // completeChangeSet computation, for example.
-      return false;
-    }
-  }
-
   private SubmitType submitType(ChangeData cd) {
     SubmitTypeRecord str = cd.submitTypeRecord();
     if (!str.isOk()) {
@@ -207,7 +171,8 @@
     return str.type;
   }
 
-  private ChangeSet byCommitsOnBranchNotMerged(
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public ChangeSet byCommitsOnBranchNotMerged(
       OpenRepo or,
       BranchNameKey branch,
       Set<String> visibleHashes,
@@ -222,7 +187,9 @@
     ChangeIsVisibleToPredicate changeIsVisibleToPredicate =
         changeIsVisibleToPredicateFactory.forUser(user);
     for (ChangeData cd : potentiallyVisibleChanges) {
-      if (changeIsVisibleToPredicate.match(cd)) {
+      // short circuit permission checks for non-private changes, as we already checked all
+      // permissions (except for private changes).
+      if (!cd.change().isPrivate() || changeIsVisibleToPredicate.match(cd)) {
         visibleChanges.add(cd);
       } else {
         invisibleChanges.add(cd);
@@ -247,7 +214,8 @@
     return result;
   }
 
-  private Set<String> walkChangesByHashes(
+  @UsedAt(UsedAt.Project.GOOGLE)
+  public Set<String> walkChangesByHashes(
       Collection<RevCommit> sourceCommits, Set<String> ignoreHashes, OpenRepo or, BranchNameKey b)
       throws IOException {
     Set<String> destHashes = new HashSet<>();
diff --git a/java/com/google/gerrit/server/submit/MergeOp.java b/java/com/google/gerrit/server/submit/MergeOp.java
index a8a8675..942f024 100644
--- a/java/com/google/gerrit/server/submit/MergeOp.java
+++ b/java/com/google/gerrit/server/submit/MergeOp.java
@@ -36,7 +36,6 @@
 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.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Project;
@@ -68,6 +67,7 @@
 import com.google.gerrit.server.logging.RequestId;
 import com.google.gerrit.server.logging.TraceContext;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.StoreSubmitRequirementsOp;
 import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleOptions;
@@ -97,6 +97,8 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
@@ -239,6 +241,7 @@
   private final NotifyResolver notifyResolver;
   private final RetryHelper retryHelper;
   private final ChangeData.Factory changeDataFactory;
+  private final StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory;
 
   // Changes that were updated by this MergeOp.
   private final Map<Change.Id, Change> updatedChanges;
@@ -272,7 +275,8 @@
       NotifyResolver notifyResolver,
       TopicMetrics topicMetrics,
       RetryHelper retryHelper,
-      ChangeData.Factory changeDataFactory) {
+      ChangeData.Factory changeDataFactory,
+      StoreSubmitRequirementsOp.Factory storeSubmitRequirementsOpFactory) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
@@ -289,6 +293,7 @@
     this.topicMetrics = topicMetrics;
     this.changeDataFactory = changeDataFactory;
     this.updatedChanges = new HashMap<>();
+    this.storeSubmitRequirementsOpFactory = storeSubmitRequirementsOpFactory;
   }
 
   @Override
@@ -655,6 +660,16 @@
               toSubmit, updateOrderCalculator, submoduleCommits, subscriptionGraph, dryrun);
       this.allProjects = updateOrderCalculator.getProjectsInOrder();
       List<BatchUpdate> batchUpdates = orm.batchUpdates(allProjects);
+      // Group batch updates by project
+      Map<Project.NameKey, BatchUpdate> batchUpdatesByProject =
+          batchUpdates.stream().collect(Collectors.toMap(b -> b.getProject(), Function.identity()));
+      for (Map.Entry<Change.Id, ChangeData> entry : cs.changesById().entrySet()) {
+        Project.NameKey project = entry.getValue().project();
+        Change.Id changeId = entry.getKey();
+        batchUpdatesByProject
+            .get(project)
+            .addOp(changeId, storeSubmitRequirementsOpFactory.create());
+      }
       try {
         submissionExecutor.setAdditionalBatchUpdateListeners(
             ImmutableList.of(new SubmitStrategyListener(submitInput, strategies, commitStatus)));
@@ -963,14 +978,8 @@
 
                   change.setStatus(Change.Status.ABANDONED);
 
-                  ChangeMessage msg =
-                      ChangeMessagesUtil.newMessage(
-                          change.currentPatchSetId(),
-                          internalUserFactory.create(),
-                          change.getLastUpdatedOn(),
-                          "Project was deleted.",
-                          ChangeMessagesUtil.TAG_MERGED);
-                  cmUtil.addChangeMessage(ctx.getUpdate(change.currentPatchSetId()), msg);
+                  cmUtil.setChangeMessage(
+                      ctx, "Project was deleted.", ChangeMessagesUtil.TAG_MERGED);
 
                   return true;
                 }
diff --git a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
index db48cce..355d25f 100644
--- a/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/RebaseSubmitStrategy.java
@@ -35,7 +35,7 @@
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -211,7 +211,10 @@
                 .setPostMessage(false)
                 .setSendEmail(false)
                 .setMatchAuthorToCommitterDate(
-                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE));
+                    args.project.is(BooleanProjectConfig.MATCH_AUTHOR_TO_COMMITTER_DATE))
+                // The votes are automatically copied and they don't count as copied votes. See
+                // method's javadoc.
+                .setStoreCopiedVotes(/* storeCopiedVotes = */ false);
         try {
           rebaseOp.updateRepo(ctx);
         } catch (MergeConflictException | NoSuchChangeException e) {
@@ -270,7 +273,7 @@
     }
 
     @Override
-    public void postUpdateImpl(Context ctx) {
+    public void postUpdateImpl(PostUpdateContext ctx) {
       if (rebaseOp != null) {
         rebaseOp.postUpdate(ctx);
       }
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategy.java b/java/com/google/gerrit/server/submit/SubmitStrategy.java
index 530c53f..6291e6c 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategy.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategy.java
@@ -26,12 +26,12 @@
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.change.RebaseChangeOp;
 import com.google.gerrit.server.change.SetPrivateOp;
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
index b533bebc..59c6b81 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyListener.java
@@ -134,6 +134,7 @@
         case NOT_FAST_FORWARD:
         case EMPTY_COMMIT:
         case MISSING_DEPENDENCY:
+        case FAST_FORWARD_INDEPENDENT_CHANGES:
           // TODO(dborowitz): Reformat these messages to be more appropriate for
           // short problem descriptions.
           String message = s.getDescription();
diff --git a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
index 3a04b82..f26ec17 100644
--- a/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
+++ b/java/com/google/gerrit/server/submit/SubmitStrategyOp.java
@@ -24,7 +24,6 @@
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
-import com.google.gerrit.entities.ChangeMessage;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
@@ -33,9 +32,9 @@
 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;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.LabelNormalizer;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -48,7 +47,7 @@
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.PostUpdateContext;
 import com.google.gerrit.server.update.RepoContext;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -284,7 +283,7 @@
             // ChangeMergedEvent in the fixup case, but we'll just live with that.
             : alreadyMergedCommit;
     try {
-      setMerged(ctx, message(ctx, commit, s));
+      setMerged(ctx, commit, message(ctx, commit, s));
     } catch (StorageException err) {
       String msg = "Error updating change status for " + id;
       logger.atSevere().withCause(err).log(msg);
@@ -395,17 +394,17 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit, CommitMergeStatus s)
+  private String 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) {
-      return message(ctx, commit.getPatchsetId(), txt);
+      return message(ctx, txt);
     } else if (s == CommitMergeStatus.CLEAN_REBASE || s == CommitMergeStatus.CLEAN_PICK) {
-      return message(ctx, commit.getPatchsetId(), txt + " as " + commit.name());
+      return message(ctx, txt + " as " + commit.name());
     } else if (s == CommitMergeStatus.SKIPPED_IDENTICAL_TREE) {
-      return message(ctx, commit.getPatchsetId(), txt);
+      return message(ctx, txt);
     } else if (s == CommitMergeStatus.ALREADY_MERGED) {
       // Best effort to mimic the message that would have happened had this
       // succeeded the first time around.
@@ -437,19 +436,14 @@
     }
   }
 
-  private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId, String body)
+  private String message(ChangeContext ctx, String body)
       throws AuthException, IOException, PermissionBackendException,
           InvalidChangeOperationException {
     stickyApprovalDiff = args.submitWithStickyApprovalDiff.apply(ctx.getNotes(), ctx.getUser());
-    return ChangeMessagesUtil.newMessage(
-        psId,
-        ctx.getUser(),
-        ctx.getWhen(),
-        body + stickyApprovalDiff,
-        ChangeMessagesUtil.TAG_MERGED);
+    return body + stickyApprovalDiff;
   }
 
-  private void setMerged(ChangeContext ctx, ChangeMessage msg) {
+  private void setMerged(ChangeContext ctx, CodeReviewCommit commit, String msg) {
     Change c = ctx.getChange();
     logger.atFine().log("Setting change %s merged", c.getId());
     c.setStatus(Change.Status.MERGED);
@@ -458,12 +452,13 @@
     // which is not the user from the update context. addMergedMessage was able
     // to do this in the past.
     if (msg != null) {
-      args.cmUtil.addChangeMessage(ctx.getUpdate(msg.getPatchSetId()), msg);
+      args.cmUtil.setChangeMessage(
+          ctx.getUpdate(commit.getPatchsetId()), msg, ChangeMessagesUtil.TAG_MERGED);
     }
   }
 
   @Override
-  public final void postUpdate(Context ctx) throws Exception {
+  public final void postUpdate(PostUpdateContext ctx) throws Exception {
     if (changeAlreadyMerged) {
       // TODO(dborowitz): This is suboptimal behavior in the presence of retries: postUpdate steps
       // will never get run for changes that submitted successfully on any but the final attempt.
@@ -508,7 +503,7 @@
           .create(
               ctx.getProject(),
               toMerge.change(),
-              submitter.accountId(),
+              args.caller,
               ctx.getNotify(getId()),
               ctx.getRepoView(),
               stickyApprovalDiff)
@@ -518,7 +513,7 @@
     }
     if (mergeResultRev != null && !args.dryrun) {
       args.changeMerged.fire(
-          updatedChange,
+          ctx.getChangeData(updatedChange),
           mergedPatchSet,
           args.accountCache.get(submitter.accountId()).orElse(null),
           args.mergeTip.getCurrentTip().name(),
@@ -526,32 +521,22 @@
     }
   }
 
-  /**
-   * @see #updateRepo(RepoContext)
-   * @param ctx
-   */
+  /** See {@link #updateRepo(RepoContext)} */
   protected void updateRepoImpl(RepoContext ctx) throws Exception {}
 
   /**
-   * @see #updateChange(ChangeContext)
-   * @param ctx
-   * @return a new patch set if one was created by the submit strategy, or null if not.
+   * Returns a new patch set if one was created by the submit strategy, or null if not
+   *
+   * <p>See {@link #updateChange(ChangeContext)}
    */
   protected PatchSet updateChangeImpl(ChangeContext ctx) throws Exception {
     return null;
   }
 
-  /**
-   * @see #postUpdate(Context)
-   * @param ctx
-   */
-  protected void postUpdateImpl(Context ctx) throws Exception {}
+  /** See {@link #postUpdate(PostUpdateContext)} */
+  protected void postUpdateImpl(PostUpdateContext ctx) throws Exception {}
 
-  /**
-   * Amend the commit with gitlink update
-   *
-   * @param commit
-   */
+  /** Amend the commit with gitlink update */
   protected CodeReviewCommit amendGitlink(CodeReviewCommit commit)
       throws IntegrationConflictException {
     if (!args.subscriptionGraph.hasSubscription(args.destBranch)) {
diff --git a/java/com/google/gerrit/server/submit/SubscriptionGraph.java b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
index ad16cb0..d434890 100644
--- a/java/com/google/gerrit/server/submit/SubscriptionGraph.java
+++ b/java/com/google/gerrit/server/submit/SubscriptionGraph.java
@@ -158,7 +158,7 @@
         throws SubmoduleConflictException;
   }
 
-  public static class Module extends AbstractModule {
+  public static class SubscriptionGraphModule extends AbstractModule {
     @Override
     protected void configure() {
       bind(Factory.class).annotatedWith(VanillaSubscriptionGraph.class).to(DefaultFactory.class);
@@ -231,7 +231,6 @@
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws SubmoduleConflictException {
-      logger.atFine().log("Calculating superprojects - submodules map");
       LinkedHashSet<BranchNameKey> allVisited = new LinkedHashSet<>();
       for (BranchNameKey updatedBranch : updatedBranches) {
         if (allVisited.contains(updatedBranch)) {
@@ -332,15 +331,15 @@
         Map<BranchNameKey, GitModules> branchGitModules,
         MergeOpRepoManager orm)
         throws IOException {
-      logger.atFine().log("Calculating possible superprojects for %s", srcBranch);
       Collection<SubmoduleSubscription> ret = new ArrayList<>();
+      if (RefNames.isGerritRef(srcBranch.branch())) return ret;
+
       Project.NameKey srcProject = srcBranch.project();
       for (SubscribeSection s :
           projectCache
               .get(srcProject)
               .orElseThrow(illegalState(srcProject))
               .getSubscribeSections(srcBranch)) {
-        logger.atFine().log("Checking subscribe section %s", s);
         Collection<BranchNameKey> branches = getDestinationBranches(srcBranch, s, orm);
         for (BranchNameKey targetBranch : branches) {
           Project.NameKey targetProject = targetBranch.project();
@@ -348,11 +347,11 @@
             OpenRepo or = orm.getRepo(targetProject);
             ObjectId id = or.repo.resolve(targetBranch.branch());
             if (id == null) {
-              logger.atFine().log("The branch %s doesn't exist.", targetBranch);
+              logger.atFine().log("SubscribeSection %s: branch %s doesn't exist.", s, targetBranch);
               continue;
             }
           } catch (NoSuchProjectException e) {
-            logger.atFine().log("The project %s doesn't exist", targetProject);
+            logger.atFine().log("SubscribeSection %s: project %s doesn't exist", s, targetProject);
             continue;
           }
 
diff --git a/java/com/google/gerrit/server/tools/ToolsCatalog.java b/java/com/google/gerrit/server/tools/ToolsCatalog.java
index aaa366c..9c1483f 100644
--- a/java/com/google/gerrit/server/tools/ToolsCatalog.java
+++ b/java/com/google/gerrit/server/tools/ToolsCatalog.java
@@ -175,28 +175,28 @@
       return type;
     }
 
-    /** @return the preferred UNIX file mode, e.g. {@code 0755}. */
+    /** Returns the preferred UNIX file mode, e.g. {@code 0755}. */
     public int getMode() {
       return mode;
     }
 
-    /** @return path of the entry, relative to the catalog root. */
+    /** Returns path of the entry, relative to the catalog root. */
     public String getPath() {
       return path;
     }
 
-    /** @return name of the entry, within its parent directory. */
+    /** Returns the name of the entry, within its parent directory. */
     public String getName() {
       final int s = path.lastIndexOf('/');
       return s < 0 ? path : path.substring(s + 1);
     }
 
-    /** @return collection of entries below this one, if this is a directory. */
+    /** Returns collection of entries below this one, if this is a directory. */
     public List<Entry> getChildren() {
       return Collections.unmodifiableList(children);
     }
 
-    /** @return a copy of the file's contents. */
+    /** Returns a copy of the file's contents. */
     public byte[] getBytes() {
       byte[] data = read(getPath());
 
diff --git a/java/com/google/gerrit/server/update/BatchUpdate.java b/java/com/google/gerrit/server/update/BatchUpdate.java
index 7fdf833..917e967 100644
--- a/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -37,7 +37,6 @@
 import com.google.gerrit.entities.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
-import com.google.gerrit.entities.PatchSet.Id;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.config.FactoryModule;
@@ -64,6 +63,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.NoSuchRefException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.assistedinject.Assisted;
@@ -74,9 +74,11 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.TimeZone;
 import java.util.TreeMap;
+import java.util.function.Function;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -134,7 +136,7 @@
     checkDifferentProject(updates);
 
     try {
-      List<ListenableFuture<?>> indexFutures = new ArrayList<>();
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>();
       List<ChangesHandle> changesHandles = new ArrayList<>(updates.size());
       try {
         for (BatchUpdate u : updates) {
@@ -156,7 +158,11 @@
         }
       }
 
-      ((ListenableFuture<?>) Futures.allAsList(indexFutures)).get();
+      Map<Change.Id, ChangeData> changeDatas =
+          Futures.allAsList(indexFutures).get().stream()
+              // filter out null values that were returned for change deletions
+              .filter(Objects::nonNull)
+              .collect(toMap(cd -> cd.change().getId(), Function.identity()));
 
       // Fire ref update events only after all mutations are finished, since callers may assume a
       // patch set ref being created means the change was created, or a branch advancing meaning
@@ -165,7 +171,7 @@
 
       if (!dryrun) {
         for (BatchUpdate u : updates) {
-          u.executePostOps();
+          u.executePostOps(changeDatas);
         }
       }
     } catch (Exception e) {
@@ -293,7 +299,7 @@
      * commit in NoteDb by re-using the same ChangeUpdate instance. Will still be one commit per
      * patch set.
      */
-    private final ListMultimap<Id, ChangeUpdate> distinctUpdates;
+    private final ListMultimap<PatchSet.Id, ChangeUpdate> distinctUpdates;
 
     private boolean deleted;
 
@@ -340,6 +346,19 @@
     }
   }
 
+  private class PostUpdateContextImpl extends ContextImpl implements PostUpdateContext {
+    private final Map<Change.Id, ChangeData> changeDatas;
+
+    PostUpdateContextImpl(Map<Change.Id, ChangeData> changeDatas) {
+      this.changeDatas = changeDatas;
+    }
+
+    @Override
+    public ChangeData getChangeData(Change change) {
+      return changeDatas.computeIfAbsent(change.getId(), id -> changeDataFactory.create(change));
+    }
+  }
+
   /** Per-change result status from {@link #executeChangeOps}. */
   private enum ChangeResult {
     SKIPPED,
@@ -348,6 +367,7 @@
   }
 
   private final GitRepositoryManager repoManager;
+  private final ChangeData.Factory changeDataFactory;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
@@ -377,6 +397,7 @@
   BatchUpdate(
       GitRepositoryManager repoManager,
       @GerritPersonIdent PersonIdent serverIdent,
+      ChangeData.Factory changeDataFactory,
       ChangeNotes.Factory changeNotesFactory,
       ChangeUpdate.Factory changeUpdateFactory,
       NoteDbUpdateManager.Factory updateManagerFactory,
@@ -386,6 +407,7 @@
       @Assisted CurrentUser user,
       @Assisted Timestamp when) {
     this.repoManager = repoManager;
+    this.changeDataFactory = changeDataFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.changeUpdateFactory = changeUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
@@ -530,11 +552,15 @@
     try {
       logDebug("Executing updateRepo on %d ops", ops.size());
       RepoContextImpl ctx = new RepoContextImpl();
-      for (BatchUpdateOp op : ops.values()) {
+      for (Map.Entry<Change.Id, BatchUpdateOp> op : ops.entries()) {
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateRepo", Metadata.empty())) {
-          op.updateRepo(ctx);
+                op.getClass().getSimpleName() + "#updateRepo",
+                Metadata.builder()
+                    .projectName(project.get())
+                    .changeId(op.getKey().get())
+                    .build())) {
+          op.getValue().updateRepo(ctx);
         }
       }
 
@@ -589,12 +615,12 @@
       BatchUpdate.this.executed = manager.isExecuted();
     }
 
-    List<ListenableFuture<?>> startIndexFutures() {
+    List<ListenableFuture<ChangeData>> startIndexFutures() {
       if (dryrun) {
         return ImmutableList.of();
       }
       logDebug("Reindexing %d changes", results.size());
-      List<ListenableFuture<?>> indexFutures = new ArrayList<>(results.size());
+      List<ListenableFuture<ChangeData>> indexFutures = new ArrayList<>(results.size());
       for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
         Change.Id id = e.getKey();
         switch (e.getValue()) {
@@ -649,7 +675,8 @@
       for (BatchUpdateOp op : e.getValue()) {
         try (TraceContext.TraceTimer ignored =
             TraceContext.newTimer(
-                op.getClass().getSimpleName() + "#updateChange", Metadata.empty())) {
+                op.getClass().getSimpleName() + "#updateChange",
+                Metadata.builder().projectName(project.get()).changeId(id.get()).build())) {
           dirty |= op.updateChange(ctx);
         }
       }
@@ -687,8 +714,8 @@
     return new ChangeContextImpl(notes);
   }
 
-  private void executePostOps() throws Exception {
-    ContextImpl ctx = new ContextImpl();
+  private void executePostOps(Map<Change.Id, ChangeData> changeDatas) throws Exception {
+    PostUpdateContextImpl ctx = new PostUpdateContextImpl(changeDatas);
     for (BatchUpdateOp op : ops.values()) {
       try (TraceContext.TraceTimer ignored =
           TraceContext.newTimer(op.getClass().getSimpleName() + "#postUpdate", Metadata.empty())) {
diff --git a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
index c223aec..99c72f2 100644
--- a/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
+++ b/java/com/google/gerrit/server/update/ChainedReceiveCommands.java
@@ -118,7 +118,7 @@
     }
   }
 
-  /** @return an unmodifiable view of commands. */
+  /** Returns an unmodifiable view of commands. */
   public Map<String, ReceiveCommand> getCommands() {
     return Collections.unmodifiableMap(commands);
   }
diff --git a/java/com/google/gerrit/server/update/ChangeContext.java b/java/com/google/gerrit/server/update/ChangeContext.java
index 5a53e2a..aeabde4 100644
--- a/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/java/com/google/gerrit/server/update/ChangeContext.java
@@ -69,7 +69,7 @@
    */
   void deleteChange();
 
-  /** @return change corresponding to {@link #getNotes()}. */
+  /** Returns change corresponding to {@link #getNotes()}. */
   default Change getChange() {
     return requireNonNull(getNotes().getChange());
   }
diff --git a/java/com/google/gerrit/server/update/PostUpdateContext.java b/java/com/google/gerrit/server/update/PostUpdateContext.java
new file mode 100644
index 0000000..d4d1f62
--- /dev/null
+++ b/java/com/google/gerrit/server/update/PostUpdateContext.java
@@ -0,0 +1,37 @@
+// 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.update;
+
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.change.ChangeData;
+
+/** Context for performing the {@link BatchUpdateOp#postUpdate} phase. */
+public interface PostUpdateContext extends Context {
+  /**
+   * Get the change data for the specified change.
+   *
+   * <p>If the change data has been computed previously, because the change has been indexed after
+   * an update or because this method has been invoked before, the cached change data instance is
+   * returned.
+   *
+   * @param change the change for which the change data should be returned
+   */
+  ChangeData getChangeData(Change change);
+
+  default ChangeData getChangeData(ChangeNotes changeNotes) {
+    return getChangeData(changeNotes.getChange());
+  }
+}
diff --git a/java/com/google/gerrit/server/update/RepoContext.java b/java/com/google/gerrit/server/update/RepoContext.java
index 9faf628..66831cd 100644
--- a/java/com/google/gerrit/server/update/RepoContext.java
+++ b/java/com/google/gerrit/server/update/RepoContext.java
@@ -22,9 +22,9 @@
 /** Context for performing the {@link BatchUpdateOp#updateRepo} phase. */
 public interface RepoContext extends Context {
   /**
-   * @return inserter for writing to the repo. Callers should not flush; the walk returned by {@link
-   *     #getRevWalk()} is able to read back objects inserted by this inserter without flushing
-   *     first.
+   * Returns inserter for writing to the repo. Callers should not flush; the walk returned by {@link
+   * #getRevWalk()} is able to read back objects inserted by this inserter without flushing first.
+   *
    * @throws IOException if an error occurred opening the repo.
    */
   ObjectInserter getInserter() throws IOException;
diff --git a/java/com/google/gerrit/server/update/RepoOnlyOp.java b/java/com/google/gerrit/server/update/RepoOnlyOp.java
index 7e9c47e..c3e3948 100644
--- a/java/com/google/gerrit/server/update/RepoOnlyOp.java
+++ b/java/com/google/gerrit/server/update/RepoOnlyOp.java
@@ -35,5 +35,5 @@
    * @param ctx context
    */
   // TODO(dborowitz): Support async operations?
-  default void postUpdate(Context ctx) throws Exception {}
+  default void postUpdate(PostUpdateContext ctx) throws Exception {}
 }
diff --git a/java/com/google/gerrit/server/update/RepoView.java b/java/com/google/gerrit/server/update/RepoView.java
index 52467a4..da9b083 100644
--- a/java/com/google/gerrit/server/update/RepoView.java
+++ b/java/com/google/gerrit/server/update/RepoView.java
@@ -59,7 +59,7 @@
     closeRepo = true;
   }
 
-  RepoView(Repository repo, RevWalk rw, ObjectInserter inserter) {
+  public RepoView(Repository repo, RevWalk rw, ObjectInserter inserter) {
     checkArgument(
         rw.getObjectReader().getCreatedFromInserter() == inserter,
         "expected RevWalk %s to be created by ObjectInserter %s",
diff --git a/java/com/google/gerrit/server/update/RetryHelper.java b/java/com/google/gerrit/server/update/RetryHelper.java
index 409c808..7e6974c 100644
--- a/java/com/google/gerrit/server/update/RetryHelper.java
+++ b/java/com/google/gerrit/server/update/RetryHelper.java
@@ -28,6 +28,7 @@
 import com.github.rholder.retry.WaitStrategy;
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Throwables;
 import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.UsedAt;
@@ -120,7 +121,9 @@
     @Inject
     Metrics(MetricMaker metricMaker) {
       Field<String> actionTypeField =
-          Field.ofString("action_type", Metadata.Builder::actionType).build();
+          Field.ofString("action_type", Metadata.Builder::actionType)
+              .description("The type of the action that was retried.")
+              .build();
       Field<String> operationNameField =
           Field.ofString("operation_name", Metadata.Builder::operationName)
               .description("The name of the operation that was retried.")
@@ -438,12 +441,12 @@
    * @param opts options for retrying the action on failure
    * @param exceptionPredicate predicate to control on which exception the action should be retried
    * @return the result of executing the action
-   * @throws Throwable any error or exception that made the action fail, callers are expected to
+   * @throws Exception any error or exception that made the action fail, callers are expected to
    *     catch and inspect this Throwable to decide carefully whether it should be re-thrown
    */
   <T> T execute(
       String actionType, Action<T> action, Options opts, Predicate<Throwable> exceptionPredicate)
-      throws Throwable {
+      throws Exception {
     MetricListener listener = new MetricListener();
     try (TraceContext traceContext = TraceContext.open()) {
       RetryerBuilder<T> retryerBuilder =
@@ -478,7 +481,7 @@
                   }
 
                   String cause = formatCause(t);
-                  if (!traceContext.isTracing()) {
+                  if (!TraceContext.isTracing()) {
                     String traceId = "retry-on-failure-" + new RequestId();
                     traceContext.addTag(RequestId.Type.TRACE_ID, traceId).forceLogging();
                     logger.atWarning().withCause(t).log(
@@ -547,8 +550,8 @@
    * @param retryer the retryer
    * @param listener metric listener
    * @return the result of executing the action
-   * @throws Throwable any error or exception that made the action fail, callers are expected to
-   *     catch and inspect this Throwable to decide carefully whether it should be re-thrown
+   * @throws Exception any exception that made the action fail, callers are expected to catch and
+   *     inspect this exception to decide carefully whether it should be re-thrown
    */
   private <T> T executeWithTimeoutCount(
       String actionType,
@@ -556,7 +559,7 @@
       Options opts,
       Retryer<T> retryer,
       MetricListener listener)
-      throws Throwable {
+      throws Exception {
     try {
       return retryer.call(action::call);
     } catch (ExecutionException | RetryException e) {
@@ -567,7 +570,8 @@
             listener.getOriginalCause().map(this::formatCause).orElse("_unknown"));
       }
       if (e.getCause() != null) {
-        throw e.getCause();
+        Throwables.throwIfUnchecked(e.getCause());
+        Throwables.throwIfInstanceOf(e.getCause(), Exception.class);
       }
       throw e;
     }
diff --git a/java/com/google/gerrit/server/update/RetryableAction.java b/java/com/google/gerrit/server/update/RetryableAction.java
index 167b209..f79a849 100644
--- a/java/com/google/gerrit/server/update/RetryableAction.java
+++ b/java/com/google/gerrit/server/update/RetryableAction.java
@@ -57,6 +57,7 @@
     PLUGIN_UPDATE,
     REST_READ_REQUEST,
     REST_WRITE_REQUEST,
+    SEND_EMAIL,
   }
 
   @FunctionalInterface
@@ -174,7 +175,7 @@
           action,
           options.build(),
           t -> exceptionPredicates.stream().anyMatch(p -> p.test(t)));
-    } catch (Throwable t) {
+    } catch (Exception t) {
       Throwables.throwIfUnchecked(t);
       Throwables.throwIfInstanceOf(t, Exception.class);
       throw new IllegalStateException(t);
diff --git a/java/com/google/gerrit/server/update/RetryableChangeAction.java b/java/com/google/gerrit/server/update/RetryableChangeAction.java
index 152db2c..84ec2bb 100644
--- a/java/com/google/gerrit/server/update/RetryableChangeAction.java
+++ b/java/com/google/gerrit/server/update/RetryableChangeAction.java
@@ -82,11 +82,11 @@
   public T call() throws UpdateException, RestApiException {
     try {
       return super.call();
-    } catch (Throwable t) {
-      Throwables.throwIfUnchecked(t);
-      Throwables.throwIfInstanceOf(t, UpdateException.class);
-      Throwables.throwIfInstanceOf(t, RestApiException.class);
-      throw new UpdateException(t);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, UpdateException.class);
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java b/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
index cf733a6..d66edcf 100644
--- a/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
+++ b/java/com/google/gerrit/server/update/RetryableIndexQueryAction.java
@@ -87,9 +87,9 @@
   public T call() {
     try {
       return super.call();
-    } catch (Throwable t) {
-      Throwables.throwIfUnchecked(t);
-      throw new StorageException(t);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      throw new StorageException(e);
     }
   }
 }
diff --git a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
index 4c65c80..8a216cd 100644
--- a/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
+++ b/java/com/google/gerrit/server/update/SuperprojectUpdateSubmissionListener.java
@@ -39,7 +39,7 @@
   private ImmutableList<BatchUpdate> batchUpdates = ImmutableList.of();
   private boolean dryrun;
 
-  public static class Module extends AbstractModule {
+  public static class SuperprojectUpdateSubmissionListenerModule extends AbstractModule {
     @Provides
     @SuperprojectUpdateOnSubmission
     ImmutableList<SubmissionListener> provideSubmissionListeners(
diff --git a/java/com/google/gerrit/server/util/AccountTemplateUtil.java b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
new file mode 100644
index 0000000..c552ce8
--- /dev/null
+++ b/java/com/google/gerrit/server/util/AccountTemplateUtil.java
@@ -0,0 +1,102 @@
+// 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.util;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.AttentionSetUpdate;
+import com.google.gerrit.entities.ChangeMessage;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.HashSet;
+import java.util.Optional;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Utility functions for text that can be persisted in data storage and should not contain user
+ * identifiable information, e.g. {@link ChangeMessage} or {@link AttentionSetUpdate#reason}.
+ */
+@Singleton
+public class AccountTemplateUtil {
+
+  /**
+   * Template to represent account in pseudonymized form in text, that might be persisted in data
+   * storage.
+   */
+  public static final String ACCOUNT_TEMPLATE = "<GERRIT_ACCOUNT_%d>";
+
+  public static final String ACCOUNT_TEMPLATE_REGEX = "<GERRIT_ACCOUNT_([0-9]+)>";
+
+  public static final Pattern ACCOUNT_TEMPLATE_PATTERN = Pattern.compile(ACCOUNT_TEMPLATE_REGEX);
+
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final AccountCache accountCache;
+
+  @Inject
+  AccountTemplateUtil(AccountCache accountCache) {
+    this.accountCache = accountCache;
+  }
+
+  /** Returns account ids that are used in text, that might contain {@link #ACCOUNT_TEMPLATE}. */
+  public static ImmutableSet<Account.Id> parseTemplates(String textTemplate) {
+    if (Strings.isNullOrEmpty(textTemplate)) {
+      return ImmutableSet.of();
+    }
+    Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(textTemplate);
+    Set<Account.Id> accountsInTemplate = new HashSet<>();
+    while (matcher.find()) {
+      String accountId = matcher.group(1);
+      Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
+      if (parsedAccountId.isPresent()) {
+        accountsInTemplate.add(parsedAccountId.get());
+      } else {
+        logger.atFine().log("Failed to parse accountId from template %s", matcher.group());
+      }
+    }
+    return ImmutableSet.copyOf(accountsInTemplate);
+  }
+
+  public static String getAccountTemplate(Account.Id accountId) {
+    return String.format(ACCOUNT_TEMPLATE, accountId.get());
+  }
+
+  /** Builds user-readable text from text, that might contain {@link #ACCOUNT_TEMPLATE}. */
+  public String replaceTemplates(String messageTemplate) {
+    Matcher matcher = ACCOUNT_TEMPLATE_PATTERN.matcher(messageTemplate);
+    StringBuffer out = new StringBuffer();
+    while (matcher.find()) {
+      String accountId = matcher.group(1);
+      String unrecognizedAccount = "Unrecognized Gerrit Account " + accountId;
+      Optional<Account.Id> parsedAccountId = Account.Id.tryParse(accountId);
+      if (parsedAccountId.isPresent()) {
+        Optional<AccountState> account = accountCache.get(parsedAccountId.get());
+        if (account.isPresent()) {
+          matcher.appendReplacement(out, account.get().account().getNameEmail(unrecognizedAccount));
+          continue;
+        }
+      }
+      matcher.appendReplacement(out, unrecognizedAccount);
+    }
+    matcher.appendTail(out);
+    return out.toString();
+  }
+}
diff --git a/java/com/google/gerrit/server/util/AttentionSetEmail.java b/java/com/google/gerrit/server/util/AttentionSetEmail.java
index 56b1dda..48ddd31 100644
--- a/java/com/google/gerrit/server/util/AttentionSetEmail.java
+++ b/java/com/google/gerrit/server/util/AttentionSetEmail.java
@@ -56,6 +56,7 @@
   }
 
   private ExecutorService sendEmailsExecutor;
+  private AccountTemplateUtil accountTemplateUtil;
   private AttentionSetSender sender;
   private Context ctx;
   private Change change;
@@ -67,6 +68,7 @@
   @Inject
   AttentionSetEmail(
       @SendEmailExecutor ExecutorService executor,
+      AccountTemplateUtil accountTemplateUtil,
       @Assisted AttentionSetSender sender,
       @Assisted Context ctx,
       @Assisted Change change,
@@ -74,6 +76,7 @@
       @Assisted MessageIdGenerator.MessageId messageId,
       @Assisted Account.Id attentionUserId) {
     this.sendEmailsExecutor = executor;
+    this.accountTemplateUtil = accountTemplateUtil;
     this.sender = sender;
     this.ctx = ctx;
     this.change = change;
@@ -97,7 +100,7 @@
       }
       sender.setNotify(ctx.getNotify(change.getId()));
       sender.setAttentionSetUser(attentionUserId);
-      sender.setReason(reason);
+      sender.setReason(accountTemplateUtil.replaceTemplates(reason));
       sender.setMessageId(messageId);
       sender.send();
     } catch (Exception e) {
diff --git a/java/com/google/gerrit/server/util/AttentionSetUtil.java b/java/com/google/gerrit/server/util/AttentionSetUtil.java
index 26c862d..9238b44 100644
--- a/java/com/google/gerrit/server/util/AttentionSetUtil.java
+++ b/java/com/google/gerrit/server/util/AttentionSetUtil.java
@@ -16,14 +16,19 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 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.common.AccountInfo;
+import com.google.gerrit.extensions.common.AttentionSetInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import java.io.IOException;
+import java.sql.Timestamp;
 import java.util.Collection;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
@@ -94,5 +99,26 @@
         || changeNotes.getReviewers().all().stream().anyMatch(id -> id.equals(attentionUserId));
   }
 
+  /**
+   * Returns {@link AttentionSetInfo} from {@link AttentionSetUpdate} with {@link AccountInfo}
+   * fields filled by {@code accountLoader}.
+   */
+  public static AttentionSetInfo createAttentionSetInfo(
+      AttentionSetUpdate attentionSetUpdate, AccountLoader accountLoader) {
+    // Only one account is expected in attention set reason. If there are multiple, do not return
+    // anything instead of failing the request.
+    ImmutableSet<Account.Id> accountsInTemplate =
+        AccountTemplateUtil.parseTemplates(attentionSetUpdate.reason());
+    AccountInfo reasonAccount =
+        accountsInTemplate.size() == 1
+            ? accountLoader.get(Iterables.getOnlyElement(accountsInTemplate))
+            : null;
+    return new AttentionSetInfo(
+        accountLoader.get(attentionSetUpdate.account()),
+        Timestamp.from(attentionSetUpdate.timestamp()),
+        attentionSetUpdate.reason(),
+        reasonAccount);
+  }
+
   private AttentionSetUtil() {}
 }
diff --git a/java/com/google/gerrit/server/util/CommitMessageUtil.java b/java/com/google/gerrit/server/util/CommitMessageUtil.java
index 55e3951..dc9c2d9 100644
--- a/java/com/google/gerrit/server/util/CommitMessageUtil.java
+++ b/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -51,7 +51,7 @@
    * the commit message.
    *
    * @throws BadRequestException if the commit message is null or empty
-   * @returns the trimmed message with a trailing newline character
+   * @return the trimmed message with a trailing newline character
    */
   public static String checkAndSanitizeCommitMessage(@Nullable String commitMessage)
       throws BadRequestException {
diff --git a/java/com/google/gerrit/server/util/RequestScopePropagator.java b/java/com/google/gerrit/server/util/RequestScopePropagator.java
index dc8a136..10c46fc 100644
--- a/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -160,7 +160,11 @@
     };
   }
 
-  /** @see #wrap(Callable) */
+  /**
+   * Ensures that the current request state is available when the passed in Callable is invoked
+   *
+   * <p>See {@link #wrap(Callable)}
+   */
   protected abstract <T> Callable<T> wrapImpl(Callable<T> callable);
 
   protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
diff --git a/java/com/google/gerrit/server/util/git/BUILD b/java/com/google/gerrit/server/util/git/BUILD
index 4f4ba83..bbc6bf0 100644
--- a/java/com/google/gerrit/server/util/git/BUILD
+++ b/java/com/google/gerrit/server/util/git/BUILD
@@ -7,5 +7,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//lib:jgit",
+        "//lib/flogger:api",
     ],
 )
diff --git a/java/com/google/gerrit/server/util/git/CloseablePool.java b/java/com/google/gerrit/server/util/git/CloseablePool.java
new file mode 100644
index 0000000..442bd09
--- /dev/null
+++ b/java/com/google/gerrit/server/util/git/CloseablePool.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2022 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT 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.util.git;
+
+import com.google.common.flogger.FluentLogger;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+
+/**
+ * Pool to manage resources that need to be closed but to whom we might lose the reference to or
+ * where closing resources individually is not always possible.
+ *
+ * <p>This pool can be used when we want to reuse closable resources in a multithreaded context.
+ * Example:
+ *
+ * <pre>{@code
+ * try (CloseablePool<T> pool = new CloseablePool(() -> new T())) {
+ *   for (int i = 0; i < 100; i++) {
+ *     executor.submit(() -> {
+ *       try (CloseablePool<T>.Handle handle = pool.get()) {
+ *         // Do work that might potentially take longer than the timeout.
+ *         handle.get(); // pooled instance to be used
+ *       }
+ *     }).get(1000, MILLISECONDS);
+ *   }
+ * }
+ * }</pre>
+ */
+public class CloseablePool<T extends AutoCloseable> implements AutoCloseable {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final Supplier<T> tCreator;
+  private List<T> ts;
+
+  /**
+   * Instantiate a new pool. The {@link Supplier} must be capable of creating a new instance on
+   * every call.
+   */
+  public CloseablePool(Supplier<T> tCreator) {
+    this.ts = new ArrayList<>();
+    this.tCreator = tCreator;
+  }
+
+  /**
+   * Get a shared instance or create a new instance. Close the returned handle to return it to the
+   * pool.
+   */
+  public synchronized Handle get() {
+    if (ts.isEmpty()) {
+      return new Handle(tCreator.get());
+    }
+    return new Handle(ts.remove(ts.size() - 1));
+  }
+
+  private synchronized boolean discard(T t) {
+    if (ts != null) {
+      ts.add(t);
+      return true;
+    }
+    return false;
+  }
+
+  @Override
+  public synchronized void close() {
+    for (T t : ts)
+      try {
+        t.close();
+      } catch (Exception e) {
+        logger.atWarning().withCause(e).log(
+            "Failed to close resource %s in CloseablePool %s", t, this);
+      }
+    ts = null;
+  }
+
+  /**
+   * Wrapper around an {@link AutoCloseable}. Will try to return the resource to the pool and close
+   * it in case the pool was already closed.
+   */
+  public class Handle implements AutoCloseable {
+    private final T t;
+
+    private Handle(T t) {
+      this.t = t;
+    }
+
+    /** Returns the managed instance. */
+    public T get() {
+      return t;
+    }
+
+    @Override
+    public void close() {
+      if (!discard(t)) {
+        try {
+          t.close();
+        } catch (Exception e) {
+          logger.atWarning().withCause(e).log(
+              "Failed to close resource %s in CloseablePool %s", this, CloseablePool.this);
+        }
+      }
+    }
+  }
+}
diff --git a/java/com/google/gerrit/server/util/time/TimeUtil.java b/java/com/google/gerrit/server/util/time/TimeUtil.java
index 639d0a6..54ef305 100644
--- a/java/com/google/gerrit/server/util/time/TimeUtil.java
+++ b/java/com/google/gerrit/server/util/time/TimeUtil.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.util.git.DelegateSystemReader;
 import java.sql.Timestamp;
 import java.time.Instant;
+import java.util.concurrent.TimeUnit;
 import java.util.function.LongSupplier;
 import org.eclipse.jgit.util.SystemReader;
 
@@ -35,6 +36,10 @@
     return currentMillisSupplier.getAsLong();
   }
 
+  public static long nowNanos() {
+    return TimeUnit.NANOSECONDS.convert(TimeUtil.nowMs(), TimeUnit.MILLISECONDS);
+  }
+
   public static Instant now() {
     return Instant.ofEpochMilli(nowMs());
   }
diff --git a/java/com/google/gerrit/sshd/BUILD b/java/com/google/gerrit/sshd/BUILD
index 0668c1e..f3bd5e1 100644
--- a/java/com/google/gerrit/sshd/BUILD
+++ b/java/com/google/gerrit/sshd/BUILD
@@ -17,6 +17,7 @@
         "//java/com/google/gerrit/metrics",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/audit",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/git/receive",
         "//java/com/google/gerrit/server/ioutil",
         "//java/com/google/gerrit/server/logging",
diff --git a/java/com/google/gerrit/sshd/BaseCommand.java b/java/com/google/gerrit/sshd/BaseCommand.java
index 48a5512..f1be04e 100644
--- a/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/java/com/google/gerrit/sshd/BaseCommand.java
@@ -73,6 +73,7 @@
   static final int STATUS_NOT_FOUND = PRIVATE_STATUS | 2;
   public static final int STATUS_NOT_ADMIN = PRIVATE_STATUS | 3;
 
+  @SuppressWarnings("unused") // unused here, but triggers logic in EndOfOptionsHandler
   @Option(name = "--", usage = "end of options", handler = EndOfOptionsHandler.class)
   private boolean endOfOptions;
 
@@ -370,7 +371,7 @@
         err.flush();
       } catch (IOException e2) {
         // Ignored
-      } catch (Throwable e2) {
+      } catch (RuntimeException e2) {
         logger.atWarning().withCause(e2).log("Cannot send failure message to client");
       }
       return f.exitCode;
@@ -381,7 +382,7 @@
       err.flush();
     } catch (IOException e2) {
       // Ignored
-    } catch (Throwable e2) {
+    } catch (RuntimeException e2) {
       logger.atWarning().withCause(e2).log("Cannot send internal server error message to client");
     }
     return 128;
@@ -500,15 +501,15 @@
 
           out.flush();
           err.flush();
-        } catch (Throwable e) {
+        } catch (Exception e) {
           try {
             out.flush();
-          } catch (Throwable e2) {
+          } catch (Exception e2) {
             // Ignored
           }
           try {
             err.flush();
-          } catch (Throwable e2) {
+          } catch (Exception e2) {
             // Ignored
           }
           rc = handleError(e);
diff --git a/java/com/google/gerrit/sshd/SshCommand.java b/java/com/google/gerrit/sshd/SshCommand.java
index c94b25c..9df263b 100644
--- a/java/com/google/gerrit/sshd/SshCommand.java
+++ b/java/com/google/gerrit/sshd/SshCommand.java
@@ -14,11 +14,17 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.common.base.Throwables;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.AccessPath;
+import com.google.gerrit.server.CancellationMetrics;
+import com.google.gerrit.server.DeadlineChecker;
 import com.google.gerrit.server.DynamicOptions;
+import com.google.gerrit.server.InvalidDeadlineException;
 import com.google.gerrit.server.RequestInfo;
 import com.google.gerrit.server.RequestListener;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateContext;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.logging.PerformanceLogContext;
 import com.google.gerrit.server.logging.PerformanceLogger;
@@ -27,6 +33,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.io.PrintWriter;
+import java.util.Optional;
 import org.apache.sshd.server.Environment;
 import org.apache.sshd.server.channel.ChannelSession;
 import org.eclipse.jgit.lib.Config;
@@ -36,6 +43,8 @@
   @Inject private DynamicSet<PerformanceLogger> performanceLoggers;
   @Inject private PluginSetContext<RequestListener> requestListeners;
   @Inject @GerritServerConfig private Config config;
+  @Inject private DeadlineChecker.Factory deadlineCheckerFactory;
+  @Inject private CancellationMetrics cancellationMetrics;
 
   @Option(name = "--trace", usage = "enable request tracing")
   private boolean trace;
@@ -43,6 +52,9 @@
   @Option(name = "--trace-id", usage = "trace ID (can only be set if --trace was set too)")
   private String traceId;
 
+  @Option(name = "--deadline", usage = "deadline after which the request should be aborted)")
+  private String deadline;
+
   protected PrintWriter stdout;
   protected PrintWriter stderr;
 
@@ -59,8 +71,31 @@
                     new PerformanceLogContext(config, performanceLoggers)) {
               RequestInfo requestInfo =
                   RequestInfo.builder(RequestInfo.RequestType.SSH, user, traceContext).build();
-              requestListeners.runEach(l -> l.onRequest(requestInfo));
-              SshCommand.this.run();
+              try (RequestStateContext requestStateContext =
+                  RequestStateContext.open()
+                      .addRequestStateProvider(
+                          deadlineCheckerFactory.create(requestInfo, deadline))) {
+                requestListeners.runEach(l -> l.onRequest(requestInfo));
+                SshCommand.this.run();
+              } catch (InvalidDeadlineException e) {
+                stderr.println(e.getMessage());
+              } catch (RuntimeException e) {
+                Optional<RequestCancelledException> requestCancelledException =
+                    RequestCancelledException.getFromCausalChain(e);
+                if (!requestCancelledException.isPresent()) {
+                  Throwables.throwIfUnchecked(e);
+                }
+                cancellationMetrics.countCancelledRequest(
+                    requestInfo, requestCancelledException.get().getCancellationReason());
+                StringBuilder msg =
+                    new StringBuilder(requestCancelledException.get().formatCancellationReason());
+                if (requestCancelledException.get().getCancellationMessage().isPresent()) {
+                  msg.append(
+                      String.format(
+                          " (%s)", requestCancelledException.get().getCancellationMessage().get()));
+                }
+                stderr.println(msg.toString());
+              }
             } finally {
               stdout.flush();
               stderr.flush();
diff --git a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 773c25b..628a050 100644
--- a/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.account.AccountSshKey;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.logging.Metadata;
@@ -97,11 +98,16 @@
   static class Loader extends CacheLoader<String, Iterable<SshKeyCacheEntry>> {
     private final ExternalIds externalIds;
     private final VersionedAuthorizedKeys.Accessor authorizedKeys;
+    private final ExternalIdKeyFactory externalIdKeyFactory;
 
     @Inject
-    Loader(ExternalIds externalIds, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+    Loader(
+        ExternalIds externalIds,
+        VersionedAuthorizedKeys.Accessor authorizedKeys,
+        ExternalIdKeyFactory externalIdKeyFactory) {
       this.externalIds = externalIds;
       this.authorizedKeys = authorizedKeys;
+      this.externalIdKeyFactory = externalIdKeyFactory;
     }
 
     @Override
@@ -111,7 +117,7 @@
               "Loading SSH keys for account with username",
               Metadata.builder().username(username).build())) {
         Optional<ExternalId> user =
-            externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+            externalIds.get(externalIdKeyFactory.create(SCHEME_USERNAME, username));
         if (!user.isPresent()) {
           return NO_SUCH_USER;
         }
@@ -138,7 +144,7 @@
         // to do with the key object, and instead we must abort this load.
         //
         throw e;
-      } catch (Throwable e) {
+      } catch (Exception e) {
         markInvalid(k);
       }
     }
diff --git a/java/com/google/gerrit/sshd/SshLog.java b/java/com/google/gerrit/sshd/SshLog.java
index 616f7d1..7c96342 100644
--- a/java/com/google/gerrit/sshd/SshLog.java
+++ b/java/com/google/gerrit/sshd/SshLog.java
@@ -92,7 +92,7 @@
     }
   }
 
-  /** @return true if a change in state has occurred */
+  /** Returns true if a change in state has occurred */
   public boolean enableLogging() {
     synchronized (lock) {
       if (async == null) {
@@ -112,7 +112,7 @@
     }
   }
 
-  /** @return true if a change in state has occurred */
+  /** Returns true if a change in state has occurred */
   public boolean disableLogging() {
     synchronized (lock) {
       if (async != null) {
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index 9301f8a..ca452c1 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits.AsyncReceiveCommitsModule;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
@@ -67,7 +67,7 @@
     bind(SshScope.class).in(SINGLETON);
 
     configureRequestScope();
-    install(new AsyncReceiveCommits.Module());
+    install(new AsyncReceiveCommitsModule());
     configureAliases();
 
     bind(SshLog.class);
diff --git a/java/com/google/gerrit/sshd/SshSession.java b/java/com/google/gerrit/sshd/SshSession.java
index b39eaed..d545844 100644
--- a/java/com/google/gerrit/sshd/SshSession.java
+++ b/java/com/google/gerrit/sshd/SshSession.java
@@ -114,7 +114,7 @@
     identity.setAccessPath(path);
   }
 
-  /** @return {@code true} if the authentication did not succeed. */
+  /** Returns {@code true} if the authentication did not succeed. */
   boolean isAuthenticationError() {
     return authError != null;
   }
diff --git a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
index 8ee6a0d..e7fe22f 100644
--- a/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
+++ b/java/com/google/gerrit/sshd/commands/DefaultCommandModule.java
@@ -21,15 +21,15 @@
 import com.google.gerrit.sshd.Commands;
 import com.google.gerrit.sshd.DispatchCommandProvider;
 import com.google.gerrit.sshd.SuExec;
-import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand;
+import com.google.gerrit.sshd.plugin.LfsPluginAuthCommand.LfsPluginAuthCommandModule;
 
 /** Register the commands a Gerrit server supports. */
 public class DefaultCommandModule extends CommandModule {
   private final DownloadConfig downloadConfig;
-  private final LfsPluginAuthCommand.Module lfsPluginAuthModule;
+  private final LfsPluginAuthCommandModule lfsPluginAuthModule;
 
   public DefaultCommandModule(
-      boolean slave, DownloadConfig downloadCfg, LfsPluginAuthCommand.Module module) {
+      boolean slave, DownloadConfig downloadCfg, LfsPluginAuthCommandModule module) {
     slaveMode = slave;
     downloadConfig = downloadCfg;
     lfsPluginAuthModule = module;
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
new file mode 100644
index 0000000..29cc1cf
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCaseSensitivityMigrationCommand.java
@@ -0,0 +1,50 @@
+// 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.sshd.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigrator;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Config;
+
+@RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
+@CommandMetaData(
+    name = "migrate-externalids-to-insensitive",
+    description = "Migrate external-ids to case insensitive")
+public class ExternalIdCaseSensitivityMigrationCommand extends SshCommand {
+
+  @Inject OnlineExternalIdCaseSensivityMigrator onlineExternalIdCaseSensivityMigrator;
+  @Inject @GerritServerConfig private Config globalConfig;
+
+  @Override
+  public void run() throws UnloggedFailure, Failure, Exception {
+    Boolean isUserNameCaseInsensitiveMigrationMode =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitiveMigrationMode", false);
+    Boolean isUserNameCaseInsensitive =
+        globalConfig.getBoolean("auth", "userNameCaseInsensitive", false);
+
+    if (!isUserNameCaseInsensitive || !isUserNameCaseInsensitiveMigrationMode) {
+      die(
+          "External IDs online migration requires auth.userNameCaseInsensitive and auth.userNameCaseInsensitiveMigrationMode to be set to true. Cannot start migration!");
+    }
+    onlineExternalIdCaseSensivityMigrator.migrate();
+    stdout.println(
+        "External ids case insensitivity migration started. To check if it's completed look for \"External IDs migration completed!\" message in the Gerrit server logs");
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
new file mode 100644
index 0000000..57bf9e5
--- /dev/null
+++ b/java/com/google/gerrit/sshd/commands/ExternalIdCommandsModule.java
@@ -0,0 +1,40 @@
+// 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.sshd.commands;
+
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigratiorExecutor;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigrator;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.sshd.CommandModule;
+import com.google.gerrit.sshd.Commands;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import java.util.concurrent.ExecutorService;
+
+public class ExternalIdCommandsModule extends CommandModule {
+
+  @Override
+  protected void configure() {
+    bind(OnlineExternalIdCaseSensivityMigrator.class);
+    command(Commands.named("gerrit"), ExternalIdCaseSensitivityMigrationCommand.class);
+  }
+
+  @Provides
+  @Singleton
+  @OnlineExternalIdCaseSensivityMigratiorExecutor
+  public ExecutorService OnlineExternalIdCaseSensivityMigratiorExecutor(WorkQueue queues) {
+    return queues.createQueue(1, "MigrateExternalIdCase", true);
+  }
+}
diff --git a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
index 28a7804..63a01d00 100644
--- a/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
+++ b/java/com/google/gerrit/sshd/commands/GarbageCollectionCommand.java
@@ -21,6 +21,7 @@
 
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.common.data.GarbageCollectionResult.GcError;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.server.git.GarbageCollection;
@@ -89,7 +90,7 @@
             .create()
             .run(projectNames, aggressive, showProgress ? stdout : null);
     if (result.hasErrors()) {
-      for (GarbageCollectionResult.Error e : result.getErrors()) {
+      for (GcError e : result.getErrors()) {
         String msg;
         switch (e.getType()) {
           case REPOSITORY_NOT_FOUND:
diff --git a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index 43a1670..0c286ca 100644
--- a/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd.commands;
 
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.RawInputUtil;
@@ -36,6 +37,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.permissions.GlobalPermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackendException;
@@ -43,6 +45,7 @@
 import com.google.gerrit.server.restapi.account.CreateEmail;
 import com.google.gerrit.server.restapi.account.DeleteActive;
 import com.google.gerrit.server.restapi.account.DeleteEmail;
+import com.google.gerrit.server.restapi.account.DeleteExternalIds;
 import com.google.gerrit.server.restapi.account.DeleteSshKey;
 import com.google.gerrit.server.restapi.account.GetEmails;
 import com.google.gerrit.server.restapi.account.GetSshKeys;
@@ -122,10 +125,18 @@
   @Option(name = "--generate-http-password", usage = "generate a new HTTP password for the account")
   private boolean generateHttpPassword;
 
+  @Option(
+      name = "--delete-external-id",
+      metaVar = "EXTERNALID",
+      usage = "external id to delete from the account")
+  private List<String> externalIdsToDelete = new ArrayList<>();
+
   @Inject private IdentifiedUser.GenericFactory genericUserFactory;
 
   @Inject private CreateEmail createEmail;
 
+  @Inject private DeleteExternalIds deleteExternalIds;
+
   @Inject private GetEmails getEmails;
 
   @Inject private DeleteEmail deleteEmail;
@@ -150,6 +161,8 @@
 
   @Inject private Provider<CurrentUser> userProvider;
 
+  @Inject private ExternalIds externalIds;
+
   private AccountResource rsrc;
 
   @Override
@@ -210,6 +223,9 @@
           "--preferred-email and --delete-email options are mutually "
               + "exclusive for the same email address.");
     }
+    if (externalIdsToDelete.contains("ALL")) {
+      externalIdsToDelete = Collections.singletonList("ALL");
+    }
   }
 
   private void setAccount() throws Failure {
@@ -265,6 +281,10 @@
       if (!deleteSshKeys.isEmpty()) {
         deleteSshKeys(deleteSshKeys);
       }
+
+      for (String externalId : externalIdsToDelete) {
+        deleteExternalId(externalId);
+      }
     } catch (RestApiException e) {
       throw die(e.getMessage());
     } catch (Exception e) {
@@ -355,4 +375,21 @@
     }
     return sshKeys;
   }
+
+  private void deleteExternalId(String externalId)
+      throws IOException, RestApiException, ConfigInvalidException, PermissionBackendException {
+    List<String> ids;
+    if (externalId.equals("ALL")) {
+      ids =
+          externalIds.byAccount(rsrc.getUser().getAccountId()).stream()
+              .map(e -> e.key().get())
+              .collect(toList());
+      if (ids.isEmpty()) {
+        throw new ResourceNotFoundException("Account has no external Ids");
+      }
+    } else {
+      ids = Collections.singletonList(externalId);
+    }
+    deleteExternalIds.apply(rsrc, ids);
+  }
 }
diff --git a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index 95627e1..4f23d1d 100644
--- a/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.exceptions.StorageException;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ReviewerResource;
@@ -136,7 +136,7 @@
     // Add reviewers
     //
     for (String reviewer : toAdd) {
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = reviewer;
       input.confirmed = true;
       String error;
diff --git a/java/com/google/gerrit/sshd/commands/UploadArchive.java b/java/com/google/gerrit/sshd/commands/UploadArchive.java
index 0eda433..c1f4a7b 100644
--- a/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -214,16 +214,16 @@
       } catch (GitAPIException e) {
         throw new Failure(7, "fatal: git api exception, " + e);
       }
-    } catch (Throwable t) {
+    } catch (Exception e) {
       // Report the error in ERROR sideband channel. Catch Throwable too so we can also catch
       // NoClassDefFound.
       try (SideBandOutputStream sidebandError =
           new SideBandOutputStream(
               SideBandOutputStream.CH_ERROR, SideBandOutputStream.MAX_BUF, out)) {
-        sidebandError.write(t.getMessage().getBytes(UTF_8));
+        sidebandError.write(e.getMessage().getBytes(UTF_8));
         sidebandError.flush();
       }
-      throw t;
+      throw e;
     } finally {
       // In any case, cleanly close the packetOut channel
       packetOut.end();
diff --git a/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java b/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
index 8fb2461..8ba673a 100644
--- a/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
+++ b/java/com/google/gerrit/sshd/plugin/LfsPluginAuthCommand.java
@@ -36,11 +36,11 @@
     String authenticate(CurrentUser user, List<String> args) throws UnloggedFailure, Failure;
   }
 
-  public static class Module extends CommandModule {
+  public static class LfsPluginAuthCommandModule extends CommandModule {
     private final boolean pluginProvided;
 
     @Inject
-    Module(@GerritServerConfig Config cfg) {
+    LfsPluginAuthCommandModule(@GerritServerConfig Config cfg) {
       pluginProvided = cfg.getString("lfs", null, "plugin") != null;
     }
 
diff --git a/java/com/google/gerrit/testing/BUILD b/java/com/google/gerrit/testing/BUILD
index 93c996e8..be32138 100644
--- a/java/com/google/gerrit/testing/BUILD
+++ b/java/com/google/gerrit/testing/BUILD
@@ -12,6 +12,7 @@
         "//lib:junit",
         "//lib/mockito",
     ],
+    runtime_deps = ["//java/com/google/gerrit/index/testing"],
     deps = [
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
diff --git a/java/com/google/gerrit/testing/ConfigSuite.java b/java/com/google/gerrit/testing/ConfigSuite.java
index d7ae397..3101c48 100644
--- a/java/com/google/gerrit/testing/ConfigSuite.java
+++ b/java/com/google/gerrit/testing/ConfigSuite.java
@@ -25,7 +25,9 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import java.lang.annotation.Annotation;
+import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 import java.lang.annotation.Target;
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
@@ -35,11 +37,15 @@
 import java.lang.reflect.Type;
 import java.util.List;
 import java.util.Map;
+import org.junit.internal.runners.statements.RunAfters;
+import org.junit.internal.runners.statements.RunBefores;
+import org.junit.rules.TestRule;
 import org.junit.runner.Runner;
 import org.junit.runners.BlockJUnit4ClassRunner;
 import org.junit.runners.Suite;
 import org.junit.runners.model.FrameworkMethod;
 import org.junit.runners.model.InitializationError;
+import org.junit.runners.model.Statement;
 
 /**
  * Suite to run tests with different {@code gerrit.config} values.
@@ -47,6 +53,9 @@
  * <p>For each {@link Config} method in the class and base classes, a new group of tests is created
  * with the {@link Parameter} field set to the config.
  *
+ * <p>Additional actions can be executed before or after each group of tests using
+ * {@literal @}BeforeConfig, {@literal @}AfterConfig or {@literal @}ConfigRule annotations.
+ *
  * <pre>
  * {@literal @}RunWith(ConfigSuite.class)
  * public abstract class MyAbstractTest {
@@ -128,6 +137,38 @@
   @Retention(RUNTIME)
   public static @interface Name {}
 
+  /**
+   * Annotation for methods which should be run after executing group of tests with a new
+   * configuration.
+   *
+   * <p>Works similar to {@link org.junit.AfterClass}, but a method can be executed multiple times
+   * if a test class provides multiple configs.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.METHOD)
+  public @interface AfterConfig {}
+
+  /**
+   * Annotation for methods which should be run before executing group of tests with a new
+   * configuration.
+   *
+   * <p>Works similar to {@link org.junit.BeforeClass}, but a method can be executed multiple times
+   * if a test class provides multiple configs.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.METHOD)
+  public @interface BeforeConfig {}
+
+  /**
+   * Annotation for fields or methods which wraps all tests with the same config
+   *
+   * <p>Works similar to {@link org.junit.ClassRule}, but Statement evaluates multiple time - ones
+   * for each config provided by a test class.
+   */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target({ElementType.FIELD, ElementType.METHOD})
+  public @interface ConfigRule {}
+
   private static class ConfigRunner extends BlockJUnit4ClassRunner {
     private final org.eclipse.jgit.lib.Config cfg;
     private final Field parameterField;
@@ -168,6 +209,26 @@
       String n = method.getName();
       return name == null ? n : n + "[" + name + "]";
     }
+
+    @Override
+    protected Statement withBeforeClasses(Statement statement) {
+      List<FrameworkMethod> befores = getTestClass().getAnnotatedMethods(BeforeConfig.class);
+      return befores.isEmpty() ? statement : new RunBefores(statement, befores, null);
+    }
+
+    @Override
+    protected Statement withAfterClasses(Statement statement) {
+      List<FrameworkMethod> afters = getTestClass().getAnnotatedMethods(AfterConfig.class);
+      return afters.isEmpty() ? statement : new RunAfters(statement, afters, null);
+    }
+
+    @Override
+    protected List<TestRule> classRules() {
+      List<TestRule> result =
+          getTestClass().getAnnotatedMethodValues(null, ConfigRule.class, TestRule.class);
+      result.addAll(getTestClass().getAnnotatedFieldValues(null, ConfigRule.class, TestRule.class));
+      return result;
+    }
   }
 
   private static List<Runner> runnersFor(Class<?> clazz) {
diff --git a/java/com/google/gerrit/testing/FakeEmailSender.java b/java/com/google/gerrit/testing/FakeEmailSender.java
index fec9b27..918a622 100644
--- a/java/com/google/gerrit/testing/FakeEmailSender.java
+++ b/java/com/google/gerrit/testing/FakeEmailSender.java
@@ -49,7 +49,7 @@
 public class FakeEmailSender implements EmailSender {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  public static class Module extends AbstractModule {
+  public static class FakeEmailSenderModule extends AbstractModule {
     @Override
     public void configure() {
       bind(EmailSender.class).to(FakeEmailSender.class);
diff --git a/java/com/google/gerrit/testing/GerritJUnit.java b/java/com/google/gerrit/testing/GerritJUnit.java
index 0771c39..e80afa9 100644
--- a/java/com/google/gerrit/testing/GerritJUnit.java
+++ b/java/com/google/gerrit/testing/GerritJUnit.java
@@ -26,11 +26,11 @@
    * <p>This construction is recommended by the Truth team for use in conjunction with asserting
    * over a {@code ThrowableSubject} on the return type:
    *
-   * <pre>
-   *   MyException e = assertThrows(MyException.class, () -> doSomething(foo));
-   *   assertThat(e).isInstanceOf(MySubException.class);
-   *   assertThat(e).hasMessageThat().contains("sub-exception occurred");
-   * </pre>
+   * <pre>{@code
+   * MyException e = assertThrows(MyException.class, () -> doSomething(foo));
+   * assertThat(e).isInstanceOf(MySubException.class);
+   * assertThat(e).hasMessageThat().contains("sub-exception occurred");
+   * }</pre>
    *
    * @param throwableClass expected exception type.
    * @param runnable runnable containing arbitrary code.
diff --git a/java/com/google/gerrit/testing/GerritServerTests.java b/java/com/google/gerrit/testing/GerritServerTests.java
index 363a07d..752c13d 100644
--- a/java/com/google/gerrit/testing/GerritServerTests.java
+++ b/java/com/google/gerrit/testing/GerritServerTests.java
@@ -26,7 +26,6 @@
 @RunWith(ConfigSuite.class)
 public class GerritServerTests {
   @ConfigSuite.Parameter public Config config;
-  @ConfigSuite.Name private String configName;
 
   @Rule
   public TestRule testRunner =
diff --git a/java/com/google/gerrit/testing/InMemoryModule.java b/java/com/google/gerrit/testing/InMemoryModule.java
index 1c322b2..b9daa13 100644
--- a/java/com/google/gerrit/testing/InMemoryModule.java
+++ b/java/com/google/gerrit/testing/InMemoryModule.java
@@ -44,6 +44,7 @@
 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.AllProjectsConfigProvider;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllProjectsNameProvider;
 import com.google.gerrit.server.config.AllUsersName;
@@ -53,7 +54,9 @@
 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;
+import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
+import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
+import com.google.gerrit.server.config.FileBasedGlobalPluginConfigProvider;
 import com.google.gerrit.server.config.GerritGlobalModule;
 import com.google.gerrit.server.config.GerritInstanceIdModule;
 import com.google.gerrit.server.config.GerritInstanceNameModule;
@@ -62,14 +65,16 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.config.GerritServerIdProvider;
+import com.google.gerrit.server.config.GlobalPluginConfigProvider;
 import com.google.gerrit.server.config.SendEmailExecutor;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.config.TrackingFootersProvider;
+import com.google.gerrit.server.experiments.ConfigExperimentFeatures.ConfigExperimentFeaturesModule;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.PerThreadRequestScope;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
+import com.google.gerrit.server.git.SearchingChangeCacheImpl.SearchingChangeCacheImplModule;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
@@ -78,11 +83,11 @@
 import com.google.gerrit.server.index.group.AllGroupsIndexer;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
+import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier.SignedTokenEmailTokenVerifierModule;
 import com.google.gerrit.server.patch.DiffExecutor;
 import com.google.gerrit.server.permissions.DefaultPermissionBackendModule;
 import com.google.gerrit.server.plugins.ServerInformationImpl;
-import com.google.gerrit.server.project.DefaultProjectNameLockManager;
+import com.google.gerrit.server.project.DefaultProjectNameLockManager.DefaultProjectNameLockManagerModule;
 import com.google.gerrit.server.restapi.RestApiModule;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
 import com.google.gerrit.server.schema.SchemaCreator;
@@ -90,10 +95,11 @@
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.gerrit.server.ssh.NoSshKeyCache;
-import com.google.gerrit.server.submit.LocalMergeSuperSetComputation;
-import com.google.gerrit.server.submit.SubscriptionGraph;
-import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener;
+import com.google.gerrit.server.submit.LocalMergeSuperSetComputation.LocalMergeSuperSetComputationModule;
+import com.google.gerrit.server.submit.SubscriptionGraph.SubscriptionGraphModule;
+import com.google.gerrit.server.update.SuperprojectUpdateSubmissionListener.SuperprojectUpdateSubmissionListenerModule;
 import com.google.gerrit.server.util.ReplicaUtil;
+import com.google.gerrit.testing.FakeEmailSender.FakeEmailSenderModule;
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
@@ -131,7 +137,6 @@
     cfg.setString("user", null, "name", "Gerrit Code Review");
     cfg.setString("user", null, "email", "gerrit@localhost");
     cfg.unset("cache", null, "directory");
-    cfg.setString("index", null, "type", "lucene");
     cfg.setBoolean("index", "lucene", "testInmemory", true);
     cfg.setInt("sendemail", null, "threadPoolSize", 0);
     cfg.setBoolean("receive", null, "enableSignedPush", false);
@@ -181,11 +186,11 @@
     factory(PluginUser.Factory.class);
     install(new PluginApiModule());
     install(new DefaultPermissionBackendModule());
-    install(new SearchingChangeCacheImpl.Module());
+    install(new SearchingChangeCacheImplModule());
     factory(GarbageCollection.Factory.class);
     install(new AuditModule());
-    install(new SubscriptionGraph.Module());
-    install(new SuperprojectUpdateSubmissionListener.Module());
+    install(new SubscriptionGraphModule());
+    install(new SuperprojectUpdateSubmissionListenerModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
@@ -193,7 +198,9 @@
     // support Path-based Configs, only FileBasedConfig.
     bind(Path.class).annotatedWith(SitePath.class).toInstance(Paths.get("."));
     bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(cfg);
-    bind(GerritOptions.class).toInstance(new GerritOptions(false, false, ""));
+    bind(GerritOptions.class).toInstance(new GerritOptions(false, false));
+    bind(AllProjectsConfigProvider.class).to(FileBasedAllProjectsConfigProvider.class);
+    bind(GlobalPluginConfigProvider.class).to(FileBasedGlobalPluginConfigProvider.class);
 
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
@@ -211,7 +218,7 @@
             return CanonicalWebUrlProvider.class;
           }
         });
-    install(new DefaultUrlFormatter.Module());
+    install(new DefaultUrlFormatterModule());
     // Replacement of DiffExecutorModule to not use thread pool in the tests
     install(
         new AbstractModule() {
@@ -227,28 +234,30 @@
         });
     install(new DefaultMemoryCacheModule());
     install(new H2CacheModule());
-    install(new FakeEmailSender.Module());
-    install(new SignedTokenEmailTokenVerifier.Module());
+    install(new FakeEmailSenderModule());
+    install(new SignedTokenEmailTokenVerifierModule());
     install(new GpgModule(cfg));
-    install(new LocalMergeSuperSetComputation.Module());
+    install(new LocalMergeSuperSetComputationModule());
 
     bind(AllAccountsIndexer.class).toProvider(Providers.of(null));
     bind(AllChangesIndexer.class).toProvider(Providers.of(null));
     bind(AllGroupsIndexer.class).toProvider(Providers.of(null));
 
-    IndexType indexType = new IndexType(cfg.getString("index", null, "type"));
+    String indexTypeCfg = cfg.getString("index", null, "type");
+    IndexType indexType = new IndexType(indexTypeCfg != null ? indexTypeCfg : "fake");
     // For custom index types, callers must provide their own module.
     if (indexType.isLucene()) {
       install(luceneIndexModule());
-    } else if (indexType.isElasticsearch()) {
-      install(elasticIndexModule());
+    } else if (indexType.isFake()) {
+      install(fakeIndexModule());
     }
     bind(ServerInformationImpl.class);
     bind(ServerInformation.class).to(ServerInformationImpl.class);
     install(new RestApiModule());
     install(new OAuthRestModule());
-    install(new DefaultProjectNameLockManager.Module());
-    install(new FileInfoJsonModule(cfg));
+    install(new DefaultProjectNameLockManagerModule());
+    install(new FileInfoJsonModule());
+    install(new ConfigExperimentFeaturesModule());
 
     bind(ProjectOperations.class).to(ProjectOperationsImpl.class);
   }
@@ -313,8 +322,8 @@
     return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
   }
 
-  private Module elasticIndexModule() {
-    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
+  private Module fakeIndexModule() {
+    return indexModule("com.google.gerrit.index.testing.FakeIndexModule");
   }
 
   private Module indexModule(String moduleClassName) {
diff --git a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
index 09ae115..362e23c 100644
--- a/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
+++ b/java/com/google/gerrit/testing/InMemoryRepositoryManager.java
@@ -17,6 +17,7 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.inject.Inject;
@@ -79,6 +80,16 @@
   }
 
   @Override
+  public synchronized Status getRepositoryStatus(NameKey name) {
+    try {
+      get(name);
+      return Status.ACTIVE;
+    } catch (RepositoryNotFoundException e) {
+      return Status.NON_EXISTENT;
+    }
+  }
+
+  @Override
   public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
     return get(name);
   }
diff --git a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
index 44d5cea..77df46c 100644
--- a/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
+++ b/java/com/google/gerrit/testing/InMemoryTestEnvironment.java
@@ -46,6 +46,7 @@
   @Inject private IdentifiedUser.GenericFactory userFactory;
   @Inject private SchemaCreator schemaCreator;
   @Inject private ThreadLocalRequestContext requestContext;
+  @Inject private AuthRequest.Factory authRequestFactory;
 
   private LifecycleManager lifecycle;
 
@@ -99,7 +100,8 @@
     schemaCreator.create();
 
     // The first user is added to the "Administrators" group. See AccountManager#create().
-    setApiUser(accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId());
+    setApiUser(
+        accountManager.authenticate(authRequestFactory.createForUser("admin")).getAccountId());
 
     // Inject target members after setting API user, so it can @Inject request-scoped objects if it
     // wants.
diff --git a/java/com/google/gerrit/testing/IndexConfig.java b/java/com/google/gerrit/testing/IndexConfig.java
index a8ed5be..13b346f 100644
--- a/java/com/google/gerrit/testing/IndexConfig.java
+++ b/java/com/google/gerrit/testing/IndexConfig.java
@@ -38,16 +38,12 @@
   }
 
   public static Config createForLucene() {
-    return create();
+    Config cfg = create();
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
   }
 
-  public static Config createForElasticsearch() {
-    Config cfg = create();
-
-    // For some reason enabling the staleness checker increases the flakiness of the Elasticsearch
-    // tests. Hence disable the staleness checker.
-    cfg.setBoolean("index", null, "autoReindexIfStale", false);
-
-    return cfg;
+  public static Config createForFake() {
+    return create();
   }
 }
diff --git a/java/com/google/gerrit/testing/SystemPropertiesTestRule.java b/java/com/google/gerrit/testing/SystemPropertiesTestRule.java
new file mode 100644
index 0000000..c7a3542
--- /dev/null
+++ b/java/com/google/gerrit/testing/SystemPropertiesTestRule.java
@@ -0,0 +1,67 @@
+// 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.testing;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.rules.ExternalResource;
+
+/** Setup system properties before tests and return previous value after tests are finished */
+public class SystemPropertiesTestRule extends ExternalResource {
+  ImmutableMap<String, Optional<String>> properties;
+  @Nullable ImmutableMap<String, Optional<String>> previousValues;
+
+  public SystemPropertiesTestRule(String key, String value) {
+    this(ImmutableMap.of(key, Optional.of(value)));
+  }
+
+  public SystemPropertiesTestRule(Map<String, Optional<String>> properties) {
+    this.properties = ImmutableMap.copyOf(properties);
+  }
+
+  @Override
+  protected void before() throws Throwable {
+    super.before();
+    checkState(
+        previousValues == null,
+        "after() wasn't called after the previous call to the before() method");
+    ImmutableMap.Builder<String, Optional<String>> previousValuesBuilder = ImmutableMap.builder();
+    for (String key : properties.keySet()) {
+      previousValuesBuilder.put(key, Optional.ofNullable(System.getProperty(key)));
+    }
+    previousValues = previousValuesBuilder.build();
+    properties.entrySet().stream().forEach(this::setSystemProperty);
+  }
+
+  @Override
+  protected void after() {
+    checkState(previousValues != null, "before() wasn't called");
+    previousValues.entrySet().stream().forEach(this::setSystemProperty);
+    previousValues = null;
+    super.after();
+  }
+
+  private void setSystemProperty(Map.Entry<String, Optional<String>> keyValue) {
+    if (keyValue.getValue().isPresent()) {
+      System.setProperty(keyValue.getKey(), keyValue.getValue().get());
+    } else {
+      System.clearProperty(keyValue.getKey());
+    }
+  }
+}
diff --git a/java/com/google/gerrit/testing/TestLoggingActivator.java b/java/com/google/gerrit/testing/TestLoggingActivator.java
index 6b5d8fd..ee1a525 100644
--- a/java/com/google/gerrit/testing/TestLoggingActivator.java
+++ b/java/com/google/gerrit/testing/TestLoggingActivator.java
@@ -48,11 +48,6 @@
           .put("org.openid4java.server.RealmVerifier", Level.ERROR)
           .put("org.openid4java.message.AuthSuccess", Level.ERROR)
 
-          // Silence non-critical messages from c3p0 (if used).
-          .put("com.mchange.v2.c3p0", Level.WARN)
-          .put("com.mchange.v2.resourcepool", Level.WARN)
-          .put("com.mchange.v2.sql", Level.WARN)
-
           // Silence non-critical messages from apache.http.
           .put("org.apache.http", Level.WARN)
 
@@ -67,13 +62,6 @@
           .put("org.eclipse.jgit.internal.storage.file.FileSnapshot", Level.WARN)
           .put("org.eclipse.jgit.util.FS", Level.WARN)
           .put("org.eclipse.jgit.util.SystemReader", Level.WARN)
-
-          // Silence non-critical messages from Elasticsearch.
-          .put("org.elasticsearch", Level.WARN)
-
-          // Silence non-critical messages from Docker for Elasticsearch query tests.
-          .put("org.testcontainers", Level.WARN)
-          .put("com.github.dockerjava.core", Level.WARN)
           .build();
 
   private static Level getGerritLogLevel() {
diff --git a/java/com/google/gerrit/util/http/RequestUtil.java b/java/com/google/gerrit/util/http/RequestUtil.java
index 92d9967..f64ce5a 100644
--- a/java/com/google/gerrit/util/http/RequestUtil.java
+++ b/java/com/google/gerrit/util/http/RequestUtil.java
@@ -31,8 +31,8 @@
   }
 
   /**
-   * @return the same value as {@link HttpServletRequest#getPathInfo()}, but without decoding
-   *     URL-encoded characters.
+   * Returns the same value as {@link HttpServletRequest#getPathInfo()}, but without decoding
+   * URL-encoded characters.
    */
   public static String getEncodedPathInfo(HttpServletRequest req) {
     // CS IGNORE LineLength FOR NEXT 3 LINES. REASON: URL.
diff --git a/java/gerrit/BUILD b/java/gerrit/BUILD
index db831b7..fea2696 100644
--- a/java/gerrit/BUILD
+++ b/java/gerrit/BUILD
@@ -5,6 +5,7 @@
     srcs = glob(["**/*.java"]),
     visibility = ["//visibility:public"],
     deps = [
+        "//java/com/google/gerrit/common:annotations",
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
         "//java/com/google/gerrit/server",
diff --git a/java/gerrit/PRED__load_commit_labels_1.java b/java/gerrit/PRED__load_commit_labels_1.java
index 5ee292ff..9a656b8 100644
--- a/java/gerrit/PRED__load_commit_labels_1.java
+++ b/java/gerrit/PRED__load_commit_labels_1.java
@@ -16,6 +16,7 @@
 import com.googlecode.prolog_cafe.lang.StructureTerm;
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
+import java.util.Optional;
 
 /** Exports list of {@code commit_label( label('Code-Review', 2), user(12345789) )}. */
 class PRED__load_commit_labels_1 extends Predicate.P1 {
@@ -38,13 +39,14 @@
     LabelTypes types = cd.getLabelTypes();
 
     for (PatchSetApproval a : cd.currentApprovals()) {
-      LabelType t = types.byLabel(a.labelId());
-      if (t == null) {
+      Optional<LabelType> t = types.byLabel(a.labelId());
+      if (!t.isPresent()) {
         continue;
       }
 
       StructureTerm labelTerm =
-          new StructureTerm(sym_label, SymbolTerm.intern(t.getName()), new IntegerTerm(a.value()));
+          new StructureTerm(
+              sym_label, SymbolTerm.intern(t.get().getName()), new IntegerTerm(a.value()));
 
       StructureTerm userTerm = new StructureTerm(sym_user, new IntegerTerm(a.accountId().get()));
 
diff --git a/java/gerrit/PRED_commit_edits_2.java b/java/gerrit/PRED_commit_edits_2.java
index 12e7086..6083010 100644
--- a/java/gerrit/PRED_commit_edits_2.java
+++ b/java/gerrit/PRED_commit_edits_2.java
@@ -14,10 +14,13 @@
 
 package gerrit;
 
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.FilePathAdapter;
 import com.google.gerrit.server.patch.Text;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
+import com.google.gerrit.server.patch.filediff.TaggedEdit;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
 import com.googlecode.prolog_cafe.exceptions.JavaException;
@@ -31,7 +34,9 @@
 import com.googlecode.prolog_cafe.lang.VariableTerm;
 import java.io.IOException;
 import java.util.List;
+import java.util.Map;
 import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.errors.CorruptObjectException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -40,7 +45,6 @@
 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.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.treewalk.TreeWalk;
@@ -69,27 +73,26 @@
     Pattern fileRegex = getRegexParameter(a1);
     Pattern editRegex = getRegexParameter(a2);
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    Map<String, FileDiffOutput> modifiedFiles = StoredValues.DIFF_LIST.get(engine);
+    FileDiffOutput firstDiff = Iterables.getFirst(modifiedFiles.values(), /* defaultValue= */ null);
+    if (firstDiff == null) {
+      // No available diffs. We cannot identify old and new commit IDs.
+      engine.fail();
+    }
     Repository repo = StoredValues.REPOSITORY.get(engine);
 
     try (ObjectReader reader = repo.newObjectReader();
         RevWalk rw = new RevWalk(reader)) {
-      final RevTree aTree;
-      final RevTree bTree;
-      final RevCommit bCommit = rw.parseCommit(pl.getNewId());
+      final RevTree aTree =
+          firstDiff.oldCommitId().equals(ObjectId.zeroId())
+              ? null
+              : rw.parseTree(firstDiff.oldCommitId());
+      final RevTree bTree = rw.parseCommit(firstDiff.newCommitId()).getTree();
 
-      if (pl.getOldId() != null) {
-        aTree = rw.parseTree(pl.getOldId());
-      } else {
-        // Octopus merge with unknown automatic merge result, since the
-        // web UI returns no files to match against, just fail.
-        return engine.fail();
-      }
-      bTree = bCommit.getTree();
-
-      for (PatchListEntry entry : pl.getPatches()) {
-        String newName = entry.getNewName();
-        String oldName = entry.getOldName();
+      for (FileDiffOutput entry : modifiedFiles.values()) {
+        String newName =
+            FilePathAdapter.getNewPath(entry.oldPath(), entry.newPath(), entry.changeType());
+        String oldName = FilePathAdapter.getOldPath(entry.oldPath(), entry.changeType());
 
         if (Patch.isMagic(newName)) {
           continue;
@@ -97,7 +100,8 @@
 
         if (fileRegex.matcher(newName).find()
             || (oldName != null && fileRegex.matcher(oldName).find())) {
-          List<Edit> edits = entry.getEdits();
+          List<Edit> edits =
+              entry.edits().stream().map(TaggedEdit::jgitEdit).collect(Collectors.toList());
           if (edits.isEmpty()) {
             continue;
           }
@@ -141,10 +145,10 @@
     return Pattern.compile(term.name(), Pattern.MULTILINE);
   }
 
-  private Text load(ObjectId tree, String path, ObjectReader reader)
+  private Text load(@Nullable ObjectId tree, String path, ObjectReader reader)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
-    if (path == null) {
+    if (tree == null || path == null) {
       return Text.EMPTY;
     }
     final TreeWalk tw = TreeWalk.forPath(reader, path, tree);
diff --git a/java/gerrit/PRED_commit_stats_3.java b/java/gerrit/PRED_commit_stats_3.java
index 286bc2c..82fad3d 100644
--- a/java/gerrit/PRED_commit_stats_3.java
+++ b/java/gerrit/PRED_commit_stats_3.java
@@ -15,8 +15,7 @@
 package gerrit;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -24,7 +23,8 @@
 import com.googlecode.prolog_cafe.lang.Predicate;
 import com.googlecode.prolog_cafe.lang.Prolog;
 import com.googlecode.prolog_cafe.lang.Term;
-import java.util.List;
+import java.util.Collection;
+import java.util.Map;
 
 /**
  * Exports basic commit statistics.
@@ -49,25 +49,30 @@
     Term a2 = arg2.dereference();
     Term a3 = arg3.dereference();
 
-    PatchList pl = StoredValues.PATCH_LIST.get(engine);
+    Map<String, FileDiffOutput> modifiedFiles = StoredValues.DIFF_LIST.get(engine);
     // Account for magic files
     if (!a1.unify(
-        new IntegerTerm(pl.getPatches().size() - countMagicFiles(pl.getPatches())), engine.trail)) {
+        new IntegerTerm(modifiedFiles.size() - countMagicFiles(modifiedFiles.values())),
+        engine.trail)) {
       return engine.fail();
     }
-    if (!a2.unify(new IntegerTerm(pl.getInsertions()), engine.trail)) {
+    Integer insertions =
+        modifiedFiles.values().stream().map(FileDiffOutput::insertions).reduce(0, Integer::sum);
+    Integer deletions =
+        modifiedFiles.values().stream().map(FileDiffOutput::deletions).reduce(0, Integer::sum);
+    if (!a2.unify(new IntegerTerm(insertions), engine.trail)) {
       return engine.fail();
     }
-    if (!a3.unify(new IntegerTerm(pl.getDeletions()), engine.trail)) {
+    if (!a3.unify(new IntegerTerm(deletions), engine.trail)) {
       return engine.fail();
     }
     return cont;
   }
 
-  private int countMagicFiles(List<PatchListEntry> entries) {
+  private int countMagicFiles(Collection<FileDiffOutput> entries) {
     int count = 0;
-    for (PatchListEntry e : entries) {
-      if (Patch.isMagic(e.getNewName())) {
+    for (FileDiffOutput e : entries) {
+      if (e.newPath().isPresent() && Patch.isMagic(e.newPath().get())) {
         count++;
       }
     }
diff --git a/java/gerrit/PRED_files_1.java b/java/gerrit/PRED_files_1.java
index ac45449..dbf96da 100644
--- a/java/gerrit/PRED_files_1.java
+++ b/java/gerrit/PRED_files_1.java
@@ -15,7 +15,8 @@
 package gerrit;
 
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.server.patch.PatchListEntry;
+import com.google.gerrit.server.patch.FilePathAdapter;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.rules.StoredValues;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -26,8 +27,8 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.HashSet;
-import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.eclipse.jgit.lib.FileMode;
@@ -54,17 +55,20 @@
 
     try (RevWalk revWalk = new RevWalk(StoredValues.REPOSITORY.get(engine))) {
       RevCommit commit = revWalk.parseCommit(StoredValues.getPatchSet(engine).commitId());
-      List<PatchListEntry> patches = StoredValues.PATCH_LIST.get(engine).getPatches();
+      Collection<FileDiffOutput> modifiedFiles = StoredValues.DIFF_LIST.get(engine).values();
       Set<String> submodules =
-          getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, patches);
-      for (PatchListEntry entry : patches) {
-        if (Patch.isMagic(entry.getNewName())) {
+          getAllSubmodulePaths(StoredValues.REPOSITORY.get(engine), commit, modifiedFiles);
+      for (FileDiffOutput fileDiff : modifiedFiles) {
+        if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
           continue;
         }
-        SymbolTerm fileNameTerm = SymbolTerm.create(entry.getNewName());
-        SymbolTerm changeType = SymbolTerm.create(entry.getChangeType().getCode());
+        String newPath =
+            FilePathAdapter.getNewPath(
+                fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType());
+        SymbolTerm fileNameTerm = SymbolTerm.create(newPath);
+        SymbolTerm changeType = SymbolTerm.create(fileDiff.changeType().getCode());
         SymbolTerm fileType;
-        if (submodules.contains(entry.getNewName())) {
+        if (submodules.contains(newPath)) {
           fileType = SymbolTerm.create("SUBMODULE");
         } else {
           fileType = SymbolTerm.create("REGULAR");
@@ -83,14 +87,14 @@
 
   /** Returns the paths for all {@code GITLINK} files. */
   private static Set<String> getAllSubmodulePaths(
-      Repository repository, RevCommit commit, List<PatchListEntry> patches)
+      Repository repository, RevCommit commit, Collection<FileDiffOutput> modifiedFiles)
       throws PrologException, IOException {
     Set<String> submodules = new HashSet<>();
     try (TreeWalk treeWalk = new TreeWalk(repository)) {
       treeWalk.addTree(commit.getTree());
       Set<String> allPaths =
-          patches.stream()
-              .map(PatchListEntry::getNewName)
+          modifiedFiles.stream()
+              .map(f -> FilePathAdapter.getNewPath(f.oldPath(), f.newPath(), f.changeType()))
               .filter(f -> !Patch.isMagic(f))
               .collect(Collectors.toSet());
       treeWalk.setFilter(PathFilterGroup.createFromStrings(allPaths));
diff --git a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
index 30f1dcb..7758be6 100644
--- a/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/OutgoingEmailIT.java
@@ -19,11 +19,11 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.Address;
-import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.testing.FakeEmailSender;
 import java.net.URL;
@@ -36,9 +36,9 @@
   public void messageIdHeaderFromChangeUpdate() throws Exception {
     Repository repository = repoManager.openRepository(project);
     PushOneCommit.Result result = createChange();
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(reviewerInput);
     sender.clear();
 
     gApi.changes().id(result.getChangeId()).abandon();
@@ -107,9 +107,9 @@
     GeneralPreferencesInfo generalPreferencesInfo = new GeneralPreferencesInfo();
     generalPreferencesInfo.emailFormat = GeneralPreferencesInfo.EmailFormat.PLAINTEXT;
     gApi.accounts().id(user.id().get()).setPreferences(generalPreferencesInfo);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(result.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(result.getChangeId()).addReviewer(reviewerInput);
     sender.clear();
 
     gApi.changes().id(result.getChangeId()).current().review(ReviewInput.approve());
@@ -125,7 +125,7 @@
   }
 
   private static String getMessageId(FakeEmailSender sender) {
-    return ((EmailHeader.String)
+    return ((StringEmailHeader)
             (Iterables.getOnlyElement(sender.getMessages()).headers().get("Message-ID")))
         .getString();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index 7495e63..915e759 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -32,7 +32,6 @@
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithoutExpiration;
 import static com.google.gerrit.server.StarredChangesUtil.DEFAULT_LABEL;
-import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -47,8 +46,8 @@
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
-import static org.mockito.Mockito.verifyZeroInteractions;
 
 import com.github.rholder.retry.StopStrategies;
 import com.google.common.collect.FluentIterable;
@@ -95,10 +94,9 @@
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
 import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
@@ -131,9 +129,13 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountIndexer;
@@ -230,6 +232,9 @@
   @Inject private VersionedAuthorizedKeys.Accessor authorizedKeys;
   @Inject private ExtensionRegistry extensionRegistry;
   @Inject private PluginSetContext<ExceptionHook> exceptionHooks;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private AuthConfig authConfig;
 
   @Inject protected Emails emails;
 
@@ -373,8 +378,8 @@
       accountIndexedCounter.assertReindexOf(accountId, 1);
       assertThat(externalIds.byAccount(accountId))
           .containsExactly(
-              ExternalId.createUsername(input.username, accountId, null),
-              ExternalId.createEmail(accountId, input.email));
+              externalIdFactory.createUsername(input.username, accountId, null),
+              externalIdFactory.createEmail(accountId, input.email));
     }
   }
 
@@ -427,7 +432,7 @@
   public void createAtomically() throws Exception {
     Account.Id accountId = Account.id(seq.nextAccountId());
     String fullName = "Foo";
-    ExternalId extId = ExternalId.createEmail(accountId, "foo@example.com");
+    ExternalId extId = externalIdFactory.createEmail(accountId, "foo@example.com");
     AccountState accountState =
         accountsUpdateProvider
             .get()
@@ -575,7 +580,7 @@
               "Account 'user' only matches inactive accounts. To use an inactive account, retry"
                   + " with one of the following exact account IDs:\n"
                   + id
-                  + ": User <user@example.com>");
+                  + ": User1 <user1@example.com>");
       assertThat(gApi.accounts().id(id).getActive()).isFalse();
 
       gApi.accounts().id(id).setActive(true);
@@ -685,7 +690,7 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoInteractions(listener);
 
       // Activate account that can be activated
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
@@ -696,7 +701,7 @@
       // Activate account that is already active
       gApi.accounts().id(activatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Try deactivating account that cannot be deactivated
       thrown =
@@ -705,13 +710,13 @@
               () -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
       assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       /* Test account that can be deactivated, but not activated */
       // Activate account that is already inactive
       gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Deactivate account that can be deactivated
       gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
@@ -726,7 +731,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
       assertThat(thrown).hasMessageThat().isEqualTo("account not active");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
 
       // Try activating account that cannot be activated
       thrown =
@@ -735,7 +740,7 @@
               () -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
       assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
       assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
-      verifyZeroInteractions(listener);
+      verifyNoMoreInteractions(listener);
     }
   }
 
@@ -791,126 +796,7 @@
   }
 
   @Test
-  public void starUnstarChangeWithLabels() throws Exception {
-    AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
-    RefUpdateCounter refUpdateCounter = new RefUpdateCounter();
-    try (Registration registration =
-        extensionRegistry.newRegistration().add(accountIndexedCounter).add(refUpdateCounter)) {
-      PushOneCommit.Result r = createChange();
-      String triplet = project.get() + "~master~" + r.getChangeId();
-      refUpdateCounter.clear();
-
-      assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
-      assertThat(gApi.accounts().self().getStarredChanges()).isEmpty();
-
-      gApi.accounts()
-          .self()
-          .setStars(triplet, new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "red", "blue")));
-      ChangeInfo change = info(triplet);
-      assertThat(change.starred).isTrue();
-      assertThat(change.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-      assertThat(gApi.accounts().self().getStars(triplet))
-          .containsExactly("blue", "red", DEFAULT_LABEL)
-          .inOrder();
-      List<ChangeInfo> starredChanges = gApi.accounts().self().getStarredChanges();
-      assertThat(starredChanges).hasSize(1);
-      ChangeInfo starredChange = starredChanges.get(0);
-      assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
-      assertThat(starredChange.starred).isTrue();
-      assertThat(starredChange.stars).containsExactly("blue", "red", DEFAULT_LABEL).inOrder();
-      refUpdateCounter.assertRefUpdateFor(
-          RefUpdateCounter.projectRef(
-              allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
-
-      gApi.accounts()
-          .self()
-          .setStars(
-              triplet,
-              new StarsInput(ImmutableSet.of("yellow"), ImmutableSet.of(DEFAULT_LABEL, "blue")));
-      change = info(triplet);
-      assertThat(change.starred).isNull();
-      assertThat(change.stars).containsExactly("red", "yellow").inOrder();
-      assertThat(gApi.accounts().self().getStars(triplet))
-          .containsExactly("red", "yellow")
-          .inOrder();
-      starredChanges = gApi.accounts().self().getStarredChanges();
-      assertThat(starredChanges).hasSize(1);
-      starredChange = starredChanges.get(0);
-      assertThat(starredChange._number).isEqualTo(r.getChange().getId().get());
-      assertThat(starredChange.starred).isNull();
-      assertThat(starredChange.stars).containsExactly("red", "yellow").inOrder();
-      refUpdateCounter.assertRefUpdateFor(
-          RefUpdateCounter.projectRef(
-              allUsers, RefNames.refsStarredChanges(Change.id(change._number), admin.id())));
-
-      accountIndexedCounter.assertNoReindex();
-
-      requestScopeOperations.setApiUser(user.id());
-      AuthException thrown =
-          assertThrows(
-              AuthException.class,
-              () -> gApi.accounts().id(Integer.toString((admin.id().get()))).getStars(triplet));
-      assertThat(thrown).hasMessageThat().contains("not allowed to get stars of another account");
-    }
-  }
-
-  @Test
-  public void starWithInvalidLabels() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () ->
-                gApi.accounts()
-                    .self()
-                    .setStars(
-                        triplet,
-                        new StarsInput(
-                            ImmutableSet.of(
-                                DEFAULT_LABEL, "invalid label", "blue", "another invalid label"))));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains("invalid labels: another invalid label, invalid label");
-  }
-
-  @Test
-  public void deleteStarLabelsFromChangeWithoutStarLabels() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
-
-    gApi.accounts().self().setStars(triplet, new StarsInput());
-
-    assertThat(gApi.accounts().self().getStars(triplet)).isEmpty();
-  }
-
-  @Test
-  public void starWithDefaultAndIgnoreLabel() throws Exception {
-    PushOneCommit.Result r = createChange();
-    String triplet = project.get() + "~master~" + r.getChangeId();
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () ->
-                gApi.accounts()
-                    .self()
-                    .setStars(
-                        triplet,
-                        new StarsInput(ImmutableSet.of(DEFAULT_LABEL, "blue", IGNORE_LABEL))));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + DEFAULT_LABEL
-                + " and "
-                + IGNORE_LABEL
-                + " are mutually exclusive."
-                + " Only one of them can be set.");
-  }
-
-  @Test
-  public void ignoreChangeBySetStars() throws Exception {
+  public void ignoreChange() throws Exception {
     AccountIndexedCounter accountIndexedCounter = new AccountIndexedCounter();
     try (Registration registration =
         extensionRegistry.newRegistration().add(accountIndexedCounter)) {
@@ -919,18 +805,16 @@
 
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-      in = new AddReviewerInput();
+      in = new ReviewerInput();
       in.reviewer = user2.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
       requestScopeOperations.setApiUser(user.id());
-      gApi.accounts()
-          .self()
-          .setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+      gApi.changes().id(r.getChangeId()).ignore(true);
 
       sender.clear();
       requestScopeOperations.setApiUser(admin.id());
@@ -950,22 +834,16 @@
       PushOneCommit.Result r = createChange();
 
       requestScopeOperations.setApiUser(user.id());
-      gApi.accounts()
-          .self()
-          .setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+      gApi.changes().id(r.getChangeId()).ignore(true);
 
       sender.clear();
       requestScopeOperations.setApiUser(admin.id());
 
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
       List<Message> messages = sender.getMessages();
-      assertThat(messages).hasSize(1);
-      Message message = messages.get(0);
-      assertThat(message.rcpt()).containsExactly(user.getNameEmail());
-      assertMailReplyTo(message, admin.email());
-      accountIndexedCounter.assertNoReindex();
+      assertThat(messages).hasSize(0);
     }
   }
 
@@ -976,9 +854,9 @@
     // First reviewer added to the change
     ReviewInput input = new ReviewInput();
     input.reviewers = new ArrayList<>(1);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    input.reviewers.add(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    input.reviewers.add(reviewerInput);
     gApi.changes().id(r.getChangeId()).current().review(input);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
@@ -991,14 +869,14 @@
     // Second reviewer and existing reviewer added to the change
     ReviewInput input2 = new ReviewInput();
     input2.reviewers = new ArrayList<>(2);
-    AddReviewerInput addReviewerInput2 = new AddReviewerInput();
-    addReviewerInput2.reviewer = user.email();
-    input2.reviewers.add(addReviewerInput2);
-    AddReviewerInput addReviewerInput3 = new AddReviewerInput();
+    ReviewerInput reviewerInput2 = new ReviewerInput();
+    reviewerInput2.reviewer = user.email();
+    input2.reviewers.add(reviewerInput2);
+    ReviewerInput reviewerInput3 = new ReviewerInput();
 
     TestAccount user2 = accountCreator.user2();
-    addReviewerInput3.reviewer = user2.email();
-    input2.reviewers.add(addReviewerInput3);
+    reviewerInput3.reviewer = user2.email();
+    input2.reviewers.add(reviewerInput3);
 
     gApi.changes().id(r.getChangeId()).current().review(input2);
     List<Message> messages2 = sender.getMessages();
@@ -1012,13 +890,13 @@
     // Existing reviewers re-added to the change: no notifications
     ReviewInput input3 = new ReviewInput();
     input3.reviewers = new ArrayList<>(2);
-    AddReviewerInput addReviewerInput4 = new AddReviewerInput();
-    addReviewerInput4.reviewer = user.email();
-    input3.reviewers.add(addReviewerInput4);
-    AddReviewerInput addReviewerInput5 = new AddReviewerInput();
+    ReviewerInput reviewerInput4 = new ReviewerInput();
+    reviewerInput4.reviewer = user.email();
+    input3.reviewers.add(reviewerInput4);
+    ReviewerInput reviewerInput5 = new ReviewerInput();
 
-    addReviewerInput5.reviewer = user2.email();
-    input3.reviewers.add(addReviewerInput5);
+    reviewerInput5.reviewer = user2.email();
+    input3.reviewers.add(reviewerInput5);
 
     gApi.changes().id(r.getChangeId()).current().review(input3);
     List<Message> messages3 = sender.getMessages();
@@ -1030,9 +908,9 @@
     PushOneCommit.Result r = createChange();
 
     // First reviewer added to the change
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput);
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     Message message = messages.get(0);
@@ -1043,9 +921,9 @@
 
     // Second reviewer added to the change
     TestAccount user2 = accountCreator.user2();
-    AddReviewerInput addReviewerInput2 = new AddReviewerInput();
-    addReviewerInput2.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput2);
+    ReviewerInput reviewerInput2 = new ReviewerInput();
+    reviewerInput2.reviewer = user2.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput2);
     List<Message> messages2 = sender.getMessages();
     assertThat(messages2).hasSize(1);
     Message message2 = messages2.get(0);
@@ -1055,9 +933,9 @@
     sender.clear();
 
     // Exiting reviewer re-added to the change: no notifications
-    AddReviewerInput addReviewerInput3 = new AddReviewerInput();
-    addReviewerInput3.reviewer = user2.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(addReviewerInput3);
+    ReviewerInput reviewerInput3 = new ReviewerInput();
+    reviewerInput3.reviewer = user2.email();
+    gApi.changes().id(r.getChangeId()).addReviewer(reviewerInput3);
     List<Message> messages3 = sender.getMessages();
     assertThat(messages3).isEmpty();
   }
@@ -1406,11 +1284,11 @@
               admin.id(),
               u ->
                   u.addExternalId(
-                          ExternalId.createWithEmail(
-                              ExternalId.Key.parse(extId1), admin.id(), email))
+                          externalIdFactory.createWithEmail(
+                              externalIdKeyFactory.parse(extId1), admin.id(), email))
                       .addExternalId(
-                          ExternalId.createWithEmail(
-                              ExternalId.Key.parse(extId2), admin.id(), email)));
+                          externalIdFactory.createWithEmail(
+                              externalIdKeyFactory.parse(extId2), admin.id(), email)));
       accountIndexedCounter.assertReindexOf(admin);
       assertThat(
               gApi.accounts().self().getExternalIds().stream()
@@ -1446,8 +1324,8 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    ExternalId.createWithEmail(
-                        ExternalId.Key.parse(ldapExternalId), admin.id(), ldapEmail)));
+                    externalIdFactory.createWithEmail(
+                        externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .contains(ldapExternalId);
@@ -1481,11 +1359,13 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                        ExternalId.createWithEmail(
-                            ExternalId.Key.parse(nonLdapExternalId), admin.id(), nonLdapEMail))
+                        externalIdFactory.createWithEmail(
+                            externalIdKeyFactory.parse(nonLdapExternalId),
+                            admin.id(),
+                            nonLdapEMail))
                     .addExternalId(
-                        ExternalId.createWithEmail(
-                            ExternalId.Key.parse(ldapExternalId), admin.id(), ldapEmail)));
+                        externalIdFactory.createWithEmail(
+                            externalIdKeyFactory.parse(ldapExternalId), admin.id(), ldapEmail)));
     assertThat(
             gApi.accounts().self().getExternalIds().stream().map(e -> e.identity).collect(toSet()))
         .containsAtLeast(ldapExternalId, nonLdapExternalId);
@@ -1548,8 +1428,8 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    ExternalId.createWithEmail(
-                        ExternalId.Key.parse("foo:bar"), admin.id(), email)));
+                    externalIdFactory.createWithEmail(
+                        externalIdKeyFactory.parse("foo:bar"), admin.id(), email)));
     assertEmail(emails.getAccountFor(email), admin);
 
     // wrong case doesn't match
@@ -1796,7 +1676,7 @@
     }
 
     assertThat(accountCache.get(admin.id())).isEmpty();
-    assertThat(accountQueryProvider.get().byDefault(admin.id().toString())).isEmpty();
+    assertThat(accountQueryProvider.get().byDefault(admin.id().toString(), true)).isEmpty();
   }
 
   @Test
@@ -1865,7 +1745,7 @@
           .update(
               "Add External ID",
               user.id(),
-              u -> u.addExternalId(ExternalId.create("foo", "myId", user.id())));
+              u -> u.addExternalId(externalIdFactory.create("foo", "myId", user.id())));
       accountIndexedCounter.assertReindexOf(user);
 
       TestKey key = validKeyWithSecondUserId();
@@ -2168,7 +2048,7 @@
         .update(
             "Delete External ID",
             account.id(),
-            u -> u.deleteExternalId(ExternalId.createEmail(account.id(), email)));
+            u -> u.deleteExternalId(externalIdFactory.createEmail(account.id(), email)));
     expectedProblems.add(
         new ConsistencyProblemInfo(
             ConsistencyProblemInfo.Status.ERROR,
@@ -2186,7 +2066,7 @@
   @Test
   public void internalQueryFindActiveAndInactiveAccounts() throws Exception {
     String name = name("foo");
-    assertThat(accountQueryProvider.get().byDefault(name)).isEmpty();
+    assertThat(accountQueryProvider.get().byDefault(name, true)).isEmpty();
 
     TestAccount foo1 = accountCreator.create(name + "-1");
     assertThat(gApi.accounts().id(foo1.username()).getActive()).isTrue();
@@ -2195,7 +2075,7 @@
     gApi.accounts().id(foo2.username()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
 
-    assertThat(accountQueryProvider.get().byDefault(name)).hasSize(2);
+    assertThat(accountQueryProvider.get().byDefault(name, true)).hasSize(2);
   }
 
   @Test
@@ -2503,7 +2383,7 @@
         .update();
 
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId extIdA1 = ExternalId.create("foo", "A-1", accountId);
+    ExternalId extIdA1 = externalIdFactory.create("foo", "A-1", accountId);
     accountsUpdateProvider
         .get()
         .insert("Create Test Account", accountId, u -> u.addExternalId(extIdA1));
@@ -2511,7 +2391,7 @@
     AtomicInteger bgCounterA1 = new AtomicInteger(0);
     AtomicInteger bgCounterA2 = new AtomicInteger(0);
     PersonIdent ident = serverIdent.get();
-    ExternalId extIdA2 = ExternalId.create("foo", "A-2", accountId);
+    ExternalId extIdA2 = externalIdFactory.create("foo", "A-2", accountId);
     AccountsUpdate update =
         new AccountsUpdate(
             repoManager,
@@ -2552,8 +2432,8 @@
                 .collect(toSet()))
         .containsExactly(extIdA1.key().get());
 
-    ExternalId extIdB1 = ExternalId.create("foo", "B-1", accountId);
-    ExternalId extIdB2 = ExternalId.create("foo", "B-2", accountId);
+    ExternalId extIdB1 = externalIdFactory.create("foo", "B-1", accountId);
+    ExternalId extIdB2 = externalIdFactory.create("foo", "B-2", accountId);
     Optional<AccountState> updatedAccount =
         update.update(
             "Update External ID",
@@ -2616,21 +2496,38 @@
     // Manually inserting/updating/deleting an external ID of the user makes the index document
     // stale.
     try (Repository repo = repoManager.openRepository(allUsers)) {
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
+      ExternalIdNotes extIdNotes =
+          ExternalIdNotes.loadNoCacheUpdate(
+              allUsers,
+              repo,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode());
 
-      ExternalId.Key key = ExternalId.Key.create("foo", "foo");
-      extIdNotes.insert(ExternalId.create(key, accountId));
+      ExternalId.Key key = externalIdKeyFactory.create("foo", "foo");
+      extIdNotes.insert(externalIdFactory.create(key, accountId));
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
       }
       assertStaleAccountAndReindex(accountId);
 
-      extIdNotes.upsert(ExternalId.createWithEmail(key, accountId, "foo@example.com"));
+      extIdNotes =
+          ExternalIdNotes.loadNoCacheUpdate(
+              allUsers,
+              repo,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode());
+      extIdNotes.upsert(externalIdFactory.createWithEmail(key, accountId, "foo@example.com"));
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
       }
       assertStaleAccountAndReindex(accountId);
 
+      extIdNotes =
+          ExternalIdNotes.loadNoCacheUpdate(
+              allUsers,
+              repo,
+              externalIdFactory,
+              authConfig.isUserNameCaseInsensitiveMigrationMode());
       extIdNotes.delete(accountId, key);
       try (MetaDataUpdate update = metaDataUpdateFactory.create(allUsers)) {
         extIdNotes.commit(update);
@@ -2886,6 +2783,154 @@
     assertThat(thrown).hasMessageThat().contains("username");
   }
 
+  @Test
+  public void externalIdBatchUpdates() throws Exception {
+    String extId1String = "foo:bar";
+    String extId2String = "foo:baz";
+    ExternalId extId1 =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse(extId1String), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse(extId2String), user.id(), "2@foo.com");
+
+    ObjectId revBefore;
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      revBefore = repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId();
+    }
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extId2));
+    ImmutableList<Optional<AccountState>> accountStates =
+        accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+    assertThat(accountStates).hasSize(2);
+    assertThat(accountStates.get(0).get().externalIds()).contains(extId1);
+    assertThat(accountStates.get(1).get().externalIds()).contains(extId2);
+    assertThat(
+            gApi.accounts().id(admin.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toSet()))
+        .contains(extId1String);
+    assertThat(
+            gApi.accounts().id(user.id().get()).getExternalIds().stream()
+                .map(e -> e.identity)
+                .collect(toSet()))
+        .contains(extId2String);
+
+    // Ensure that we only applied one single commit.
+    try (Repository repo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit after = rw.parseCommit(repo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+      assertThat(after.getParent(0).toObjectId()).isEqualTo(revBefore);
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_fail_sameAccount() {
+    ExternalId extId1 =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId1));
+    // Another update for the same account is not allowed.
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extId2));
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2)));
+    assertThat(e).hasMessageThat().contains("updates must all be for different accounts");
+  }
+
+  @Test
+  public void externalIdBatchUpdates_fail_duplicateKey() {
+    ExternalId extIdAdmin =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extIdUser =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:bar"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", admin.id(), (a, u) -> u.addExternalId(extIdAdmin));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "Add External ID", user.id(), (a, u) -> u.addExternalId(extIdUser));
+    DuplicateExternalIdKeyException e =
+        assertThrows(
+            DuplicateExternalIdKeyException.class,
+            () -> accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2)));
+    assertThat(e).hasMessageThat().contains("foo:bar");
+  }
+
+  @Test
+  public void externalIdBatchUpdates_commitMsg_multipleAccounts() throws Exception {
+    ExternalId extId1 =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+    ExternalId extId2 =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:baz"), user.id(), "2@foo.com");
+
+    AccountsUpdate.UpdateArguments ua1 =
+        new AccountsUpdate.UpdateArguments(
+            "first message", admin.id(), (a, u) -> u.addExternalId(extId1));
+    AccountsUpdate.UpdateArguments ua2 =
+        new AccountsUpdate.UpdateArguments(
+            "second message", user.id(), (a, u) -> u.addExternalId(extId2));
+    accountsUpdateProvider.get().updateBatch(ImmutableList.of(ua1, ua2));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("Batch update for 2 accounts\n");
+    }
+  }
+
+  @Test
+  public void externalIdBatchUpdates_commitMsg_singleAccount() throws Exception {
+    ExternalId extId =
+        externalIdFactory.createWithEmail(
+            externalIdKeyFactory.parse("foo:bar"), admin.id(), "1@foo.com");
+
+    accountsUpdateProvider.get().update("foobar", admin.id(), (a, u) -> u.addExternalId(extId));
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        RevWalk rw = new RevWalk(allUsersRepo)) {
+      RevCommit commit =
+          rw.parseCommit(allUsersRepo.exactRef(RefNames.REFS_EXTERNAL_IDS).getObjectId());
+
+      assertThat(commit.getFullMessage()).isEqualTo("foobar\n");
+    }
+  }
+
+  @Test
+  public void searchForSecondaryEmailRequiresModifyAccountPermission() throws Exception {
+    String email = "preferred@example.com";
+    TestAccount foo = accountCreator.create(name("foo"), email, "Foo", null);
+    String secondaryEmail = "secondary@example.com";
+    EmailInput input = newEmailInput(secondaryEmail);
+    gApi.accounts().id(foo.id().get()).addEmail(input);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> gApi.accounts().id("secondary"));
+    requestScopeOperations.setApiUser(admin.id());
+    assertThat(gApi.accounts().id("secondary").get()._accountId).isEqualTo(foo.id().get());
+  }
+
   private void createDraft(PushOneCommit.Result r, String path, String message) throws Exception {
     DraftInput in = new DraftInput();
     in.path = path;
@@ -3016,7 +3061,7 @@
               account.id(),
               u ->
                   u.addExternalId(
-                      ExternalId.createWithEmail(name("test"), email, account.id(), email)));
+                      externalIdFactory.createWithEmail(name("test"), email, account.id(), email)));
       accountIndexedCounter.assertReindexOf(account);
       requestScopeOperations.setApiUser(account.id());
     }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
index f78cb9ab..d1258fc 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIndexerIT.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -59,7 +59,7 @@
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.index(accountId);
@@ -75,7 +75,7 @@
     loadAccountToCache(accountId);
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.index(accountId);
@@ -90,7 +90,7 @@
     Account.Id accountId = createAccount("foo");
     String preferredEmail = "foo@example.com";
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail(preferredEmail).build());
+        accountId, newAccountDelta().setPreferredEmail(preferredEmail).build());
     assertThat(accountQueryProvider.get().byPreferredEmail(preferredEmail)).isEmpty();
 
     accountIndexer.reindexIfStale(accountId);
@@ -104,7 +104,7 @@
   public void notStaleAccountIsNotReindexed() throws Exception {
     Account.Id accountId = createAccount("foo");
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail("foo@example.com").build());
+        accountId, newAccountDelta().setPreferredEmail("foo@example.com").build());
     accountIndexer.index(accountId);
 
     boolean reindexed = accountIndexer.reindexIfStale(accountId);
@@ -115,7 +115,7 @@
   public void indexStalenessIsNotDerivedFromCacheStaleness() throws Exception {
     Account.Id accountId = createAccount("foo");
     updateAccountWithoutCacheOrIndex(
-        accountId, newAccountUpdate().setPreferredEmail("foo@example.com").build());
+        accountId, newAccountDelta().setPreferredEmail("foo@example.com").build());
     reloadAccountToCache(accountId);
 
     boolean reindexed = accountIndexer.reindexIfStale(accountId);
@@ -135,12 +135,11 @@
     accountCache.get(accountId);
   }
 
-  private static InternalAccountUpdate.Builder newAccountUpdate() {
-    return InternalAccountUpdate.builder();
+  private static AccountDelta.Builder newAccountDelta() {
+    return AccountDelta.builder();
   }
 
-  private void updateAccountWithoutCacheOrIndex(
-      Account.Id accountId, InternalAccountUpdate accountUpdate)
+  private void updateAccountWithoutCacheOrIndex(Account.Id accountId, AccountDelta accountDelta)
       throws IOException, ConfigInvalidException {
     try (Repository allUsersRepo = repoManager.openRepository(allUsersName);
         MetaDataUpdate md =
@@ -150,7 +149,7 @@
       md.getCommitBuilder().setCommitter(ident);
 
       AccountConfig accountConfig = new AccountConfig(accountId, allUsersName, allUsersRepo).load();
-      accountConfig.setAccountUpdate(accountUpdate);
+      accountConfig.setAccountDelta(accountDelta);
       accountConfig.commit(md);
     }
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
index 80eff96..3ead608 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -41,11 +41,11 @@
  */
 @TestPlugin(
     name = "account-listener-it-plugin",
-    sysModule = "com.google.gerrit.acceptance.api.accounts.AccountListenersIT$Module")
+    sysModule = "com.google.gerrit.acceptance.api.accounts.AccountListenersIT$TestModule")
 public class AccountListenersIT extends LightweightPluginDaemonTest {
   @Inject private AccountOperations accountOperations;
 
-  public static class Module extends AbstractModule {
+  public static class TestModule extends AbstractModule {
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
index b41a2f3..7e23f0e 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountManagerIT.java
@@ -37,6 +37,8 @@
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.SetInactiveFlag;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -62,14 +64,17 @@
   @Inject private SshKeyCache sshKeyCache;
   @Inject private GroupsUpdate.Factory groupsUpdateFactory;
   @Inject private SetInactiveFlag setInactiveFlag;
+  @Inject private AuthRequest.Factory authRequestFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
 
   @Test
   public void authenticateNewAccountWithEmail() throws Exception {
     String email = "foo@example.com";
-    ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
+    ExternalId.Key mailtoExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_MAILTO, email);
     assertNoSuchExternalIds(mailtoExtIdKey);
 
-    AuthRequest who = AuthRequest.forEmail(email);
+    AuthRequest who = authRequestFactory.createForEmail(email);
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForNewAccount(authResult, mailtoExtIdKey);
     assertExternalId(mailtoExtIdKey, email);
@@ -78,11 +83,12 @@
   @Test
   public void authenticateNewAccountWithUsername() throws Exception {
     String username = "foo";
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
-    ExternalId.Key usernameExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_USERNAME, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key usernameExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, username);
     assertNoSuchExternalIds(gerritExtIdKey, usernameExtIdKey);
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForNewAccount(authResult, gerritExtIdKey);
     assertExternalIdsWithoutEmail(gerritExtIdKey, usernameExtIdKey);
@@ -91,11 +97,12 @@
   @Test
   public void authenticateNewAccountWithUsernameAndEmail() throws Exception {
     String username = "foo";
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
-    ExternalId.Key usernameExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_USERNAME, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key usernameExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, username);
     assertNoSuchExternalIds(gerritExtIdKey, usernameExtIdKey);
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     String email = "foo@example.com";
     who.setEmailAddress(email);
     AuthResult authResult = accountManager.authenticate(who);
@@ -107,12 +114,14 @@
   @Test
   public void authenticateNewAccountWithExternalUser() throws Exception {
     String username = "foo";
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
-    ExternalId.Key usernameExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_USERNAME, username);
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key usernameExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     assertNoSuchExternalIds(externalExtIdKey, usernameExtIdKey, gerritExtIdKey);
 
-    AuthRequest who = AuthRequest.forExternalUser(username);
+    AuthRequest who = authRequestFactory.createForExternalUser(username);
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForNewAccount(authResult, externalExtIdKey);
     assertExternalIdsWithoutEmail(externalExtIdKey, usernameExtIdKey);
@@ -122,12 +131,14 @@
   @Test
   public void authenticateNewAccountWithExternalUserAndEmail() throws Exception {
     String username = "foo";
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
-    ExternalId.Key usernameExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_USERNAME, username);
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key usernameExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_USERNAME, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     assertNoSuchExternalIds(externalExtIdKey, usernameExtIdKey, gerritExtIdKey);
 
-    AuthRequest who = AuthRequest.forExternalUser(username);
+    AuthRequest who = authRequestFactory.createForExternalUser(username);
     String email = "foo@example.com";
     who.setEmailAddress(email);
     AuthResult authResult = accountManager.authenticate(who);
@@ -141,13 +152,13 @@
   public void authenticateWithEmail() throws Exception {
     String email = "foo@example.com";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
+    ExternalId.Key mailtoExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_MAILTO, email);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(mailtoExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(mailtoExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forEmail(email);
+    AuthRequest who = authRequestFactory.createForEmail(email);
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForExistingAccount(authResult, accountId, mailtoExtIdKey);
   }
@@ -156,13 +167,13 @@
   public void authenticateWithUsername() throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForExistingAccount(authResult, accountId, gerritExtIdKey);
   }
@@ -171,13 +182,14 @@
   public void authenticateWithExternalUser() throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(externalExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(externalExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forExternalUser(username);
+    AuthRequest who = authRequestFactory.createForExternalUser(username);
     AuthResult authResult = accountManager.authenticate(who);
     assertAuthResultForExistingAccount(authResult, accountId, externalExtIdKey);
   }
@@ -187,15 +199,16 @@
     String username = "foo";
     String email = "foo@example.com";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
         u ->
             u.setPreferredEmail(email)
-                .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
+                .addExternalId(
+                    externalIdFactory.createWithEmail(gerritExtIdKey, accountId, email)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     String newEmail = "bar@example.com";
     who.setEmailAddress(newEmail);
     AuthResult authResult = accountManager.authenticate(who);
@@ -233,23 +246,26 @@
             projectCache,
             externalIds,
             groupsUpdateFactory,
-            setInactiveFlag));
+            setInactiveFlag,
+            externalIdFactory,
+            externalIdKeyFactory));
   }
 
   private void authenticateWithUsernameAndUpdateDisplayName(AccountManager am) throws Exception {
     String username = "foo";
     String email = "foo@example.com";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
         u ->
             u.setFullName("Initial Name")
                 .setPreferredEmail(email)
-                .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
+                .addExternalId(
+                    externalIdFactory.createWithEmail(gerritExtIdKey, accountId, email)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     String newName = "Updated Name";
     who.setDisplayName(newName);
     AuthResult authResult = am.authenticate(who);
@@ -263,12 +279,12 @@
   @Test
   public void cannotAuthenticateWithOrphanedExtId() throws Exception {
     String username = "foo";
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     assertNoSuchExternalIds(gerritExtIdKey);
 
     // Create orphaned SCHEME_GERRIT external ID.
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId gerritExtId = ExternalId.create(gerritExtIdKey, accountId);
+    ExternalId gerritExtId = externalIdFactory.create(gerritExtIdKey, accountId);
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
@@ -276,7 +292,7 @@
       extIdNotes.commit(md);
     }
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(who));
     assertThat(thrown).hasMessageThat().contains("Authentication error, account not found");
@@ -286,13 +302,13 @@
   public void cannotAuthenticateWithInactiveAccount() throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.setActive(false).addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.setActive(false).addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(who));
     assertThat(thrown).hasMessageThat().contains("Authentication error, account inactive");
@@ -303,13 +319,13 @@
       throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.setActive(false).addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.setActive(false).addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     who.setActive(true);
     who.setAuthProvidesAccountActiveStatus(true);
     AccountException thrown =
@@ -323,13 +339,13 @@
       throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.setActive(false).addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.setActive(false).addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     who.setActive(true);
     who.setAuthProvidesAccountActiveStatus(true);
     AuthResult authResult = accountManager.authenticate(who);
@@ -344,13 +360,13 @@
       throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     who.setActive(false);
     who.setAuthProvidesAccountActiveStatus(true);
     AuthResult authResult = accountManager.authenticate(who);
@@ -366,13 +382,13 @@
       throws Exception {
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     who.setActive(false);
     who.setAuthProvidesAccountActiveStatus(true);
     AccountException thrown =
@@ -391,15 +407,17 @@
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.createWithEmail(externalExtIdKey, accountId, email)));
+        u ->
+            u.addExternalId(externalIdFactory.createWithEmail(externalExtIdKey, accountId, email)));
 
     // Try to authenticate with this email to create a new account with a SCHEME_MAILTO external ID.
     // Expect that this fails because the email is already assigned to the other account.
-    AuthRequest who = AuthRequest.forEmail(email);
+    AuthRequest who = authRequestFactory.createForEmail(email);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(who));
     assertThat(thrown)
@@ -414,15 +432,17 @@
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.createWithEmail(externalExtIdKey, accountId, email)));
+        u ->
+            u.addExternalId(externalIdFactory.createWithEmail(externalExtIdKey, accountId, email)));
 
     // Try to authenticate with a new username and claim the same email.
     // Expect that this fails because the email is already assigned to the other account.
-    AuthRequest who = AuthRequest.forUser("bar");
+    AuthRequest who = authRequestFactory.createForUser("bar");
     who.setEmailAddress(email);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(who));
@@ -439,25 +459,29 @@
     // Create an account with a SCHEME_GERRIT external ID and an email.
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
         u ->
             u.setPreferredEmail(email)
-                .addExternalId(ExternalId.createWithEmail(gerritExtIdKey, accountId, email)));
+                .addExternalId(
+                    externalIdFactory.createWithEmail(gerritExtIdKey, accountId, email)));
 
     // Create another account with an SCHEME_EXTERNAL external ID that occupies the new email.
     Account.Id accountId2 = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, "bar");
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, "bar");
     accountsUpdate.insert(
         "Create Test Account",
         accountId2,
-        u -> u.addExternalId(ExternalId.createWithEmail(externalExtIdKey, accountId2, newEmail)));
+        u ->
+            u.addExternalId(
+                externalIdFactory.createWithEmail(externalExtIdKey, accountId2, newEmail)));
 
     // Try to authenticate and update the email for the first account.
     // Expect that this fails because the new email is already assigned to the other account.
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     who.setEmailAddress(newEmail);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.authenticate(who));
@@ -482,21 +506,21 @@
 
     // Create an account with a SCHEME_GERRIT external ID
     String username = "foo";
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     Account.Id accountId = Account.id(seq.nextAccountId());
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
     // Add the additional mail external ID with SCHEME_EMAIL
-    accountManager.link(accountId, AuthRequest.forEmail(email));
+    accountManager.link(accountId, authRequestFactory.createForEmail(email));
 
     // Try to authenticate and update the email for the account.
     // Expect that this to succeed because even if the email already exist
     // it is associated to the same account-id and thus is not really
     // a duplicate but simply a promotion of external id to preferred email.
-    AuthRequest who = AuthRequest.forUser(username);
+    AuthRequest who = authRequestFactory.createForUser(username);
     who.setEmailAddress(email);
     AuthResult authResult = accountManager.authenticate(who);
 
@@ -519,20 +543,20 @@
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username);
+    ExternalId.Key gerritExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.create(gerritExtIdKey, accountId)));
+        u -> u.addExternalId(externalIdFactory.create(gerritExtIdKey, accountId)));
 
     // Check that email is not used yet.
     String email = "foo@example.com";
-    ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
+    ExternalId.Key mailtoExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_MAILTO, email);
     assertNoSuchExternalIds(mailtoExtIdKey);
 
     // Link the email to the account.
     // Expect that a MAILTO external ID is created.
-    AuthRequest who = AuthRequest.forEmail(email);
+    AuthRequest who = authRequestFactory.createForEmail(email);
     AuthResult authResult = accountManager.link(accountId, who);
     assertAuthResultForExistingAccount(authResult, accountId, mailtoExtIdKey);
     assertExternalId(mailtoExtIdKey, accountId, email);
@@ -543,17 +567,18 @@
     // Create an account with a SCHEME_GERRIT external ID and no email
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
         u ->
             u.addExternalId(
-                ExternalId.createWithEmail(externalExtIdKey, accountId, "old@example.com")));
+                externalIdFactory.createWithEmail(externalExtIdKey, accountId, "old@example.com")));
 
     // Link the email to the existing SCHEME_EXTERNAL external ID, but with a new email.
     // Expect that the email of the existing external ID is updated.
-    AuthRequest who = AuthRequest.forExternalUser(username);
+    AuthRequest who = authRequestFactory.createForExternalUser(username);
     String newEmail = "new@example.com";
     who.setEmailAddress(newEmail);
     AuthResult authResult = accountManager.link(accountId, who);
@@ -566,24 +591,26 @@
     // Create an account with a SCHEME_EXTERNAL external ID
     String username1 = "foo";
     Account.Id accountId1 = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey1 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username1);
+    ExternalId.Key externalExtIdKey1 =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username1);
     accountsUpdate.insert(
         "Create Test Account",
         accountId1,
-        u -> u.addExternalId(ExternalId.create(externalExtIdKey1, accountId1)));
+        u -> u.addExternalId(externalIdFactory.create(externalExtIdKey1, accountId1)));
 
     // Create another account with a SCHEME_EXTERNAL external ID
     String username2 = "bar";
     Account.Id accountId2 = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey2 = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username2);
+    ExternalId.Key externalExtIdKey2 =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username2);
     accountsUpdate.insert(
         "Create Test Account",
         accountId2,
-        u -> u.addExternalId(ExternalId.create(externalExtIdKey2, accountId2)));
+        u -> u.addExternalId(externalIdFactory.create(externalExtIdKey2, accountId2)));
 
     // Try to link external ID of the first account to the second account.
     // Expect that this fails because the external ID is already assigned to the first account.
-    AuthRequest who = AuthRequest.forExternalUser(username1);
+    AuthRequest who = authRequestFactory.createForExternalUser(username1);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.link(accountId2, who));
     assertThat(thrown)
@@ -598,24 +625,27 @@
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.createWithEmail(externalExtIdKey, accountId, email)));
+        u ->
+            u.addExternalId(externalIdFactory.createWithEmail(externalExtIdKey, accountId, email)));
 
     // Create another account with a SCHEME_GERRIT external ID and no email
     String username2 = "foo";
     Account.Id accountId2 = Account.id(seq.nextAccountId());
-    ExternalId.Key gerritExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_GERRIT, username2);
+    ExternalId.Key gerritExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_GERRIT, username2);
     accountsUpdate.insert(
         "Create Test Account",
         accountId2,
-        u -> u.addExternalId(ExternalId.create(gerritExtIdKey, accountId2)));
+        u -> u.addExternalId(externalIdFactory.create(gerritExtIdKey, accountId2)));
 
     // Try to link the email to the second account (via a new MAILTO external ID) and expect that
     // this fails because the email is already assigned to the first account.
-    AuthRequest who = AuthRequest.forEmail(email);
+    AuthRequest who = authRequestFactory.createForEmail(email);
     AccountException thrown =
         assertThrows(AccountException.class, () -> accountManager.link(accountId2, who));
     assertThat(thrown)
@@ -630,13 +660,15 @@
     // Create an account with an SCHEME_EXTERNAL external ID that occupies the email.
     String username = "foo";
     Account.Id accountId = Account.id(seq.nextAccountId());
-    ExternalId.Key externalExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_EXTERNAL, username);
+    ExternalId.Key externalExtIdKey =
+        externalIdKeyFactory.create(ExternalId.SCHEME_EXTERNAL, username);
     accountsUpdate.insert(
         "Create Test Account",
         accountId,
-        u -> u.addExternalId(ExternalId.createWithEmail(externalExtIdKey, accountId, email)));
+        u ->
+            u.addExternalId(externalIdFactory.createWithEmail(externalExtIdKey, accountId, email)));
 
-    AuthRequest who = AuthRequest.forEmail(email);
+    AuthRequest who = authRequestFactory.createForEmail(email);
     AuthResult result = accountManager.link(accountId, who);
     assertThat(result.isNew()).isFalse();
     assertThat(result.getAccountId().get()).isEqualTo(accountId.get());
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
index f66bc8d..aa8615b 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/GeneralPreferencesIT.java
@@ -75,6 +75,7 @@
     i.emailStrategy = EmailStrategy.DISABLED;
     i.emailFormat = EmailFormat.PLAINTEXT;
     i.defaultBaseForMerges = DefaultBase.AUTO_MERGE;
+    i.disableKeyboardShortcuts = true;
     i.expandInlineDiffs ^= true;
     i.highlightAssigneeInChangeTable ^= true;
     i.relativeDateInChangeTable ^= true;
@@ -93,6 +94,7 @@
     assertThat(o.my).containsExactlyElementsIn(i.my);
     assertThat(o.changeTable).containsExactlyElementsIn(i.changeTable);
     assertThat(o.theme).isEqualTo(i.theme);
+    assertThat(o.disableKeyboardShortcuts).isEqualTo(i.disableKeyboardShortcuts);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
index a4f91d7..c7aac96 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.Truth8.assertThat;
@@ -37,9 +38,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_ACTIONS;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_COMMIT;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.extensions.client.ListChangesOption.PUSH_CERTIFICATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
@@ -70,6 +69,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.MoreCollectors;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ChangeIndexedCounter;
@@ -78,9 +78,11 @@
 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.TestProjectInput;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.UseTimezone;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
@@ -97,15 +99,20 @@
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
+import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -119,8 +126,9 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
@@ -142,7 +150,12 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.GitPerson;
 import com.google.gerrit.extensions.common.LabelInfo;
+import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.SubmitRecordInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementInput;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo;
+import com.google.gerrit.extensions.common.SubmitRequirementResultInfo.Status;
 import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -153,30 +166,34 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.httpd.raw.IndexPreloadingUtil;
 import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.index.query.PostFilterPredicate;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.change.ChangeMessages;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.testing.TestChangeETagComputation;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 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;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.DiffSummary;
 import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.IntraLineDiff;
 import com.google.gerrit.server.patch.IntraLineDiffKey;
-import com.google.gerrit.server.patch.PatchList;
-import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.ChangeOperatorFactory;
 import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.AbstractModule;
@@ -185,6 +202,7 @@
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.text.MessageFormat;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
@@ -201,14 +219,18 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
+import org.junit.Ignore;
 import org.junit.Test;
 
 @NoHttpd
 @UseTimezone(timezone = "US/Eastern")
+@VerifyNoPiiInChangeNotes(true)
 public class ChangeIT extends AbstractDaemonTest {
 
   @Inject private AccountOperations accountOperations;
@@ -220,10 +242,6 @@
   @Inject private ExtensionRegistry extensionRegistry;
 
   @Inject
-  @Named("diff")
-  private Cache<PatchListKey, PatchList> fileCache;
-
-  @Inject
   @Named("diff_intraline")
   private Cache<IntraLineDiffKey, IntraLineDiff> intraCache;
 
@@ -284,13 +302,10 @@
     String fileContent = "First line\nSecond line\n";
     PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
     String triplet = project.get() + "~master~" + result.getChangeId();
-    CacheStats startPatch = cloneStats(fileCache.stats());
     CacheStats startIntra = cloneStats(intraCache.stats());
     CacheStats startSummary = cloneStats(diffSummaryCache.stats());
     gApi.changes().id(triplet).get(ImmutableList.of(ListChangesOption.SKIP_DIFFSTAT));
 
-    assertThat(fileCache.stats()).since(startPatch).hasMissCount(0);
-    assertThat(fileCache.stats()).since(startPatch).hasHitCount(0);
     assertThat(intraCache.stats()).since(startIntra).hasMissCount(0);
     assertThat(intraCache.stats()).since(startIntra).hasHitCount(0);
     assertThat(diffSummaryCache.stats()).since(startSummary).hasMissCount(0);
@@ -436,25 +451,25 @@
         .newAccount()
         .username(name("user1"))
         .preferredEmail(email1)
-        .fullname("User 1")
+        .fullname("User1")
         .create();
     accountOperations
         .newAccount()
         .username(name("user2"))
         .preferredEmail(email2)
-        .fullname("User 2")
+        .fullname("User2")
         .create();
     accountOperations
         .newAccount()
         .username(name("user3"))
         .preferredEmail(email3)
-        .fullname("User 3")
+        .fullname("User3")
         .create();
     accountOperations
         .newAccount()
         .username(name("user4"))
         .preferredEmail(email4)
-        .fullname("User 4")
+        .fullname("User4")
         .create();
     ReviewInput in =
         ReviewInput.noScore()
@@ -641,7 +656,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWorkInProgressChangeOwner() throws Exception {
     PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -656,7 +671,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void reviewWithWithWorkInProgressAdmin() throws Exception {
     PushOneCommit push = pushFactory.create(user.newIdent(), testRepo);
     PushOneCommit.Result r = push.to("refs/for/master");
@@ -797,6 +812,28 @@
   }
 
   @Test
+  public void rebaseAsUploaderInAttentionSet() throws Exception {
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve());
+    revision.submit();
+
+    TestAccount admin2 = accountCreator.admin2();
+    requestScopeOperations.setApiUser(admin2.id());
+    amendChangeWithUploader(r2, project, admin2);
+    gApi.changes()
+        .id(r2.getChangeId())
+        .addToAttentionSet(new AttentionSetInput(admin2.id().toString(), "manual update"));
+
+    gApi.changes().id(r2.getChangeId()).rebase();
+  }
+
+  @Test
   public void rebaseOnChangeNumber() throws Exception {
     String branchTip = testRepo.getRepository().exactRef("HEAD").getObjectId().name();
     PushOneCommit.Result r1 = createChange();
@@ -994,7 +1031,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteNewChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1019,7 +1056,7 @@
   @Test
   public void deleteNewChangeAsUserWithDeleteChangesPermissionForProjectOwners() throws Exception {
     GroupApi groupApi = gApi.groups().create(name("delete-change"));
-    groupApi.addMembers("user");
+    groupApi.addMembers("user1");
 
     Project.NameKey nameKey = Project.nameKey(name("delete-change"));
     ProjectInput in = new ProjectInput();
@@ -1148,7 +1185,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeAsNormalUser() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1163,7 +1200,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteAbandonedChangeOfAnotherUserAsAdmin() throws Exception {
     PushOneCommit.Result changeResult =
         pushFactory.create(user.newIdent(), testRepo).to("refs/for/master");
@@ -1189,7 +1226,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void deleteMergedChangeWithDeleteOwnChangesPermission() throws Exception {
     projectOperations
         .project(project)
@@ -1791,9 +1828,9 @@
 
     // try to add user as reviewer
     requestScopeOperations.setApiUser(admin.id());
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(user.email());
     assertThat(r.error).contains("does not have permission to see this change");
@@ -1801,42 +1838,15 @@
   }
 
   @Test
-  public void addReviewerThatIsInactive() throws Exception {
+  public void addReviewerThatIsInactiveByUsername() throws Exception {
     PushOneCommit.Result result = createChange();
 
     String username = name("new-user");
     Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = username;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
-
-    assertThat(r.input).isEqualTo(in.reviewer);
-    assertThat(r.error)
-        .isEqualTo(
-            "Account '"
-                + username
-                + "' only matches inactive accounts. To use an inactive account, retry with one of"
-                + " the following exact account IDs:\n"
-                + id
-                + ": Name of user not set ("
-                + id
-                + ")\n"
-                + username
-                + " does not identify a registered user or group");
-    assertThat(r.reviewers).isNull();
-  }
-
-  @Test
-  public void addReviewerThatIsInactiveById() throws Exception {
-    PushOneCommit.Result result = createChange();
-
-    String username = name("new-user");
-    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = Integer.toString(id.get());
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(in.reviewer);
     assertThat(r.error).isNull();
@@ -1847,26 +1857,44 @@
   }
 
   @Test
-  public void addReviewerThatIsInactiveEmailFallback() throws Exception {
+  public void addReviewerThatIsInactiveById() throws Exception {
+    PushOneCommit.Result result = createChange();
+
+    String username = name("new-user");
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
+
+    ReviewerInput in = new ReviewerInput();
+    in.reviewer = Integer.toString(id.get());
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+
+    assertThat(r.input).isEqualTo(in.reviewer);
+    assertThat(r.error).isNull();
+    assertThat(r.reviewers).hasSize(1);
+    ReviewerInfo reviewer = r.reviewers.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
+  }
+
+  @Test
+  public void addReviewerThatIsInactiveByEmail() throws Exception {
     ConfigInput conf = new ConfigInput();
     conf.enableReviewerByEmail = InheritableBoolean.TRUE;
     gApi.projects().name(project.get()).config(conf);
-
     PushOneCommit.Result result = createChange();
-
     String username = "user@domain.com";
-    accountOperations.newAccount().username(username).inactive().create();
+    Account.Id id = accountOperations.newAccount().username(username).inactive().create();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = username;
     in.state = ReviewerState.CC;
-    AddReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
+    ReviewerResult r = gApi.changes().id(result.getChangeId()).addReviewer(in);
 
     assertThat(r.input).isEqualTo(username);
     assertThat(r.error).isNull();
-    // When adding by email, the reviewers field is also empty because we can't
-    // render a ReviewerInfo object for a non-account.
-    assertThat(r.reviewers).isNull();
+    assertThat(r.ccs).hasSize(1);
+    AccountInfo reviewer = r.ccs.get(0);
+    assertThat(reviewer._accountId).isEqualTo(id.get());
+    assertThat(reviewer.username).isEqualTo(username);
   }
 
   @Test
@@ -1874,7 +1902,7 @@
   public void addReviewer() throws Exception {
     testAddReviewerViaPostReview(
         (changeId, reviewer) -> {
-          AddReviewerInput in = new AddReviewerInput();
+          ReviewerInput in = new ReviewerInput();
           in.reviewer = reviewer;
           gApi.changes().id(changeId).addReviewer(in);
         });
@@ -1885,10 +1913,10 @@
   public void addReviewerViaPostReview() throws Exception {
     testAddReviewerViaPostReview(
         (changeId, reviewer) -> {
-          AddReviewerInput addReviewerInput = new AddReviewerInput();
-          addReviewerInput.reviewer = reviewer;
+          ReviewerInput reviewerInput = new ReviewerInput();
+          reviewerInput.reviewer = reviewer;
           ReviewInput reviewInput = new ReviewInput();
-          reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+          reviewInput.reviewers = ImmutableList.of(reviewerInput);
           gApi.changes().id(changeId).current().review(reviewInput);
         });
   }
@@ -1948,7 +1976,7 @@
   @Test
   public void listReviewers() throws Exception {
     PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
     assertThat(gApi.changes().id(r.getChangeId()).reviewers()).hasSize(1);
@@ -1959,7 +1987,7 @@
         .newAccount()
         .username(username1)
         .preferredEmail(email1)
-        .fullname("User 1")
+        .fullname("User1")
         .create();
     in.reviewer = email1;
     in.state = ReviewerState.CC;
@@ -1970,7 +1998,7 @@
 
   @Test
   public void notificationsForAddedWorkInProgressReviewers() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     ReviewInput batchIn = new ReviewInput();
     batchIn.reviewers = ImmutableList.of(in);
@@ -2025,7 +2053,7 @@
     groupApi.description("test group");
     groupApi.addMembers(user.fullName());
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = "abc";
     gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
 
@@ -2086,7 +2114,7 @@
     // ensure that user "user" is not in the group
     groupApi.removeMembers(testUserFullname);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = testGroup;
     gApi.changes().id(r.getChangeId()).addReviewer(in.reviewer);
 
@@ -2113,6 +2141,46 @@
   }
 
   @Test
+  public void deleteGroupFromReviewersFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    // create a group named "kobe" with one user: lee
+    String myGroupUserEmail = "lee@example.com";
+    String myGroupUserFullname = "lee";
+    accountOperations
+        .newAccount()
+        .username("lee")
+        .preferredEmail(myGroupUserEmail)
+        .fullname(myGroupUserFullname)
+        .create();
+
+    String groupName = "kobe";
+    String testGroup = groupOperations.newGroup().name(groupName).create().get();
+    GroupApi groupApi = gApi.groups().id(testGroup);
+    groupApi.description("test group");
+    groupApi.addMembers(myGroupUserFullname);
+
+    // add the user as reviewer.
+    gApi.changes().id(r.getChangeId()).addReviewer(myGroupUserFullname);
+
+    // fail to remove that user via group.
+    ReviewResult reviewResult =
+        gApi.changes()
+            .id(r.getChangeId())
+            .current()
+            .review(ReviewInput.create().reviewer(testGroup, REMOVED, /* confirmed= */ true));
+
+    assertThat(reviewResult.error).isEqualTo("error adding reviewer");
+
+    ReviewerInput in = new ReviewerInput();
+    in.reviewer = testGroup;
+    in.state = REMOVED;
+    ReviewerResult reviewerResult = gApi.changes().id(r.getChangeId()).addReviewer(in);
+    assertThat(reviewerResult.error)
+        .isEqualTo(MessageFormat.format(ChangeMessages.get().groupRemovalIsNotAllowed, groupName));
+  }
+
+  @Test
   @UseClockStep
   public void addSelfAsReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -2120,7 +2188,7 @@
     String oldETag = rsrc.getETag();
     Timestamp oldTs = rsrc.getChange().getLastUpdatedOn();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(r.getChangeId()).addReviewer(in);
@@ -2212,7 +2280,7 @@
     assertThat(reviewers.iterator().next()._accountId).isEqualTo(admin.id().get());
     assertThat(c.reviewers).doesNotContainKey(CC);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -2279,7 +2347,7 @@
   public void emailNotificationForFileLevelComment() throws Exception {
     String changeId = createChange().getChangeId();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
     sender.clear();
@@ -2391,12 +2459,17 @@
 
     assertThat(sender.getMessages()).hasSize(1);
     Message message = sender.getMessages().get(0);
-    assertThat(message.body()).contains("Removed reviewer " + user.fullName() + ".");
+    assertThat(message.body()).contains("Removed reviewer " + user.getNameEmail() + ".");
     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());
+        .isEqualTo("Removed reviewer " + user.getNameEmail() + ".");
+    ChangeMessageInfo changeMessageInfo =
+        Iterables.getLast(gApi.changes().id(changeId).get().messages);
+    assertThat(changeMessageInfo.message)
+        .isEqualTo("Removed reviewer " + AccountTemplateUtil.getAccountTemplate(user.id()) + ".");
+    assertThat(changeMessageInfo.accountsInMessage).containsExactly(getAccountInfo(user.id()));
 
     // Make sure the reviewer can still be added again.
     gApi.changes().id(changeId).addReviewer(user.id().toString());
@@ -2417,10 +2490,10 @@
     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);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = CC;
+    reviewerInput.reviewer = user.id().toString();
+    gApi.changes().id(changeId).addReviewer(reviewerInput);
 
     // Remove a cc
     sender.clear();
@@ -2430,11 +2503,17 @@
     // 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() + ".");
+    assertThat(message.body()).contains("Removed cc " + user.getNameEmail() + ".");
 
     // 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());
+        .isEqualTo("Removed cc " + user.getNameEmail() + ".");
+
+    ChangeMessageInfo changeMessageInfo =
+        Iterables.getLast(gApi.changes().id(changeId).get().messages);
+    assertThat(changeMessageInfo.message)
+        .isEqualTo("Removed cc " + AccountTemplateUtil.getAccountTemplate(user.id()) + ".");
+    assertThat(changeMessageInfo.accountsInMessage).containsExactly(getAccountInfo(user.id()));
   }
 
   @Test
@@ -2474,8 +2553,22 @@
       assertThat(sender.getMessages()).hasSize(1);
       Message message = sender.getMessages().get(0);
       assertThat(message.body())
-          .contains("Removed reviewer " + user.fullName() + " with the following votes");
-      assertThat(message.body()).contains("* Code-Review+1 by " + user.fullName());
+          .contains("Removed reviewer " + user.getNameEmail() + " with the following votes");
+      assertThat(message.body()).contains("* Code-Review+1 by " + user.getNameEmail());
+      ChangeMessageInfo changeMessageInfo =
+          Iterables.getLast(gApi.changes().id(changeId).messages());
+      assertThat(changeMessageInfo.message)
+          .contains("Removed reviewer " + user.getNameEmail() + " with the following votes");
+      assertThat(changeMessageInfo.message).contains("* Code-Review+1 by " + user.getNameEmail());
+      changeMessageInfo = Iterables.getLast(gApi.changes().id(changeId).get().messages);
+      assertThat(changeMessageInfo.message)
+          .contains(
+              "Removed reviewer "
+                  + AccountTemplateUtil.getAccountTemplate(user.id())
+                  + " with the following votes");
+      assertThat(changeMessageInfo.message)
+          .contains("* Code-Review+1 by " + AccountTemplateUtil.getAccountTemplate(user.id()));
+      assertThat(changeMessageInfo.accountsInMessage).containsExactly(getAccountInfo(user.id()));
     } else {
       assertThat(sender.getMessages()).isEmpty();
     }
@@ -2591,7 +2684,12 @@
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            "Removed Code-Review+1 by " + AccountTemplateUtil.getAccountTemplate(user.id()) + "\n");
+    assertThat(message.accountsInMessage).containsExactly(getAccountInfo(user.id()));
+    assertThat(gApi.changes().id(r.getChangeId()).message(message.id).get().message)
+        .isEqualTo("Removed Code-Review+1 by User1 <user1@example.com>\n");
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
@@ -2708,7 +2806,7 @@
     assertThat(c.reviewers.get(CC)).isNull();
 
     // Add the user as reviewer
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
     c = gApi.changes().id(changeId).get();
@@ -2925,7 +3023,7 @@
   @Test
   public void checkReviewedFlagBeforeAndAfterReview() throws Exception {
     PushOneCommit.Result r = createChange();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -3033,6 +3131,24 @@
   }
 
   @Test
+  public void submitToSymref() throws Exception {
+    // Create symref in the origin repository (testRepo references to a local repository)
+    try (Repository repo = repoManager.openRepository(project)) {
+      RefUpdate u = repo.updateRef("refs/heads/master_symref");
+      assertThat(u.link("refs/heads/master")).isEqualTo(Result.NEW);
+    }
+
+    PushOneCommit.Result r = createChange("refs/for/master_symref");
+    String id = r.getChangeId();
+
+    gApi.changes().id(id).current().review(ReviewInput.approve());
+    ResourceConflictException thrown =
+        assertThrows(
+            ResourceConflictException.class, () -> gApi.changes().id(id).current().submit());
+    assertThat(thrown).hasMessageThat().contains("the target branch is a symbolic ref");
+  }
+
+  @Test
   public void check() throws Exception {
     PushOneCommit.Result r = createChange();
     assertThat(gApi.changes().id(r.getChangeId()).get().problems).isNull();
@@ -3164,10 +3280,7 @@
               gApi.changes()
                   .query()
                   .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
-                  // Options should match defaults in AccountDashboardScreen.
-                  .withOption(LABELS)
-                  .withOption(DETAILED_ACCOUNTS)
-                  .withOption(REVIEWED)
+                  .withOptions(IndexPreloadingUtil.DASHBOARD_OPTIONS)
                   .get())
           .hasSize(2);
     }
@@ -3826,7 +3939,7 @@
     for (com.google.gerrit.acceptance.TestAccount acc : ImmutableList.of(admin, user)) {
       requestScopeOperations.setApiUser(acc.id());
       String newMessage =
-          "modified commit by " + acc.username() + "\n\nChange-Id: " + r.getChangeId() + "\n";
+          "modified commit by " + acc.id() + "\n\nChange-Id: " + r.getChangeId() + "\n";
       gApi.changes().id(r.getChangeId()).setMessage(newMessage);
       RevisionApi rApi = gApi.changes().id(r.getChangeId()).current();
       assertThat(rApi.files().keySet()).containsExactly("/COMMIT_MSG", "a.txt");
@@ -3937,6 +4050,938 @@
   }
 
   @Test
+  public void submitRecords() throws Exception {
+    PushOneCommit.Result r = createChange();
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      String changeId = r.getChangeId();
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      // Check the default submit record for the code-review label
+      SubmitRecordInfo codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.NOT_READY);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.NEED);
+      assertThat(label.appliedBy).isNull();
+      // Check the custom test record created by the TestSubmitRule
+      SubmitRecordInfo testRecord = Iterables.get(change.submitRecords, 1);
+      assertThat(testRecord.ruleName).isEqualTo("gerrit~TestSubmitRule");
+      assertThat(testRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(testRecord.requirements)
+          .containsExactly(new LegacySubmitRequirementInfo("OK", "fallback text", "type"));
+      assertThat(testRecord.labels).hasSize(1);
+      SubmitRecordInfo.Label testLabel = Iterables.getOnlyElement(testRecord.labels);
+      assertThat(testLabel.label).isEqualTo("label");
+      assertThat(testLabel.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(testLabel.appliedBy).isNull();
+
+      voteLabel(changeId, "code-review", 2);
+      // Code review record is satisfied after voting +2
+      change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRecords).hasSize(2);
+      codeReviewRecord = Iterables.get(change.submitRecords, 0);
+      assertThat(codeReviewRecord.ruleName).isEqualTo("gerrit~DefaultSubmitRule");
+      assertThat(codeReviewRecord.status).isEqualTo(SubmitRecordInfo.Status.OK);
+      assertThat(codeReviewRecord.labels).hasSize(1);
+      label = Iterables.getOnlyElement(codeReviewRecord.labels);
+      assertThat(label.label).isEqualTo("Code-Review");
+      assertThat(label.status).isEqualTo(SubmitRecordInfo.Label.Status.OK);
+      assertThat(label.appliedBy._accountId).isEqualTo(admin.id().get());
+    }
+  }
+
+  @Test
+  public void checkSubmitRequirement_satisfied() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review", /* submittabilityExpression= */ "label:Code-Review=+2");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+  }
+
+  @Test
+  public void checkSubmitRequirement_notApplicable() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ "branch:non-existent",
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ null);
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void checkSubmitRequirement_overridden() throws Exception {
+    configLabel("Override-Label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("Override-Label")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput(
+            "Code-Review",
+            /* applicableIf= */ null,
+            /* submittableIf= */ "label:Code-Review=+2",
+            /* overrideIf= */ "label:Override-Label=+1");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.UNSATISFIED);
+
+    voteLabel(changeId, "Code-Review", 2);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.SATISFIED);
+
+    voteLabel(changeId, "Override-Label", 1);
+    result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void checkSubmitRequirement_error() throws Exception {
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    SubmitRequirementInput in =
+        createSubmitRequirementInput("Code-Review", /* submittabilityExpression= */ "!!!");
+
+    SubmitRequirementResultInfo result = gApi.changes().id(changeId).checkSubmitRequirement(in);
+    assertThat(result.status).isEqualTo(SubmitRequirementResultInfo.Status.ERROR);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_withLabelEqualsMax() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:code-review=MAX"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_withLabelEqualsMax_fromNonUploader() throws Exception {
+    configLabel("my-label", LabelFunction.NO_OP); // label function has no effect
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:my-label=MAX,user=non_uploader"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The second requirement is coming from the legacy code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting with a max vote as the uploader will not satisfy the submit requirement.
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Voting as a non-uploader will satisfy the submit requirement.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_withLabelEqualsMinBlockingSubmission() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("-label:code-review=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is satisfied because there are no votes
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement (coming from the label function definition) is not satisfied. We return
+    // both legacy and non-legacy requirements in this case since their statuses are not identical.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "code-review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Requirement is still satisfied because -1 is not the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    voteLabel(changeId, "code-review", -2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    // Requirement is now unsatisfied because -2 is the max negative value
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_withMaxWithBlock_ignoringSelfApproval() throws Exception {
+    configLabel("my-label", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel("my-label").ref("refs/heads/master").group(REGISTERED_USERS).range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("my-label")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:my-label=MAX,user=non_uploader -label:my-label=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create the change as admin
+    requestScopeOperations.setApiUser(admin.id());
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    // Admin (a.k.a uploader) adds a -1 min vote. This is going to block submission.
+    voteLabel(changeId, "my-label", -1);
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    // The other requirement is coming from the code-review label function
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // user (i.e. non_uploader) votes 1. Requirement is still blocking because of -1 of uploader.
+    requestScopeOperations.setApiUser(user.id());
+    voteLabel(changeId, "my-label", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    // Admin (a.k.a uploader) removes -1. Now requirement is fulfilled.
+    requestScopeOperations.setApiUser(admin.id());
+    voteLabel(changeId, "my-label", 0);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "my-label", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_withLabelEqualsAny() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:code-review=ANY"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // Legacy and non-legacy requirements have mismatching status. Both are returned from the API.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "verified", Status.UNSATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsNotFulfilled()
+      throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setApplicabilityExpression(SubmitRequirementExpression.of("project:foo"))
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.NOT_APPLICABLE, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsFulfilled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.NO_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "build-cop-override", 1);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.OVERRIDDEN, /* isLegacy= */ false);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  @Ignore("Test is flaky")
+  public void submitRequirement_overriddenInChildProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(true)
+            .build());
+
+    // Override submit requirement in child project (requires code-review=+2 instead of +1)
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_inheritedFromParentProject() throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_ignoredInChildProject_ifParentDoesNotAllowOverride()
+      throws Exception {
+    configSubmitRequirement(
+        allProjects,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+1"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Override submit requirement in child project (requires code-review=+2 instead of +1).
+    // Will have no effect since parent does not allow override.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setOverrideExpression(SubmitRequirementExpression.of("label:build-cop-override=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    // +1 was enough to fulfill the requirement: override in child project was ignored
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+    // Legacy requirement is coming from the label MaxWithBlock function. Still unsatisfied.
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
+  public void submitRequirement_storedForClosedChanges() throws Exception {
+    for (SubmitType submitType : SubmitType.values()) {
+      Project.NameKey project = createProjectForPush(submitType);
+      TestRepository<InMemoryRepository> repo = cloneProject(project);
+      configSubmitRequirement(
+          project,
+          SubmitRequirement.builder()
+              .setName("code-review")
+              .setSubmittabilityExpression(
+                  SubmitRequirementExpression.create("label:code-review=+2"))
+              .setAllowOverrideInChildProjects(false)
+              .build());
+
+      PushOneCommit.Result r =
+          createChange(repo, "master", "Add a file", "foo", "content", "topic");
+      String changeId = r.getChangeId();
+
+      voteLabel(changeId, "code-review", 2);
+
+      ChangeInfo change = gApi.changes().id(changeId).get();
+      assertThat(change.submitRequirements).hasSize(1);
+      assertSubmitRequirementStatus(
+          change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+
+      RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+      revision.review(ReviewInput.approve());
+      revision.submit();
+
+      ChangeNotes notes = notesFactory.create(project, r.getChange().getId());
+
+      SubmitRequirementResult result =
+          notes.getSubmitRequirementsResult().stream().collect(MoreCollectors.onlyElement());
+      assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+      assertThat(result.submittabilityExpressionResult().status())
+          .isEqualTo(SubmitRequirementExpressionResult.Status.PASS);
+      assertThat(result.submittabilityExpressionResult().expression().expressionString())
+          .isEqualTo("label:code-review=+2");
+    }
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
+  public void submitRequirement_retrievedFromNoteDbForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.UNSATISFIED, /* isLegacy= */ false);
+
+    voteLabel(changeId, "code-review", 2);
+
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+
+    gApi.changes().id(changeId).current().submit();
+
+    // Add new submit requirement
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("verified")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:verified=+1"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // The new "verified" submit requirement is not returned, since this change is closed
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(1);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "code-review", Status.SATISFIED, /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
+  public void
+      submitRequirements_returnOneEntryForMatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create(
+                    "label:build-cop-override=MAX -label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change. Vote to fulfill all requirements.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Only non-legacy bco is returned.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+
+    // Merge the change. Submit requirements are still the same.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void
+      submitRequirements_returnTwoEntriesForMismatchingLegacyAndNonLegacyResultsWithTheSameName_ifLegacySubmitRecordsAreEnabled()
+          throws Exception {
+    // Configure a legacy submit requirement: label with a max with block function
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // Configure a submit requirement with the same name.
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("build-cop-override")
+            .setSubmittabilityExpression(
+                SubmitRequirementExpression.create("label:build-cop-override=MIN"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    // Create a change
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    voteLabel(changeId, "build-cop-override", 1);
+    voteLabel(changeId, "Code-Review", 2);
+
+    // Project has two legacy requirements: Code-Review and bco, and a non-legacy requirement: bco.
+    // Two instances of bco will be returned since their status is not matching.
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(3);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.SATISFIED,
+        /* isLegacy= */ true,
+        // MAX_WITH_BLOCK function was translated to a submittability expression.
+        /* submittabilityCondition= */ "label:build-cop-override=MAX -label:build-cop-override=MIN");
+    assertSubmitRequirementStatus(
+        change.submitRequirements,
+        "build-cop-override",
+        Status.UNSATISFIED,
+        /* isLegacy= */ false,
+        /* submittabilityCondition= */ "label:build-cop-override=MIN");
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirements_returnForLegacySubmitRecords_ifEnabled() throws Exception {
+    configLabel("build-cop-override", LabelFunction.MAX_WITH_BLOCK);
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel("build-cop-override")
+                .ref("refs/heads/master")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // 1. Project has two legacy requirements: Code-Review and bco. Both unsatisfied.
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.UNSATISFIED, /* isLegacy= */ true);
+
+    // 2. Vote +1 on bco. bco becomes satisfied
+    voteLabel(changeId, "build-cop-override", 1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.UNSATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 3. Vote +1 on Code-Review. Code-Review becomes satisfied
+    voteLabel(changeId, "Code-Review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+
+    // 4. Merge the change. Submit requirements status is presented from NoteDb.
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    // Legacy submit records are returned as submit requirements.
+    assertThat(change.submitRequirements).hasSize(2);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "Code-Review", Status.SATISFIED, /* isLegacy= */ true);
+    assertSubmitRequirementStatus(
+        change.submitRequirements, "build-cop-override", Status.SATISFIED, /* isLegacy= */ true);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      value = ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS)
+  public void submitRequirement_backFilledFromIndexForActiveChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "code-review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS,
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_STORE_SUBMIT_REQUIREMENTS_ON_MERGE
+      })
+  public void submitRequirement_backFilledFromIndexForClosedChanges() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    voteLabel(changeId, "code-review", 2);
+    gApi.changes().id(changeId).current().submit();
+
+    // Query the change. ChangeInfo is back-filled from the change index.
+    List<ChangeInfo> changeInfos =
+        gApi.changes()
+            .query()
+            .withQuery("project:{" + project.get() + "} (status:open OR status:closed)")
+            .withOptions(ImmutableSet.of(ListChangesOption.SUBMIT_REQUIREMENTS))
+            .get();
+    assertThat(changeInfos).hasSize(1);
+    assertSubmitRequirementStatus(
+        changeInfos.get(0).submitRequirements,
+        "code-review",
+        Status.SATISFIED,
+        /* isLegacy= */ false);
+  }
+
+  @Test
+  public void submitRequirements_notServedIfExperimentNotEnabled() throws Exception {
+    configSubmitRequirement(
+        project,
+        SubmitRequirement.builder()
+            .setName("code-review")
+            .setSubmittabilityExpression(SubmitRequirementExpression.create("label:code-review=+2"))
+            .setAllowOverrideInChildProjects(false)
+            .build());
+
+    PushOneCommit.Result r = createChange();
+    String changeId = r.getChangeId();
+
+    ChangeInfo change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+
+    voteLabel(changeId, "code-review", -1);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+
+    voteLabel(changeId, "code-review", 2);
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+
+    gApi.changes().id(changeId).current().submit();
+    change = gApi.changes().id(changeId).get();
+    assertThat(change.submitRequirements).isEmpty();
+  }
+
+  @Test
   public void fourByteEmoji() throws Exception {
     // U+1F601 GRINNING FACE WITH SMILING EYES
     String smile = new String(Character.toChars(0x1f601));
@@ -3994,6 +5039,58 @@
     submittableAfterLosingPermissions("Label");
   }
 
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void cantSubmitWithInvisibleChangesWithTopic() throws Exception {
+    createBranch(BranchNameKey.create(project, "secret"));
+
+    // create two changes in the same topic.
+    String topic = "topic";
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange("refs/for/secret");
+    approve(r1.getChangeId());
+    approve(r2.getChangeId());
+    gApi.changes().id(r1.getChangeId()).topic(topic);
+    gApi.changes().id(r2.getChangeId()).topic(topic);
+
+    // make one of the changes invisible.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/heads/secret").group(REGISTERED_USERS))
+        .update();
+
+    // can't submit with invisible changes.
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(AuthException.class, () -> gApi.changes().id(r1.getChangeId()).current().submit());
+  }
+
+  @Test
+  public void cantSubmitWithInvisibleDependentChange() throws Exception {
+    // create two dependent changes.
+    PushOneCommit.Result r1 = createChange();
+    PushOneCommit.Result r2 = createChange();
+    approve(r1.getChangeId());
+    approve(r2.getChangeId());
+
+    // make the dependent change invisible.
+    gApi.changes().id(r1.getChangeId()).setPrivate(true);
+
+    // can't submit with invisible changes.
+    requestScopeOperations.setApiUser(user.id());
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    assertThrows(AuthException.class, () -> gApi.changes().id(r2.getChangeId()).current().submit());
+  }
+
   private void submittableAfterLosingPermissions(String label) throws Exception {
     String codeReviewLabel = LabelId.CODE_REVIEW;
     AccountGroup.UUID registered = REGISTERED_USERS;
@@ -4203,6 +5300,28 @@
   }
 
   @Test
+  @GerritConfig(name = "trackingid.jira-bug.footer", value = "Bug:")
+  @GerritConfig(name = "trackingid.jira-bug.match", value = "\\d+")
+  @GerritConfig(name = "trackingid.jira-bug.system", value = "JIRA")
+  public void multipleTrackingIdsInSingleFooter() throws Exception {
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(),
+            testRepo,
+            PushOneCommit.SUBJECT + "\n\n" + "Bug: 123, 456",
+            PushOneCommit.FILE_NAME,
+            PushOneCommit.FILE_CONTENT);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    result.assertOkStatus();
+
+    ChangeInfo change = gApi.changes().id(result.getChangeId()).get(TRACKING_IDS);
+    Collection<TrackingIdInfo> trackingIds = change.trackingIds;
+    assertThat(trackingIds).isNotNull();
+    assertThat(trackingIds).hasSize(2);
+    assertThat(trackingIds.stream().map(t -> t.id)).containsExactly("123", "456");
+  }
+
+  @Test
   public void starUnstar() throws Exception {
     ChangeIndexedCounter changeIndexedCounter = new ChangeIndexedCounter();
     try (Registration registration =
@@ -4238,11 +5357,11 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
-    in = new AddReviewerInput();
+    in = new ReviewerInput();
     in.reviewer = email;
     gApi.changes().id(r.getChangeId()).addReviewer(in);
 
@@ -4330,132 +5449,6 @@
   }
 
   @Test
-  public void markAsReviewed() throws Exception {
-    com.google.gerrit.acceptance.TestAccount user2 = accountCreator.user2();
-
-    PushOneCommit.Result r = createChange();
-
-    AddReviewerInput in = new AddReviewerInput();
-    in.reviewer = user.email();
-    gApi.changes().id(r.getChangeId()).addReviewer(in);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
-    gApi.changes().id(r.getChangeId()).markAsReviewed(true);
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isTrue();
-
-    requestScopeOperations.setApiUser(user2.id());
-    sender.clear();
-    amendChange(r.getChangeId());
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThat(gApi.changes().id(r.getChangeId()).get().reviewed).isNull();
-
-    List<Message> messages = sender.getMessages();
-    assertThat(messages).hasSize(1);
-    assertThat(messages.get(0).rcpt()).containsExactly(user.getNameEmail());
-  }
-
-  @Test
-  public void cannotSetUnreviewedLabelForPatchSetThatAlreadyHasReviewedLabel() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).markAsReviewed(true);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
-
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () ->
-                gApi.accounts()
-                    .self()
-                    .setStars(
-                        changeId,
-                        new StarsInput(
-                            ImmutableSet.of(StarredChangesUtil.UNREVIEWED_LABEL + "/1"))));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.REVIEWED_LABEL
-                + "/"
-                + 1
-                + " and "
-                + StarredChangesUtil.UNREVIEWED_LABEL
-                + "/"
-                + 1
-                + " are mutually exclusive. Only one of them can be set.");
-  }
-
-  @Test
-  public void cannotSetReviewedLabelForPatchSetThatAlreadyHasUnreviewedLabel() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).markAsReviewed(false);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
-
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () ->
-                gApi.accounts()
-                    .self()
-                    .setStars(
-                        changeId,
-                        new StarsInput(ImmutableSet.of(StarredChangesUtil.REVIEWED_LABEL + "/1"))));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "The labels "
-                + StarredChangesUtil.REVIEWED_LABEL
-                + "/"
-                + 1
-                + " and "
-                + StarredChangesUtil.UNREVIEWED_LABEL
-                + "/"
-                + 1
-                + " are mutually exclusive. Only one of them can be set.");
-  }
-
-  @Test
-  public void setReviewedAndUnreviewedLabelsForDifferentPatchSets() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    requestScopeOperations.setApiUser(user.id());
-    gApi.changes().id(changeId).markAsReviewed(true);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isTrue();
-
-    amendChange(changeId);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
-
-    gApi.changes().id(changeId).markAsReviewed(false);
-    assertThat(gApi.changes().id(changeId).get().reviewed).isNull();
-
-    assertThat(gApi.accounts().self().getStars(changeId))
-        .containsExactly(
-            StarredChangesUtil.REVIEWED_LABEL + "/" + 1,
-            StarredChangesUtil.UNREVIEWED_LABEL + "/" + 2);
-  }
-
-  @Test
-  public void cannotSetInvalidLabel() throws Exception {
-    String changeId = createChange().getChangeId();
-
-    // label cannot contain whitespace
-    String invalidLabel = "invalid label";
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () ->
-                gApi.accounts()
-                    .self()
-                    .setStars(changeId, new StarsInput(ImmutableSet.of(invalidLabel))));
-    assertThat(thrown).hasMessageThat().contains("invalid labels: " + invalidLabel);
-  }
-
-  @Test
   public void changeDetailsDoesNotRequireIndex() throws Exception {
     // This set of options must be kept in sync with gr-rest-api-interface.js
     Set<ListChangesOption> options =
@@ -4525,4 +5518,104 @@
           event.getChange().workInProgress != null ? event.getChange().workInProgress : false;
     }
   }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy,
+      String submittabilityCondition) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy
+          && result.submittabilityExpressionResult.expression.equals(submittabilityCondition)) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private void assertSubmitRequirementStatus(
+      Collection<SubmitRequirementResultInfo> results,
+      String requirementName,
+      SubmitRequirementResultInfo.Status status,
+      boolean isLegacy) {
+    for (SubmitRequirementResultInfo result : results) {
+      if (result.name.equals(requirementName)
+          && result.status == status
+          && result.isLegacy == isLegacy) {
+        return;
+      }
+    }
+    throw new AssertionError(
+        String.format(
+            "Could not find submit requirement %s with status %s (results = %s)",
+            requirementName,
+            status,
+            results.stream()
+                .map(r -> String.format("%s=%s", r.name, r.status))
+                .collect(toImmutableList())));
+  }
+
+  private Project.NameKey createProjectForPush(SubmitType submitType) throws Exception {
+    Project.NameKey project = projectOperations.newProject().submitType(submitType).create();
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/heads/*").group(adminGroupUuid()))
+        .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/*").group(adminGroupUuid()))
+        .update();
+    return project;
+  }
+
+  /** Returns a hard-coded submit record containing all fields. */
+  private static class TestSubmitRule implements SubmitRule {
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      SubmitRecord record = new SubmitRecord();
+      record.ruleName = "testSubmitRule";
+      record.status = SubmitRecord.Status.OK;
+      SubmitRecord.Label label = new SubmitRecord.Label();
+      label.label = "label";
+      label.status = SubmitRecord.Label.Status.OK;
+      record.labels = Arrays.asList(label);
+      record.requirements =
+          Arrays.asList(
+              LegacySubmitRequirement.builder()
+                  .setType("type")
+                  .setFallbackText("fallback text")
+                  .build());
+      return Optional.of(record);
+    }
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String submittabilityExpression) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.submittabilityExpression = submittabilityExpression;
+    return input;
+  }
+
+  private static SubmitRequirementInput createSubmitRequirementInput(
+      String name, String applicableIf, String submittableIf, String overrideIf) {
+    SubmitRequirementInput input = new SubmitRequirementInput();
+    input.name = name;
+    input.applicabilityExpression = applicableIf;
+    input.submittabilityExpression = submittableIf;
+    input.overrideExpression = overrideIf;
+    return input;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
index c6b57da..96db71a 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/ChangeSubmitRequirementIT.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.LegacySubmitRequirement;
 import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -28,6 +29,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LegacySubmitRequirementInfo;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.experiments.ExperimentFeaturesConstants;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.rules.SubmitRule;
 import com.google.inject.Inject;
@@ -201,6 +203,37 @@
     assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
   }
 
+  @Test
+  @GerritConfig(
+      name = "experiments.enabled",
+      values = {
+        ExperimentFeaturesConstants
+            .GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS_BACKFILLING_ON_DASHBOARD,
+        ExperimentFeaturesConstants.GERRIT_BACKEND_REQUEST_FEATURE_ENABLE_SUBMIT_REQUIREMENTS
+      })
+  public void submitRuleIsInvokedWhenQueryingChangeWithExperiment() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
+
+    // Submit rules are invoked
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(1);
+  }
+
+  @Test
+  public void submitRuleIsNotInvokedWhenQueryingChangeWithoutExperiment() throws Exception {
+    PushOneCommit.Result r = createChange("Some Change", "foo.txt", "some content");
+    String changeId = r.getChangeId();
+
+    rule.numberOfEvaluations.set(0);
+    gApi.changes().query(changeId).withOptions(ListChangesOption.SUBMIT_REQUIREMENTS).get();
+
+    // Submit rules are not invoked
+    assertThat(rule.numberOfEvaluations.get()).isEqualTo(0);
+  }
+
   private List<ChangeInfo> queryIsSubmittable() throws Exception {
     return gApi.changes().query("is:submittable project:" + project.get()).get();
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index c8b1715..96bc65d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toList;
@@ -26,6 +27,7 @@
 
 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.truth.Correspondence;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -40,12 +42,14 @@
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.SubmitRecord;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -53,24 +57,32 @@
 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.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 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.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.OnPostReview;
 import com.google.gerrit.server.restapi.change.PostReview;
+import com.google.gerrit.server.rules.SubmitRule;
 import com.google.gerrit.server.update.CommentsRejectedException;
+import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.regex.Pattern;
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.ArgumentCaptor;
@@ -677,6 +689,281 @@
     }
   }
 
+  @Test
+  public void submitRulesAreInvokedOnlyOnce() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestSubmitRule testSubmitRule = new TestSubmitRule();
+    try (Registration registration = extensionRegistry.newRegistration().add(testSubmitRule)) {
+      ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+      gApi.changes().id(r.getChangeId()).current().review(input);
+    }
+
+    assertThat(testSubmitRule.count).isEqualTo(1);
+  }
+
+  @Test
+  public void addingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+
+    TestReviewerAddedListener testReviewerAddedListener = new TestReviewerAddedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testReviewerAddedListener)) {
+      // add user and user2
+      ReviewResult reviewResult =
+          gApi.changes()
+              .id(r.getChangeId())
+              .current()
+              .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+      assertThat(
+              reviewResult.reviewers.values().stream()
+                  .filter(a -> a.reviewers != null)
+                  .map(a -> Iterables.getOnlyElement(a.reviewers).name)
+                  .collect(toImmutableSet()))
+          .containsExactly(user.fullName(), user2.fullName());
+    }
+
+    assertThat(
+            gApi.changes().id(r.getChangeId()).reviewers().stream()
+                .map(a -> a.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user.fullName(), user2.fullName());
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("Hello ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")"
+                + Pattern.quote(" to <strong>review</strong> this change"));
+
+    // Ensure that a batch event has been sent:
+    // * 1 batch event for adding user and user2 as reviewers
+    assertThat(testReviewerAddedListener.receivedEvents).hasSize(1);
+    assertThat(testReviewerAddedListener.getReviewerIds()).containsExactly(user.id(), user2.id());
+  }
+
+  @Test
+  public void deletingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+
+    // add user and user2
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    sender.clear();
+
+    TestReviewerDeletedListener testReviewerDeletedListener = new TestReviewerDeletedListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testReviewerDeletedListener)) {
+      // remove user and user2
+      ReviewResult reviewResult =
+          gApi.changes()
+              .id(r.getChangeId())
+              .current()
+              .review(
+                  ReviewInput.create()
+                      .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                      .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true));
+
+      assertThat(
+              reviewResult.reviewers.values().stream()
+                  .map(a -> a.removed.name)
+                  .collect(toImmutableSet()))
+          .containsExactly(user.fullName(), user2.fullName());
+    }
+
+    assertThat(gApi.changes().id(r.getChangeId()).reviewers()).isEmpty();
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")");
+
+    // Ensure that events have been sent:
+    // * 2 events for removing user and user2 as reviewers (one event per removed reviewer, batch
+    //   event not available for reviewer removal)
+    assertThat(testReviewerDeletedListener.receivedEvents).hasSize(2);
+    assertThat(testReviewerDeletedListener.getReviewerIds()).containsExactly(user.id(), user2.id());
+  }
+
+  @Test
+  public void addingAndDeletingReviewers() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    TestAccount user2 = accountCreator.user2();
+    TestAccount user3 = accountCreator.create("user3", "user3@email.com", "user3", "user3");
+    TestAccount user4 = accountCreator.create("user4", "user4@email.com", "user4", "user4");
+
+    // add user and user2
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+    sender.clear();
+
+    TestReviewerAddedListener testReviewerAddedListener = new TestReviewerAddedListener();
+    TestReviewerDeletedListener testReviewerDeletedListener = new TestReviewerDeletedListener();
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(testReviewerAddedListener)
+            .add(testReviewerDeletedListener)) {
+      // remove user and user2 while adding user3 and user4
+      ReviewResult reviewResult =
+          gApi.changes()
+              .id(r.getChangeId())
+              .current()
+              .review(
+                  ReviewInput.create()
+                      .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                      .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+                      .reviewer(user3.email())
+                      .reviewer(user4.email()));
+
+      assertThat(
+              reviewResult.reviewers.values().stream()
+                  .filter(a -> a.removed != null)
+                  .map(a -> a.removed.name)
+                  .collect(toImmutableSet()))
+          .containsExactly(user.fullName(), user2.fullName());
+      assertThat(
+              reviewResult.reviewers.values().stream()
+                  .filter(a -> a.reviewers != null)
+                  .map(a -> Iterables.getOnlyElement(a.reviewers).name)
+                  .collect(toImmutableSet()))
+          .containsExactly(user3.fullName(), user4.fullName());
+    }
+
+    assertThat(
+            gApi.changes().id(r.getChangeId()).reviewers().stream()
+                .map(a -> a.name)
+                .collect(toImmutableSet()))
+        .containsExactly(user3.fullName(), user4.fullName());
+
+    // Ensure only one batch email was sent for this operation
+    FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("Hello ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user3.fullName(), user4.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user4.fullName(), user3.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            "("
+                + Pattern.quote(String.format("%s and %s", user3.fullName(), user4.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user4.fullName(), user3.fullName()))
+                + ")"
+                + Pattern.quote(" to <strong>review</strong> this change"));
+
+    assertThat(message.body())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+                + ")");
+    assertThat(message.htmlBody())
+        .containsMatch(
+            Pattern.quote("removed ")
+                + "("
+                + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+                + "|"
+                + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+                + ")");
+
+    // Ensure that events have been sent:
+    // * 1 batch event for adding user3 and user4 as reviewers
+    // * 2 events for removing user and user2 as reviewers (one event per removed reviewer, batch
+    //   event not available for reviewer removal)
+    assertThat(testReviewerAddedListener.receivedEvents).hasSize(1);
+    assertThat(testReviewerAddedListener.getReviewerIds()).containsExactly(user3.id(), user4.id());
+    assertThat(testReviewerDeletedListener.receivedEvents).hasSize(2);
+    assertThat(testReviewerDeletedListener.getReviewerIds()).containsExactly(user.id(), user2.id());
+  }
+
+  @Test
+  public void deletingNonExistingReviewerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .current()
+                    .review(
+                        ReviewInput.create()
+                            .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)));
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Reviewer %s doesn't exist in the change, hence can't delete it", user.fullName()));
+  }
+
+  @Test
+  public void addingAndDeletingSameReviewerFails() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    ResourceNotFoundException resourceNotFoundException =
+        assertThrows(
+            ResourceNotFoundException.class,
+            () ->
+                gApi.changes()
+                    .id(r.getChangeId())
+                    .current()
+                    .review(
+                        ReviewInput.create()
+                            .reviewer(user.email())
+                            .reviewer(user.email(), ReviewerState.REMOVED, true)));
+    assertThat(resourceNotFoundException)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Reviewer %s doesn't exist in the change," + " hence can't delete it",
+                user.fullName()));
+  }
+
   private static class TestListener implements CommentAddedListener {
     public CommentAddedListener.Event lastCommentAddedEvent;
 
@@ -753,4 +1040,46 @@
       assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
     }
   }
+
+  private static class TestSubmitRule implements SubmitRule {
+    int count;
+
+    @Override
+    public Optional<SubmitRecord> evaluate(ChangeData changeData) {
+      count++;
+      return Optional.empty();
+    }
+  }
+
+  private static class TestReviewerAddedListener implements ReviewerAddedListener {
+    List<ReviewerAddedListener.Event> receivedEvents = new ArrayList<>();
+
+    @Override
+    public void onReviewersAdded(ReviewerAddedListener.Event event) {
+      receivedEvents.add(event);
+    }
+
+    public ImmutableSet<Account.Id> getReviewerIds() {
+      return receivedEvents.stream()
+          .flatMap(e -> e.getReviewers().stream())
+          .map(accountInfo -> Account.id(accountInfo._accountId))
+          .collect(toImmutableSet());
+    }
+  }
+
+  private static class TestReviewerDeletedListener implements ReviewerDeletedListener {
+    List<ReviewerDeletedListener.Event> receivedEvents = new ArrayList<>();
+
+    @Override
+    public void onReviewerDeleted(ReviewerDeletedListener.Event event) {
+      receivedEvents.add(event);
+    }
+
+    public ImmutableSet<Account.Id> getReviewerIds() {
+      return receivedEvents.stream()
+          .map(ReviewerDeletedListener.Event::getReviewer)
+          .map(accountInfo -> Account.id(accountInfo._accountId))
+          .collect(toImmutableSet());
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
index 14704ad..31381dd 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/QueryChangesIT.java
@@ -24,8 +24,8 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
@@ -51,7 +51,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.junit.Test;
 
-@NoHttpd
 public class QueryChangesIT extends AbstractDaemonTest {
   @Inject private AccountOperations accountOperations;
   @Inject private ProjectOperations projectOperations;
@@ -334,6 +333,13 @@
   }
 
   @Test
+  public void testInvalidListChangeOption() throws Exception {
+    PushOneCommit.Result r = createChange();
+    RestResponse rep = adminRestSession.get("/changes/" + r.getChange().getId() + "/?O=fffffff");
+    rep.assertBadRequest();
+  }
+
+  @Test
   @SuppressWarnings("unchecked")
   public void skipVisibility_privateChange() throws Exception {
     TestRepository<InMemoryRepository> userRepo = cloneProject(project, user);
diff --git a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
index 6cf3f3e..ff88f31 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/RevertIT.java
@@ -359,7 +359,10 @@
     gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).submit();
 
     sender.clear();
-    gApi.changes().id(r.getChangeId()).revert(createWipRevertInput()).get();
+    // If notify input not specified, the endpoint overrides it to OWNER
+    RevertInput revertInput = createWipRevertInput();
+    revertInput.notify = null;
+    gApi.changes().id(r.getChangeId()).revert(revertInput).get();
     assertThat(sender.getMessages()).isEmpty();
   }
 
@@ -702,7 +705,7 @@
   }
 
   @Test
-  public void revertSubmissionWipNotificationsAreSupressed() throws Exception {
+  public void revertSubmissionWipNotificationsWithNotifyHandlingAll() throws Exception {
     String changeId1 = createChange("first change", "a.txt", "message").getChangeId();
     approve(changeId1);
     gApi.changes().id(changeId1).addReviewer(user.email());
@@ -714,13 +717,12 @@
 
     sender.clear();
 
+    // If notify handling is specified, it will be used by the API
     RevertInput revertInput = createWipRevertInput();
-    // Setting the Notifications to ALL will be overridden because the WIP flag overrides the
-    // notifications to OWNER
     revertInput.notify = NotifyHandling.ALL;
     gApi.changes().id(changeId2).revertSubmission(revertInput);
 
-    assertThat(sender.getMessages()).isEmpty();
+    assertThat(sender.getMessages()).hasSize(4);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
index 4e47bb1..3888679 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/StickyApprovalsIT.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance.api.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
@@ -28,31 +29,33 @@
 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 static org.eclipse.jgit.lib.Constants.HEAD;
+import static java.util.Comparator.comparing;
 
 import com.google.common.cache.Cache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 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.TestProjectInput;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
 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.common.RawInputUtil;
 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.PatchSetApproval;
 import com.google.gerrit.entities.RefNames;
-import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.CommitInfo;
+import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.inject.Inject;
@@ -62,10 +65,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -74,6 +74,7 @@
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
   @Inject private ChangeOperations changeOperations;
+  @Inject private ChangeKindCreator changeKindCreator;
 
   @Inject
   @Named("change_kind")
@@ -136,11 +137,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 1, 0, changeKind);
@@ -148,6 +149,49 @@
   }
 
   @Test
+  public void stickyWhenCopyConditionIsTrue() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:ANY"));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, 1, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, 1, 0, changeKind);
+    }
+  }
+
+  @Test
+  public void stickyEvenWhenUserCantSeeUploaderInGroup() throws Exception {
+    // user can't see admin group
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyCondition("approverin:" + administratorsUUID));
+      u.save();
+    }
+
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    amendChange(changeId);
+    vote(user, changeId, 1, -1); // Invalidate cache
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo c = detailedChange(changeId);
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, 1, -1);
+  }
+
+  @Test
   public void stickyOnMinScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMinScore(true));
@@ -158,11 +202,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, -2, 0, changeKind);
@@ -170,6 +214,30 @@
   }
 
   @Test
+  public void stickyWhenEitherBooleanConfigsOrCopyConditionAreTrue() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyCondition("is:MAX").setCopyMinScore(true));
+      u.save();
+    }
+
+    for (ChangeKind changeKind :
+        EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
+      testRepo.reset(projectOperations.project(project).getHead("master"));
+
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
+      vote(admin, changeId, 2, 1);
+      vote(user, changeId, -2, -1);
+
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
+      ChangeInfo c = detailedChange(changeId);
+      assertVotes(c, admin, 2, 0, changeKind);
+      assertVotes(c, user, -2, 0, changeKind);
+    }
+  }
+
+  @Test
   public void stickyOnMaxScore() throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig().updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyMaxScore(true));
@@ -180,11 +248,11 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -206,12 +274,12 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, -1, 1);
       vote(user, changeId, -2, -1);
       vote(user2, changeId, 1, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, -1, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -227,16 +295,16 @@
       u.save();
     }
 
-    String changeId = createChange(TRIVIAL_REBASE);
+    String changeId = changeKindCreator.createChange(TRIVIAL_REBASE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, TRIVIAL_REBASE);
+    changeKindCreator.updateChange(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, TRIVIAL_REBASE);
     assertVotes(c, user, -2, 0, TRIVIAL_REBASE);
@@ -249,7 +317,8 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    String cherryPickChangeId = cherryPick(changeId, TRIVIAL_REBASE);
+    String cherryPickChangeId =
+        changeKindCreator.cherryPick(changeId, TRIVIAL_REBASE, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 2, 0);
     assertVotes(c, user, -2, 0);
@@ -260,7 +329,7 @@
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    cherryPickChangeId = cherryPick(changeId, REWORK);
+    cherryPickChangeId = changeKindCreator.cherryPick(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(cherryPickChangeId);
     assertVotes(c, admin, 0, 0);
     assertVotes(c, user, 0, 0);
@@ -273,16 +342,16 @@
       u.save();
     }
 
-    String changeId = createChange(NO_CODE_CHANGE);
+    String changeId = changeKindCreator.createChange(NO_CODE_CHANGE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CHANGE);
     assertVotes(c, user, 0, -1, NO_CHANGE);
 
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 1, NO_CODE_CHANGE);
     assertVotes(c, user, 0, -1, NO_CODE_CHANGE);
@@ -299,16 +368,16 @@
       u.save();
     }
 
-    String changeId = createChange(MERGE_FIRST_PARENT_UPDATE);
+    String changeId = changeKindCreator.createChange(MERGE_FIRST_PARENT_UPDATE, testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateChange(changeId, NO_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CHANGE, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, NO_CHANGE);
     assertVotes(c, user, -2, 0, NO_CHANGE);
 
-    updateChange(changeId, MERGE_FIRST_PARENT_UPDATE);
+    changeKindCreator.updateChange(changeId, MERGE_FIRST_PARENT_UPDATE, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, MERGE_FIRST_PARENT_UPDATE);
     assertVotes(c, user, -2, 0, MERGE_FIRST_PARENT_UPDATE);
@@ -323,25 +392,42 @@
       u.save();
     }
 
-    String changeId = createChangeForMergeCommit();
+    String changeId = changeKindCreator.createChangeForMergeCommit(testRepo, admin);
     vote(admin, changeId, 2, 1);
     vote(user, changeId, -2, -1);
 
-    updateSecondParent(changeId);
+    changeKindCreator.updateSecondParent(changeId, testRepo, admin);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, null);
     assertVotes(c, user, 0, 0, null);
   }
 
   @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
-      throws Exception {
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withoutCopyCondition()
+          throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
               LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
       u.save();
     }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+  }
+
+  @Test
+  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded_withCopyCondition()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded();
+  }
+
+  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsAdded()
+      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -361,15 +447,33 @@
   }
 
   @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
-      throws Exception {
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withoutCopyCondition()
+          throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
               LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
       u.save();
     }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+  }
 
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists_withCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists();
+  }
+
+  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileAlreadyExists()
+      throws Exception {
     // create "existing file" and submit it.
     String existingFile = "existing file";
     Change.Id prep =
@@ -401,14 +505,32 @@
   }
 
   @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
-      throws Exception {
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withoutCopyCondition()
+          throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
               LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
       u.save();
     }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted_withCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted();
+  }
+
+  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsDeleted()
+      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -423,14 +545,71 @@
   }
 
   @Test
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
-      throws Exception {
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withoutCopyCondition()
+          throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
               LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
       u.save();
     }
+    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  }
+
+  @Test
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedDueToRebase_withoutCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Modify f.txt in change 1. Approve and submit the first change
+    gApi.changes().id(r.getChangeId()).edit().modifyFile("f.txt", RawInputUtil.create("content"));
+    gApi.changes().id(r.getChangeId()).edit().publish();
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
+    revision.submit();
+
+    // Add an approval whose score should be copied on change 2.
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Rebase the second change. The rebase adds f1.txt.
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    // The code-review approval is copied for the second change between PS1 and PS2 since the only
+    // modified file is due to rebase.
+    List<PatchSetApproval> patchSetApprovals =
+        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertCopied(nonCopied, /* psId= */ 1, LabelId.CODE_REVIEW, (short) 1, false);
+    assertCopied(copied, /* psId= */ 2, LabelId.CODE_REVIEW, (short) 1, true);
+  }
+
+  @Test
+  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified_withCopyCondition()
+      throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified();
+  }
+
+  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModified()
+      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -445,16 +624,33 @@
     assertVotes(c, user, -2, 0);
   }
 
-  @Test
   @TestProjectInput(createEmptyCommit = false)
-  public void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
-      throws Exception {
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withoutCopyCondition()
+          throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
               LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
       u.save();
     }
+    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
+  }
+
+  @TestProjectInput(createEmptyCommit = false)
+  public void
+      stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit_withCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit();
+  }
+
+  private void stickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedAsInitialCommit()
+      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -470,14 +666,79 @@
   }
 
   @Test
-  public void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
-      throws Exception {
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withoutCopyCondition()
+          throws Exception {
     try (ProjectConfigUpdate u = updateProject(project)) {
       u.getConfig()
           .updateLabelType(
               LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
       u.save();
     }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset_withCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset();
+  }
+
+  private void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsModifiedOnEarlierPatchset()
+          throws Exception {
+    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("content").create();
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+    ChangeInfo c = detailedChange(changeId.toString());
+
+    // Don't copy over votes since ps1->ps2 should copy over, but ps2->ps3 should not.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withoutCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+  }
+
+  @Test
+  public void
+      notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed_withCopyCondition()
+          throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed();
+  }
+
+  private void notStickyWithCopyAllScoresIfListOfFilesDidNotChangeWhenFileIsRenamed()
+      throws Exception {
     Change.Id changeId =
         changeOperations.newChange().project(project).file("file").content("content").create();
     vote(admin, changeId.toString(), 2, 1);
@@ -504,7 +765,7 @@
         EnumSet.of(REWORK, TRIVIAL_REBASE, NO_CODE_CHANGE, MERGE_FIRST_PARENT_UPDATE, NO_CHANGE)) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, 2, 1);
       vote(user, changeId, -2, -1);
 
@@ -515,7 +776,7 @@
       assertVotes(c, admin, 0, 0, null);
       assertVotes(c, user, 0, 0, null);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
@@ -530,16 +791,16 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     for (int i = 0; i < 5; i++) {
-      updateChange(changeId, NO_CODE_CHANGE);
+      changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 2, 1, NO_CODE_CHANGE);
     }
 
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
   }
@@ -556,11 +817,11 @@
       u.save();
     }
 
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
-    updateChange(changeId, NO_CODE_CHANGE);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
+    changeKindCreator.updateChange(changeId, NO_CODE_CHANGE, testRepo, admin, project);
 
     Map<Integer, ObjectId> revisions = new HashMap<>();
     gApi.changes()
@@ -589,24 +850,24 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, 2, 1);
 
     // Have someone else vote min score on PS2
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(user, changeId, -2, 0);
     ChangeInfo c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // No vote changes on PS3
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 2, 0, REWORK);
     assertVotes(c, user, -2, 0, REWORK);
 
     // Both users revote on PS4
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     vote(admin, changeId, 1, 1);
     vote(user, changeId, 1, 1);
     c = detailedChange(changeId);
@@ -614,13 +875,127 @@
     assertVotes(c, user, 1, 1, REWORK);
 
     // New approvals shouldn't carry through to PS5
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     c = detailedChange(changeId);
     assertVotes(c, admin, 0, 0, REWORK);
     assertVotes(c, user, 0, 0, REWORK);
   }
 
   @Test
+  public void copyWithListOfFilesUnchanged_withoutCopyCondition() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    copyWithListOfFilesUnchanged();
+  }
+
+  @Test
+  public void copyWithListOfFilesUnchanged_withCopyCondition() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    copyWithListOfFilesUnchanged();
+  }
+
+  private void copyWithListOfFilesUnchanged() throws Exception {
+    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());
+
+    // Code-Review votes are copied over from ps1-> ps2 since the list of files were unchanged.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("very new content")
+        .create();
+    c = detailedChange(changeId.toString());
+
+    // Code-Review votes are copied over from ps1-> ps3 since the list of files were unchanged.
+    assertVotes(c, admin, 2, 0);
+    assertVotes(c, user, -2, 0);
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("new file")
+        .content("new content")
+        .create();
+
+    c = detailedChange(changeId.toString());
+    // Code-Review votes are not copied over from ps1-> ps4 since a file was added.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+
+    changeOperations.change(changeId).newPatchset().file("file").content("content").create();
+
+    c = detailedChange(changeId.toString());
+    // Code-Review votes are not copied over from ps1 -> ps5 since a file was added on ps4.
+    // Although the list of files is the same between ps4->ps5, we don't copy votes from before
+    // ps4.
+    assertVotes(c, admin, 0, 0);
+    assertVotes(c, user, 0, 0);
+  }
+
+  @Test
+  public void copyWithListOfFilesUnchangedButAddedMergeList() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    Change.Id parent1ChangeId = changeOperations.newChange().create();
+    Change.Id parent2ChangeId = changeOperations.newChange().create();
+    Change.Id dummyParentChangeId = changeOperations.newChange().create();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .mergeOf()
+            .change(parent1ChangeId)
+            .and()
+            .change(parent2ChangeId)
+            .create();
+
+    Map<String, FileInfo> changedFilesFirstPatchset =
+        gApi.changes().id(changeId.get()).current().files();
+
+    assertThat(changedFilesFirstPatchset.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST");
+
+    // Make a Code-Review vote that should be sticky.
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .parent()
+        .patchset(PatchSet.id(dummyParentChangeId, 1))
+        .create();
+
+    Map<String, FileInfo> changedFilesSecondPatchset =
+        gApi.changes().id(changeId.get()).current().files();
+
+    // Only "/MERGE_LIST" was removed.
+    assertThat(changedFilesSecondPatchset.keySet()).containsExactly("/COMMIT_MSG");
+    ApprovalInfo approvalInfo =
+        Iterables.getOnlyElement(
+            gApi.changes().id(changeId.get()).current().votes().get(LabelId.CODE_REVIEW));
+    assertThat(approvalInfo._accountId).isEqualTo(admin.id().get());
+    assertThat(approvalInfo.value).isEqualTo(2);
+  }
+
+  @Test
   public void deleteStickyVote() throws Exception {
     String label = LabelId.CODE_REVIEW;
     try (ProjectConfigUpdate u = updateProject(project)) {
@@ -629,10 +1004,10 @@
     }
 
     // Vote max score on PS1
-    String changeId = createChange(REWORK);
+    String changeId = changeKindCreator.createChange(REWORK, testRepo, admin);
     vote(admin, changeId, label, 2);
     assertVotes(detailedChange(changeId), admin, label, 2, null);
-    updateChange(changeId, REWORK);
+    changeKindCreator.updateChange(changeId, REWORK, testRepo, admin, project);
     assertVotes(detailedChange(changeId), admin, label, 2, REWORK);
 
     // Delete vote that was copied via sticky approval
@@ -667,6 +1042,229 @@
     assertThat(r.getChange().approvals().get(PatchSet.id(r.getChange().getId(), 2))).hasSize(1);
   }
 
+  @Test
+  public void stickyVoteStoredOnUpload() 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);
+    input.tag = "tag";
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make new patchsets, keeping the Code-Review +2 vote.
+    for (int i = 0; i < 9; i++) {
+      amendChange(r.getChangeId());
+    }
+
+    List<PatchSetApproval> patchSetApprovals =
+        r.getChange().notes().getApprovalsWithCopied().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+
+    for (int i = 0; i < 10; i++) {
+      int patchSet = i + 1;
+      assertThat(patchSetApprovals.get(i).patchSetId().get()).isEqualTo(patchSet);
+      assertThat(patchSetApprovals.get(i).accountId().get()).isEqualTo(admin.id().get());
+      assertThat(patchSetApprovals.get(i).realAccountId().get()).isEqualTo(admin.id().get());
+      assertThat(patchSetApprovals.get(i).label()).isEqualTo(LabelId.CODE_REVIEW);
+      assertThat(patchSetApprovals.get(i).value()).isEqualTo((short) 2);
+      assertThat(patchSetApprovals.get(i).tag().get()).isEqualTo("tag");
+      if (patchSet == 1) {
+        assertThat(patchSetApprovals.get(i).copied()).isFalse();
+      } else {
+        assertThat(patchSetApprovals.get(i).copied()).isTrue();
+      }
+    }
+  }
+
+  @Test
+  public void stickyVoteStoredOnRebase() 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();
+    }
+
+    // Create two changes both with the same parent
+    PushOneCommit.Result r = createChange();
+    testRepo.reset("HEAD~1");
+    PushOneCommit.Result r2 = createChange();
+
+    // Approve and submit the first change
+    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
+    revision.review(ReviewInput.approve().label(LabelId.VERIFIED, 1));
+    revision.submit();
+
+    // Add an approval whose score should be copied.
+    gApi.changes().id(r2.getChangeId()).current().review(ReviewInput.recommend());
+
+    // Rebase the second change
+    gApi.changes().id(r2.getChangeId()).rebase();
+
+    List<PatchSetApproval> patchSetApprovals =
+        r2.getChange().notes().getApprovalsWithCopied().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertCopied(nonCopied, 1, LabelId.CODE_REVIEW, (short) 1, /* copied= */ false);
+    assertCopied(copied, 2, LabelId.CODE_REVIEW, (short) 1, /* copied= */ true);
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccount() throws Exception {
+    // Give "user" permission to vote on behalf of other users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // 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 as user
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+    input.onBehalfOf = admin.email();
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+
+    List<PatchSetApproval> patchSetApprovals =
+        r.getChange().notes().getApprovalsWithCopied().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
+    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(nonCopied.value()).isEqualTo((short) 1);
+    assertThat(nonCopied.copied()).isFalse();
+
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertThat(copied.patchSetId().get()).isEqualTo(2);
+    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(copied.value()).isEqualTo((short) 1);
+    assertThat(copied.copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredOnUploadWithRealAccountAndTag() throws Exception {
+    // Give "user" permission to vote on behalf of other users.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allowLabel(TestLabels.codeReview().getName())
+                .impersonation(true)
+                .ref("refs/heads/*")
+                .group(REGISTERED_USERS)
+                .range(-1, 1))
+        .update();
+
+    // 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 as user
+    requestScopeOperations.setApiUser(user.id());
+    ReviewInput input = new ReviewInput().label(LabelId.CODE_REVIEW, 1);
+    input.onBehalfOf = admin.email();
+    input.tag = "tag";
+    gApi.changes().id(r.getChangeId()).current().review(input);
+
+    // Make a new patchset, keeping the Code-Review +1 vote.
+    amendChange(r.getChangeId());
+
+    List<PatchSetApproval> patchSetApprovals =
+        r.getChange().notes().getApprovalsWithCopied().values().stream()
+            .sorted(comparing(a -> a.patchSetId().get()))
+            .collect(toImmutableList());
+
+    PatchSetApproval nonCopied = patchSetApprovals.get(0);
+    assertThat(nonCopied.patchSetId().get()).isEqualTo(1);
+    assertThat(nonCopied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(nonCopied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(nonCopied.value()).isEqualTo((short) 1);
+    assertThat(nonCopied.tag().get()).isEqualTo("tag");
+    assertThat(nonCopied.copied()).isFalse();
+
+    PatchSetApproval copied = patchSetApprovals.get(1);
+    assertThat(copied.patchSetId().get()).isEqualTo(2);
+    assertThat(copied.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(copied.realAccountId().get()).isEqualTo(user.id().get());
+    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(copied.value()).isEqualTo((short) 1);
+    assertThat(nonCopied.tag().get()).isEqualTo("tag");
+    assertThat(copied.copied()).isTrue();
+  }
+
+  @Test
+  public void stickyVoteStoredCanBeRemoved() 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());
+    assertVotes(detailedChange(r.getChangeId()), admin, label, 2, null);
+
+    gApi.changes().id(r.getChangeId()).current().review(ReviewInput.noScore());
+
+    PatchSetApproval nonCopiedSecondPatchsetRemovedVote =
+        Iterables.getOnlyElement(
+            r.getChange()
+                .notes()
+                .getApprovalsWithCopied()
+                .get(r.getChange().change().currentPatchSetId()));
+
+    assertThat(nonCopiedSecondPatchsetRemovedVote.patchSetId().get()).isEqualTo(2);
+    assertThat(nonCopiedSecondPatchsetRemovedVote.accountId().get()).isEqualTo(admin.id().get());
+    assertThat(nonCopiedSecondPatchsetRemovedVote.label()).isEqualTo(LabelId.CODE_REVIEW);
+    // The vote got removed since the latest patch-set only has one vote and it's "0".
+    assertThat(nonCopiedSecondPatchsetRemovedVote.value()).isEqualTo((short) 0);
+    assertThat(nonCopiedSecondPatchsetRemovedVote.copied()).isFalse();
+  }
+
   private void assertChangeKindCacheContains(ObjectId prior, ObjectId next) {
     ChangeKind kind =
         changeKindCache.getIfPresent(ChangeKindCacheImpl.Key.create(prior, next, "recursive"));
@@ -687,209 +1285,17 @@
     for (ChangeKind changeKind : changeKinds) {
       testRepo.reset(projectOperations.project(project).getHead("master"));
 
-      String changeId = createChange(changeKind);
+      String changeId = changeKindCreator.createChange(changeKind, testRepo, admin);
       vote(admin, changeId, +2, 1);
       vote(user, changeId, -2, -1);
 
-      updateChange(changeId, changeKind);
+      changeKindCreator.updateChange(changeId, changeKind, testRepo, admin, project);
       ChangeInfo c = detailedChange(changeId);
       assertVotes(c, admin, 0, 0, changeKind);
       assertVotes(c, user, 0, 0, changeKind);
     }
   }
 
-  private String createChange(ChangeKind kind) throws Exception {
-    switch (kind) {
-      case NO_CODE_CHANGE:
-      case REWORK:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return createChange().getChangeId();
-      case MERGE_FIRST_PARENT_UPDATE:
-        return createChangeForMergeCommit();
-      default:
-        throw new IllegalStateException("unexpected change kind: " + kind);
-    }
-  }
-
-  private void updateChange(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case NO_CODE_CHANGE:
-        noCodeChange(changeId);
-        return;
-      case REWORK:
-        rework(changeId);
-        return;
-      case TRIVIAL_REBASE:
-        trivialRebase(changeId);
-        return;
-      case MERGE_FIRST_PARENT_UPDATE:
-        updateFirstParent(changeId);
-        return;
-      case NO_CHANGE:
-        noChange(changeId);
-        return;
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-  }
-
-  private void noCodeChange(String changeId) throws Exception {
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message("New subject " + System.nanoTime())
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CODE_CHANGE);
-  }
-
-  private void noChange(String changeId) throws Exception {
-    ChangeInfo change = gApi.changes().id(changeId).get();
-    String commitMessage = change.revisions.get(change.currentRevision).commit.message;
-
-    TestRepository<?>.CommitBuilder commitBuilder =
-        testRepo.amendRef("HEAD").insertChangeId(changeId.substring(1));
-    commitBuilder
-        .message(commitMessage)
-        .author(admin.newIdent())
-        .committer(new PersonIdent(admin.newIdent(), testRepo.getDate()));
-    commitBuilder.create();
-    GitUtil.pushHead(testRepo, "refs/for/master", false);
-    assertThat(getChangeKind(changeId)).isEqualTo(NO_CHANGE);
-  }
-
-  private void rework(String changeId) throws Exception {
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            PushOneCommit.SUBJECT,
-            PushOneCommit.FILE_NAME,
-            "new content " + System.nanoTime(),
-            changeId);
-    push.to("refs/for/master").assertOkStatus();
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private void trivialRebase(String changeId) throws Exception {
-    requestScopeOperations.setApiUser(admin.id());
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(),
-            testRepo,
-            "Other Change",
-            "a" + System.nanoTime() + ".txt",
-            PushOneCommit.FILE_CONTENT);
-    PushOneCommit.Result r = push.to("refs/for/master");
-    r.assertOkStatus();
-    RevisionApi revision = gApi.changes().id(r.getChangeId()).current();
-    ReviewInput in = new ReviewInput().label(LabelId.CODE_REVIEW, 2).label(LabelId.VERIFIED, 1);
-    revision.review(in);
-    revision.submit();
-
-    gApi.changes().id(changeId).current().rebase();
-    assertThat(getChangeKind(changeId)).isEqualTo(TRIVIAL_REBASE);
-  }
-
-  private String createChangeForMergeCommit() throws Exception {
-    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
-
-    PushOneCommit.Result parent1 = createChange("parent 1", "p1.txt", "content 1");
-
-    testRepo.reset(initial);
-    PushOneCommit.Result parent2 = createChange("parent 2", "p2.txt", "content 2");
-
-    testRepo.reset(parent1.getCommit());
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo);
-    merge.setParents(ImmutableList.of(parent1.getCommit(), parent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-    return result.getChangeId();
-  }
-
-  private void updateFirstParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent2 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent2));
-
-    testRepo.reset(parent1);
-    PushOneCommit.Result newParent1 = createChange("new parent 1", "p1-1.txt", "content 1-1");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(newParent1.getCommit(), commitParent2));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(MERGE_FIRST_PARENT_UPDATE);
-  }
-
-  private void updateSecondParent(String changeId) throws Exception {
-    ChangeInfo c = detailedChange(changeId);
-    List<CommitInfo> parents = c.revisions.get(c.currentRevision).commit.parents;
-    String parent1 = parents.get(0).commit;
-    String parent2 = parents.get(1).commit;
-    RevCommit commitParent1 = testRepo.getRevWalk().parseCommit(ObjectId.fromString(parent1));
-
-    testRepo.reset(parent2);
-    PushOneCommit.Result newParent2 = createChange("new parent 2", "p2-2.txt", "content 2-2");
-
-    PushOneCommit merge = pushFactory.create(admin.newIdent(), testRepo, changeId);
-    merge.setParents(ImmutableList.of(commitParent1, newParent2.getCommit()));
-    PushOneCommit.Result result = merge.to("refs/for/master");
-    result.assertOkStatus();
-
-    assertThat(getChangeKind(changeId)).isEqualTo(REWORK);
-  }
-
-  private String cherryPick(String changeId, ChangeKind changeKind) throws Exception {
-    switch (changeKind) {
-      case REWORK:
-      case TRIVIAL_REBASE:
-        break;
-      case NO_CODE_CHANGE:
-      case NO_CHANGE:
-      case MERGE_FIRST_PARENT_UPDATE:
-      default:
-        assertWithMessage("unexpected change kind: " + changeKind).fail();
-    }
-
-    testRepo.reset(projectOperations.project(project).getHead("master"));
-    PushOneCommit.Result r =
-        pushFactory
-            .create(
-                admin.newIdent(),
-                testRepo,
-                PushOneCommit.SUBJECT,
-                "other.txt",
-                "new content " + System.nanoTime())
-            .to("refs/for/master");
-    r.assertOkStatus();
-    vote(admin, r.getChangeId(), 2, 1);
-    merge(r);
-
-    String subject =
-        TRIVIAL_REBASE.equals(changeKind)
-            ? PushOneCommit.SUBJECT
-            : "Reworked change " + System.nanoTime();
-    CherryPickInput in = new CherryPickInput();
-    in.destination = "master";
-    in.message = String.format("%s\n\nChange-Id: %s", subject, changeId);
-    ChangeInfo c = gApi.changes().id(changeId).current().cherryPick(in).get();
-    return c.changeId;
-  }
-
-  private ChangeKind getChangeKind(String changeId) throws Exception {
-    ChangeInfo c = gApi.changes().id(changeId).get(CURRENT_REVISION);
-    return c.revisions.get(c.currentRevision).kind;
-  }
-
   private void vote(TestAccount user, String changeId, String label, int vote) throws Exception {
     requestScopeOperations.setApiUser(user.id());
     gApi.changes().id(changeId).current().review(new ReviewInput().label(label, vote));
@@ -938,4 +1344,12 @@
     }
     assertWithMessage(name).that(vote).isEqualTo(expectedVote);
   }
+
+  private void assertCopied(
+      PatchSetApproval approval, int psId, String label, short value, boolean copied) {
+    assertThat(approval.patchSetId().get()).isEqualTo(psId);
+    assertThat(approval.label()).isEqualTo(label);
+    assertThat(approval.value()).isEqualTo(value);
+    assertThat(approval.copied()).isEqualTo(copied);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
index bc9f50a5..636b71d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitRuleIT.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.inject.Inject;
 import java.util.List;
+import java.util.stream.Collectors;
 import org.junit.Test;
 
 public class SubmitRuleIT extends AbstractDaemonTest {
@@ -39,6 +40,11 @@
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
     List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    assertThat(
+            recordsBeforeSubmission.stream()
+                .map(record -> record.ruleName)
+                .collect(Collectors.toList()))
+        .containsExactly("gerrit~DefaultSubmitRule");
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
@@ -57,6 +63,11 @@
     PushOneCommit.Result r = createChange();
     approve(r.getChangeId());
     List<SubmitRecord> recordsBeforeSubmission = submitRuleEvaluator.evaluate(r.getChange());
+    assertThat(
+            recordsBeforeSubmission.stream()
+                .map(record -> record.ruleName)
+                .collect(Collectors.toList()))
+        .containsExactly("gerrit~DefaultSubmitRule");
     gApi.changes().id(r.getChangeId()).current().submit();
     // Add a new label that blocks submission if not granted. In case we reevaluate the rules,
     // this would show up as blocking submission.
diff --git a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
index 5ca7310..5124d11 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/SubmitWithStickyApprovalDiffIT.java
@@ -19,6 +19,7 @@
 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 static org.eclipse.jgit.lib.Constants.HEAD;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -31,13 +32,14 @@
 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.RebaseInput;
 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 java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -103,11 +105,195 @@
         /* 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"),
+        /* expectedFileDiff= */ "@@ -1,9 +1,8 @@\n"
+            + " content\n"
+            + " aa\n"
+            + "-sF\n"
+            + "+sS\n"
+            + " aa\n"
+            + " aaa\n"
+            + "-something\n"
+            + "+different\n"
+            + " foo\n"
+            + "-bla\n"
+            + "-deletedEnd\n"
+            + "+bla",
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_ignoreDiffFromRebaseAdditions()
+      throws Exception {
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("line1\nline2\nline3\nline4")
+            .create();
+
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("line012\nline1\nline2\nline3\nline4")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    testRepo.reset(initial);
+
+    // create 2 unrelated changes and rebase on top of them. Those rebases should be ignored.
+    // The changes add files.
+    Change.Id unrelated =
+        changeOperations.newChange().project(project).file("a").content("a").create();
+    gApi.changes().id(unrelated.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.get()).current().submit();
+    unrelated = changeOperations.newChange().project(project).file("z").content("z").create();
+    gApi.changes().id(unrelated.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.get()).current().submit();
+
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = gApi.changes().id(unrelated.get()).current().commit(true).commit;
+    gApi.changes().id(changeId.get()).current().rebase(rebaseInput);
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 1,
+        /* deletions= */ 0,
+        /* expectedFileDiff= */ "@@ -1,3 +1,4 @@\n"
+            + "+line012\n"
+            + " line1\n"
+            + " line2\n"
+            + " line3",
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_ignoreDiffFromRebaseRenames()
+      throws Exception {
+    Change.Id setup = changeOperations.newChange().project(project).file("a").content("a").create();
+    gApi.changes().id(setup.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(setup.get()).current().submit();
+
+    setup = changeOperations.newChange().project(project).file("z").content("z").create();
+    gApi.changes().id(setup.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(setup.get()).current().submit();
+
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("line1\nline2\nline3\nline4")
+            .create();
+
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("line012\nline1\nline2\nline3\nline4")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    testRepo.reset(initial);
+
+    // create 2 unrelated changes and rebase on top of them. Those rebases should be ignored.
+    // The changes rename files.
+    Change.Id unrelated =
+        changeOperations.newChange().project(project).file("a").renameTo("aa").create();
+    gApi.changes().id(unrelated.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.get()).current().submit();
+    unrelated = changeOperations.newChange().project(project).file("z").renameTo("zz").create();
+    gApi.changes().id(unrelated.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.get()).current().submit();
+
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = gApi.changes().id(unrelated.get()).current().commit(true).commit;
+    gApi.changes().id(changeId.get()).current().rebase(rebaseInput);
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 1,
+        /* deletions= */ 0,
+        /* expectedFileDiff= */ "@@ -1,3 +1,4 @@\n"
+            + "+line012\n"
+            + " line1\n"
+            + " line2\n"
+            + " line3",
+        /* oldFileName= */ null);
+  }
+
+  @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_ignoreDiffFromRebaseDeletions()
+      throws Exception {
+    Change.Id setup = changeOperations.newChange().project(project).file("a").content("a").create();
+    gApi.changes().id(setup.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(setup.get()).current().submit();
+
+    setup = changeOperations.newChange().project(project).file("z").content("z").create();
+    gApi.changes().id(setup.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(setup.get()).current().submit();
+
+    ObjectId initial = repo().exactRef(HEAD).getLeaf().getObjectId();
+    Change.Id changeId =
+        changeOperations
+            .newChange()
+            .project(project)
+            .file("file")
+            .content("line1\nline2\nline3\nline4")
+            .create();
+
+    gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("file")
+        .content("line012\nline1\nline2\nline3\nline4")
+        .create();
+
+    // add a reviewer to ensure an email is sent.
+    gApi.changes().id(changeId.get()).addReviewer(user.email());
+
+    testRepo.reset(initial);
+    // create 2 unrelated changes and rebase on top of them. Those rebases should be ignored.
+    // The changes delete files.
+    Change.Id unrelated = changeOperations.newChange().project(project).file("a").delete().create();
+    gApi.changes().id(unrelated.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.get()).current().submit();
+    unrelated = changeOperations.newChange().project(project).file("z").delete().create();
+    gApi.changes().id(unrelated.get()).current().review(ReviewInput.approve());
+    gApi.changes().id(unrelated.get()).current().submit();
+
+    RebaseInput rebaseInput = new RebaseInput();
+    rebaseInput.base = gApi.changes().id(unrelated.get()).current().commit(true).commit;
+    gApi.changes().id(changeId.get()).current().rebase(rebaseInput);
+
+    gApi.changes().id(changeId.get()).current().submit();
+
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        Iterables.getLast(gApi.changes().id(changeId.get()).messages()).message,
+        /* file= */ "file",
+        /* insertions= */ 1,
+        /* deletions= */ 0,
+        /* expectedFileDiff= */ "@@ -1,3 +1,4 @@\n"
+            + "+line012\n"
+            + " line1\n"
+            + " line2\n"
+            + " line3",
         /* oldFileName= */ null);
   }
 
@@ -119,7 +305,7 @@
             .newChange()
             .project(project)
             .file("file")
-            .content("content\naa\nbb\ncc" + "\ndd\nee\nff\nTODELETE1\nTODELETE2\ngg\nend")
+            .content("content\naa\nbb\ncc\ndd\nee\nff\nTODELETE1\nTODELETE2\ngg\nend")
             .create();
     gApi.changes().id(changeId.get()).current().review(ReviewInput.approve());
 
@@ -140,9 +326,21 @@
         /* 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"),
+        /* expectedFileDiff= */ "@@ -2,10 +2,12 @@\n"
+            + " aa\n"
+            + " bb\n"
+            + " cc\n"
+            + "+INSERTION\n"
+            + "+INSERTED\n"
+            + "+VERY\n"
+            + "+LONG\n"
+            + " dd\n"
+            + " ee\n"
+            + " ff\n"
+            + "-TODELETE1\n"
+            + "-TODELETE2\n"
+            + " gg\n"
+            + " end",
         /* oldFileName= */ null);
   }
 
@@ -191,9 +389,9 @@
     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.");
+                + "change was submitted with unreviewed changes in the following "
+                + "files:\n\n```\nThe name of the file: file\nInsertions: 1, Deletions: 1.\n\nThe"
+                + " diff is too large to show. Please review the diff.\n```\n");
   }
 
   @Test
@@ -218,13 +416,49 @@
         /* 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"),
+        /* expectedFileDiff= */ "@@ -0,0 +1,3 @@\n+content\n+more content\n+last content",
         /* oldFileName= */ null);
   }
 
   @Test
+  public void diffChangeMessageOnSubmitWithStickyVote_addedMultipleFiles() 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("content1\nmore content\nlast content")
+        .create();
+
+    changeOperations
+        .change(changeId)
+        .newPatchset()
+        .file("otherFile")
+        .content("content2\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,
+        /* file1= */ "otherFile",
+        /* insertions1= */ 3,
+        /* deletions1= */ 0,
+        /* expectedFileDiff1= */ "@@ -0,0 +1,3 @@\n+content2\n+more content\n+last content",
+        /* oldFileName1= */ null,
+        /* file2= */ "file",
+        /* insertions2= */ 3,
+        /* deletions2= */ 0,
+        /* expectedFileDiff2= */ "@@ -0,0 +1,3 @@\n+content1\n+more content\n+last content",
+        /* oldFileName2= */ null);
+  }
+
+  @Test
   public void diffChangeMessageOnSubmitWithStickyVote_removedFile() throws Exception {
     Change.Id changeId =
         changeOperations
@@ -247,9 +481,7 @@
         /* 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(),
+        /* expectedFileDiff= */ "@@ -1,3 +0,0 @@\n-content\n-more content\n-last content",
         /* oldFileName= */ null);
   }
 
@@ -276,9 +508,7 @@
         /* file= */ "new_file",
         /* insertions= */ 0,
         /* deletions= */ 0,
-        /* edits= */ ImmutableList.of(),
-        /* previousLines= */ ImmutableList.of(),
-        /* newLines= */ ImmutableList.of(),
+        /* expectedFileDiff= */ "",
         /* oldFileName= */ "file");
   }
 
@@ -345,55 +575,77 @@
       String file,
       int insertions,
       int deletions,
-      List<Edit> edits,
-      List<String> previousLines,
-      List<String> newLines,
+      String expectedFileDiff,
       String oldFileName) {
-    String expectedMessage =
+    assertDiffChangeMessageAndEmailWithStickyApproval(
+        message,
+        file,
+        insertions,
+        deletions,
+        expectedFileDiff,
+        oldFileName,
+        /* file2= */ null,
+        /* insertions2= */ 0,
+        /* deletions2 =
+         */ 0,
+        /* expectedFileDiff2= */ null,
+        /* oldFileName2= */ null);
+  }
+
+  private void assertDiffChangeMessageAndEmailWithStickyApproval(
+      String message,
+      String file1,
+      int insertions1,
+      int deletions1,
+      String expectedFileDiff1,
+      String oldFileName1,
+      String file2,
+      int insertions2,
+      int deletions2,
+      String expectedFileDiff2,
+      String oldFileName2) {
+    String beginningOfMessage =
         "1 is the latest approved patch-set.\n"
             + "The change was submitted with unreviewed changes in the following files:\n"
-            + "\n"
+            + "\n";
+    String fileDiff1 = fileDiff(expectedFileDiff1, oldFileName1, file1, insertions1, deletions1);
+    String expectedMessage1 = beginningOfMessage + fileDiff1;
+    String expectedMessage2 = "";
+    Set<String> expectedChangeMessages = new HashSet<>();
+    if (file2 != null) {
+      String fileDiff2 = fileDiff(expectedFileDiff2, oldFileName2, file2, insertions2, deletions2);
+      expectedMessage2 = beginningOfMessage + fileDiff2 + fileDiff1;
+      String expectedChangeMessage2 = "Change has been successfully merged\n\n" + expectedMessage2;
+      expectedMessage1 += fileDiff2;
+      expectedChangeMessages.add(expectedChangeMessage2.trim());
+    }
+    String expectedChangeMessage1 = "Change has been successfully merged\n\n" + expectedMessage1;
+    expectedChangeMessage1 = expectedChangeMessage1.trim();
+    expectedChangeMessages.add(expectedChangeMessage1.trim());
+
+    // The order of appearance in the diff for multiple files is not defined, so check both
+    // possible orders.
+    assertThat(expectedChangeMessages).contains(message.trim());
+    String email = Iterables.getLast(sender.getMessages()).body();
+    if (email.contains(expectedMessage1) || expectedMessage2.isEmpty()) {
+      assertThat(email).contains(expectedMessage1.trim());
+    } else {
+      assertThat(email).contains(expectedMessage2.trim());
+    }
+  }
+
+  private String fileDiff(
+      String expectedFileDiff, String oldFileName, String file, int insertions, int deletions) {
+    String expectedMessage =
+        "```\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);
+    expectedMessage += expectedFileDiff;
+    expectedMessage += "\n```\n";
+    return expectedMessage;
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java b/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java
index b6d2712..6ca3a37 100644
--- a/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/config/TopMenusIT.java
@@ -30,13 +30,13 @@
 
 @TestPlugin(
     name = "test-topmenus",
-    sysModule = "com.google.gerrit.acceptance.api.config.TopMenusIT$Module")
+    sysModule = "com.google.gerrit.acceptance.api.config.TopMenusIT$TestModule")
 public class TopMenusIT extends LightweightPluginDaemonTest {
 
   static final TopMenu.MenuEntry TEST_MENU_ENTRY =
       new TopMenu.MenuEntry("MyMenu", Collections.emptyList());
 
-  public static class Module extends AbstractModule {
+  public static class TestModule extends AbstractModule {
 
     @Override
     protected void configure() {
diff --git a/javatests/com/google/gerrit/acceptance/api/group/BUILD b/javatests/com/google/gerrit/acceptance/api/group/BUILD
index 8530a92..29f532e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/BUILD
+++ b/javatests/com/google/gerrit/acceptance/api/group/BUILD
@@ -22,7 +22,6 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/extensions:api",
-        "//java/com/google/gerrit/server",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
index 87bdee4..ece46c5 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupIndexerIT.java
@@ -28,8 +28,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.group.testing.InternalGroupSubject;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.query.group.InternalGroupQuery;
@@ -60,7 +60,7 @@
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
-        newGroupUpdate()
+        newGroupDelta()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
             .build());
 
@@ -77,7 +77,7 @@
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
-        newGroupUpdate()
+        newGroupDelta()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
             .build());
 
@@ -91,7 +91,7 @@
   public void indexingUpdatesStaleUuidCache() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
     loadGroupToCache(groupUuid);
-    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupDelta().setDescription("Modified").build());
 
     groupIndexer.index(groupUuid);
 
@@ -105,7 +105,7 @@
     AccountGroup.UUID subgroupUuid = AccountGroup.uuid("contributors");
     updateGroupWithoutCacheOrIndex(
         groupUuid,
-        newGroupUpdate()
+        newGroupDelta()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroupUuid))
             .build());
 
@@ -118,7 +118,7 @@
   @Test
   public void notStaleGroupIsNotReindexed() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
-    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupDelta().setDescription("Modified").build());
     groupIndexer.index(groupUuid);
 
     boolean reindexed = groupIndexer.reindexIfStale(groupUuid);
@@ -129,7 +129,7 @@
   @Test
   public void indexStalenessIsNotDerivedFromCacheStaleness() throws Exception {
     AccountGroup.UUID groupUuid = createGroup("verifiers");
-    updateGroupWithoutCacheOrIndex(groupUuid, newGroupUpdate().setDescription("Modified").build());
+    updateGroupWithoutCacheOrIndex(groupUuid, newGroupDelta().setDescription("Modified").build());
     reloadGroupToCache(groupUuid);
 
     boolean reindexed = groupIndexer.reindexIfStale(groupUuid);
@@ -151,14 +151,13 @@
     groupCache.get(groupUuid);
   }
 
-  private static InternalGroupUpdate.Builder newGroupUpdate() {
-    return InternalGroupUpdate.builder();
+  private static GroupDelta.Builder newGroupDelta() {
+    return GroupDelta.builder();
   }
 
-  private void updateGroupWithoutCacheOrIndex(
-      AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
+  private void updateGroupWithoutCacheOrIndex(AccountGroup.UUID groupUuid, GroupDelta groupDelta)
       throws NoSuchGroupException, IOException, ConfigInvalidException {
-    groupsUpdate.updateGroupInNoteDb(groupUuid, groupUpdate);
+    groupsUpdate.updateGroupInNoteDb(groupUuid, groupDelta);
   }
 
   private static OptionalSubject<InternalGroupSubject, InternalGroup> assertThatGroup(
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
index a277f26..4cbc36b 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsIT.java
@@ -72,7 +72,6 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo.GroupMemberAuditEventInfo;
-import com.google.gerrit.extensions.common.GroupAuditEventInfo.Type;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo.UserMemberAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
@@ -91,11 +90,11 @@
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.group.PeriodicGroupIndexer;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsConsistencyChecker;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupIndexer;
 import com.google.gerrit.server.index.group.StalenessChecker;
 import com.google.gerrit.server.notedb.Sequences;
@@ -201,10 +200,10 @@
   public void addRemoveMember() throws Exception {
     AccountGroup.UUID group = groupOperations.newGroup().create();
 
-    gApi.groups().id(group.get()).addMembers("user");
+    gApi.groups().id(group.get()).addMembers("user1");
     assertMembers(group.get(), user);
 
-    gApi.groups().id(group.get()).removeMembers("user");
+    gApi.groups().id(group.get()).removeMembers("user1");
     ImmutableSet<Account.Id> members = groupOperations.group(group).get().members();
     assertThat(members).isEmpty();
   }
@@ -408,7 +407,8 @@
 
     List<? extends GroupAuditEventInfo> auditEvents = gApi.groups().id(group.get()).auditLog();
     assertThat(auditEvents).hasSize(1);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), "Registered Users");
+    assertSubgroupAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_GROUP, admin.id(), "Registered Users");
   }
 
   @Test
@@ -1046,41 +1046,48 @@
     GroupApi g = gApi.groups().create(name("group"));
     List<? extends GroupAuditEventInfo> auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(1);
-    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id(), admin.id());
+    assertMemberAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_USER, admin.id(), admin.id());
 
     g.addMembers(user.username());
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(2);
-    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id(), user.id());
+    assertMemberAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_USER, admin.id(), user.id());
 
     g.removeMembers(user.username());
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(3);
-    assertMemberAuditEvent(auditEvents.get(0), Type.REMOVE_USER, admin.id(), user.id());
+    assertMemberAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.REMOVE_USER, admin.id(), user.id());
 
     String otherGroup = name("otherGroup");
     gApi.groups().create(otherGroup);
     g.addGroups(otherGroup);
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(4);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), otherGroup);
+    assertSubgroupAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_GROUP, admin.id(), otherGroup);
 
     g.removeGroups(otherGroup);
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(5);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.REMOVE_GROUP, admin.id(), otherGroup);
+    assertSubgroupAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.REMOVE_GROUP, admin.id(), otherGroup);
 
     // Add a removed member back again.
     g.addMembers(user.username());
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(6);
-    assertMemberAuditEvent(auditEvents.get(0), Type.ADD_USER, admin.id(), user.id());
+    assertMemberAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_USER, admin.id(), user.id());
 
     // Add a removed group back again.
     g.addGroups(otherGroup);
     auditEvents = g.auditLog();
     assertThat(auditEvents).hasSize(7);
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), otherGroup);
+    assertSubgroupAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_GROUP, admin.id(), otherGroup);
 
     Timestamp lastDate = null;
     for (GroupAuditEventInfo auditEvent : auditEvents) {
@@ -1112,7 +1119,8 @@
     List<? extends GroupAuditEventInfo> auditEvents = gApi.groups().id(parentGroup.id).auditLog();
     assertThat(auditEvents).hasSize(2);
     // Verify the unavailable subgroup's name is null.
-    assertSubgroupAuditEvent(auditEvents.get(0), Type.ADD_GROUP, admin.id(), null);
+    assertSubgroupAuditEvent(
+        auditEvents.get(0), GroupAuditEventInfo.Type.ADD_GROUP, admin.id(), null);
   }
 
   private void deleteGroupRef(String groupId) throws Exception {
@@ -1486,14 +1494,14 @@
               .setNameKey(AccountGroup.nameKey(groupName))
               .setId(AccountGroup.id(seq.nextGroupId()))
               .build(),
-          InternalGroupUpdate.builder().build());
+          GroupDelta.builder().build());
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
 
       // Update a group without updating the cache or index,
       // then run the reindexer -> only the updated group is reindexed.
       groupsUpdate.updateGroupInNoteDb(
-          groupUuid, InternalGroupUpdate.builder().setDescription("bar").build());
+          groupUuid, GroupDelta.builder().setDescription("bar").build());
       slaveGroupIndexer.run();
       groupIndexedCounter.assertReindexOf(groupUuid);
 
@@ -1617,7 +1625,7 @@
 
   private void assertMemberAuditEvent(
       GroupAuditEventInfo info,
-      Type expectedType,
+      GroupAuditEventInfo.Type expectedType,
       Account.Id expectedUser,
       Account.Id expectedMember) {
     assertThat(info.user._accountId).isEqualTo(expectedUser.get());
@@ -1628,7 +1636,7 @@
 
   private void assertSubgroupAuditEvent(
       GroupAuditEventInfo info,
-      Type expectedType,
+      GroupAuditEventInfo.Type expectedType,
       Account.Id expectedUser,
       String expectedMemberGroupName) {
     assertThat(info.user._accountId).isEqualTo(expectedUser.get());
diff --git a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
index c977d43..e57c82e 100644
--- a/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/group/GroupsUpdateIT.java
@@ -24,10 +24,10 @@
 import com.google.gerrit.exceptions.NoSuchGroupException;
 import com.google.gerrit.exceptions.StorageException;
 import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.Groups;
 import com.google.gerrit.server.group.db.GroupsUpdate;
 import com.google.gerrit.server.group.db.InternalGroupCreation;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.testing.InMemoryTestEnvironment;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -46,12 +46,12 @@
   @Test
   public void groupCreationIsRetriedWhenFailedDueToConcurrentNameModification() throws Exception {
     InternalGroupCreation groupCreation = getGroupCreation("users", "users-UUID");
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     Stream<String> allGroupNames = getAllGroupNames();
     assertThat(allGroupNames).containsAtLeast("users", "verifiers");
@@ -61,13 +61,13 @@
   public void groupRenameIsRetriedWhenFailedDueToConcurrentNameModification() throws Exception {
     createGroup("users", "users-UUID");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("contributors"))
             .setMemberModification(
                 new CreateAnotherGroupOnceAsSideEffectOfMemberModification("verifiers"))
             .build();
-    updateGroup(AccountGroup.uuid("users-UUID"), groupUpdate);
+    updateGroup(AccountGroup.uuid("users-UUID"), groupDelta);
 
     Stream<String> allGroupNames = getAllGroupNames();
     assertThat(allGroupNames).containsAtLeast("contributors", "verifiers");
@@ -75,28 +75,27 @@
 
   @Test
   public void groupUpdateFailsWithExceptionForNotExistingGroup() throws Exception {
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("A description for the group").build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setDescription("A description for the group").build();
     assertThrows(
         NoSuchGroupException.class,
-        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupUpdate));
+        () -> updateGroup(AccountGroup.uuid("nonexistent-group-UUID"), groupDelta));
   }
 
   private void createGroup(String groupName, String groupUuid) throws Exception {
     InternalGroupCreation groupCreation = getGroupCreation(groupName, groupUuid);
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
+    GroupDelta groupDelta = GroupDelta.builder().build();
 
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
   }
 
-  private void createGroup(InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate)
+  private void createGroup(InternalGroupCreation groupCreation, GroupDelta groupDelta)
       throws IOException, ConfigInvalidException {
-    groupsUpdateProvider.get().createGroup(groupCreation, groupUpdate);
+    groupsUpdateProvider.get().createGroup(groupCreation, groupDelta);
   }
 
-  private void updateGroup(AccountGroup.UUID groupUuid, InternalGroupUpdate groupUpdate)
-      throws Exception {
-    groupsUpdateProvider.get().updateGroup(groupUuid, groupUpdate);
+  private void updateGroup(AccountGroup.UUID groupUuid, GroupDelta groupDelta) throws Exception {
+    groupsUpdateProvider.get().updateGroup(groupUuid, groupDelta);
   }
 
   private Stream<String> getAllGroupNames() throws IOException, ConfigInvalidException {
@@ -112,7 +111,7 @@
   }
 
   private class CreateAnotherGroupOnceAsSideEffectOfMemberModification
-      implements InternalGroupUpdate.MemberModification {
+      implements GroupDelta.MemberModification {
 
     private boolean groupCreated = false;
     private String groupName;
@@ -133,9 +132,9 @@
 
     private void createGroup() {
       InternalGroupCreation groupCreation = getGroupCreation(groupName, groupName + "-UUID");
-      InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
+      GroupDelta groupDelta = GroupDelta.builder().build();
       try {
-        groupsUpdateProvider.get().createGroup(groupCreation, groupUpdate);
+        groupsUpdateProvider.get().createGroup(groupCreation, groupDelta);
       } catch (StorageException | IOException | ConfigInvalidException e) {
         throw new IllegalStateException(e);
       }
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
new file mode 100644
index 0000000..df5a094
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -0,0 +1,1102 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+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.server.schema.AclUtil.grant;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static com.google.gerrit.truth.ConfigSubject.assertThat;
+import static com.google.gerrit.truth.MapSubject.assertThatMap;
+import static java.util.Arrays.asList;
+import static org.junit.Assert.assertEquals;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.entities.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;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.projects.ProjectApi;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.common.WebLinkInfo;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.webui.FileHistoryWebLink;
+import com.google.gerrit.server.config.AllProjectsNameProvider;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.google.gerrit.server.schema.GrantRevertPermission;
+import com.google.inject.Inject;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.junit.Before;
+import org.junit.Test;
+
+public class AccessIT extends AbstractDaemonTest {
+
+  private static final String REFS_ALL = Constants.R_REFS + "*";
+  private static final String REFS_HEADS = Constants.R_HEADS + "*";
+  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;
+  @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private GrantRevertPermission grantRevertPermission;
+
+  private Project.NameKey newProjectName;
+
+  @Before
+  public void setUp() throws Exception {
+    newProjectName = projectOperations.newProject().create();
+  }
+
+  @Test
+  public void grantRevertPermission() throws Exception {
+    String ref = "refs/*";
+    String groupId = "global:Registered-Users";
+
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+    assertThat(info.local.containsKey(ref)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(ref);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
+    String refsHeads = "refs/heads/*";
+    String refsStar = "refs/*";
+    String groupId = "global:Registered-Users";
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    grantRevertPermission.execute(newProjectName);
+
+    ProjectAccessInfo info = pApi().access();
+
+    // Revert permission is removed on refs/heads/*.
+    assertThat(info.local.containsKey(refsHeads)).isTrue();
+    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
+
+    // new permission is added on refs/* with Registered-Users.
+    assertThat(info.local.containsKey(refsStar)).isTrue();
+    accessSectionInfo = info.local.get(refsStar);
+    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
+    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
+    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
+    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
+    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  }
+
+  @Test
+  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      projectConfig.upsertAccessSection(
+          AccessSection.HEADS,
+          heads -> {
+            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
+            grant(projectConfig, heads, Permission.REVERT, otherGroup);
+          });
+      md.getCommitBuilder().setAuthor(admin.newIdent());
+      md.getCommitBuilder().setCommitter(admin.newIdent());
+      md.setMessage("Add revert permission for all registered users\n");
+
+      projectConfig.commit(md);
+    }
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo expected = pApi().access();
+
+    grantRevertPermission.execute(newProjectName);
+    projectCache.evict(newProjectName);
+    ProjectAccessInfo actual = pApi().access();
+    // Permissions don't change
+    assertThat(actual.local).isEqualTo(expected.local);
+  }
+
+  @Test
+  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
+    grantRevertPermission.execute(newProjectName);
+    grantRevertPermission.execute(newProjectName);
+
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
+      ProjectConfig projectConfig = projectConfigFactory.read(md);
+      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
+
+      Permission permission = all.getPermission(Permission.REVERT);
+      assertThat(permission.getRules()).hasSize(1);
+    }
+  }
+
+  @Test
+  public void getDefaultInheritance() throws Exception {
+    String inheritedName = pApi().access().inheritsFrom.name;
+    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
+  }
+
+  private Registration newFileHistoryWebLink() {
+    FileHistoryWebLink weblink =
+        new FileHistoryWebLink() {
+          @Override
+          public WebLinkInfo getFileHistoryWebLink(
+              String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(weblink);
+  }
+
+  @Test
+  public void webLink() throws Exception {
+    try (Registration registration = newFileHistoryWebLink()) {
+      ProjectAccessInfo info = pApi().access();
+      assertThat(info.configWebLinks).hasSize(1);
+      assertThat(info.configWebLinks.get(0).url)
+          .isEqualTo("http://view/" + newProjectName + "/project.config");
+    }
+  }
+
+  @Test
+  public void webLinkNoRefsMetaConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName);
+        Registration registration = newFileHistoryWebLink()) {
+      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
+      u.setForceUpdate(true);
+      assertThat(u.delete()).isEqualTo(Result.FORCED);
+
+      // This should not crash.
+      pApi().access();
+    }
+  }
+
+  @Test
+  public void addAccessSection() throws Exception {
+    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+
+    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
+    eventRecorder.assertRefUpdatedEvents(
+        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
+  }
+
+  @Test
+  public void addAccessSectionForPluginPermission() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new PluginProjectPermissionDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Project Permission";
+                  }
+                },
+                "fooPermission")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(
+          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
+
+      accessInput.add.put(REFS_HEADS, accessSectionInfo);
+      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
+      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
+
+      assertThat(pApi().access().local).isEqualTo(accessInput.add);
+    }
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
+  }
+
+  @Test
+  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("label-Invalid Permission", push);
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
+  }
+
+  @Test
+  public void createAccessChangeNop() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+  }
+
+  @Test
+  public void createAccessChangeEmptyConfig() throws Exception {
+    try (Repository repo = repoManager.openRepository(newProjectName)) {
+      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
+      ru.setForceUpdate(true);
+      assertThat(ru.delete()).isEqualTo(Result.FORCED);
+
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSection = newAccessSectionInfo();
+      PermissionInfo read = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
+      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSection.permissions.put(Permission.READ, read);
+      accessInput.add.put(REFS_HEADS, accessSection);
+
+      ChangeInfo out = pApi().accessChange(accessInput);
+      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    }
+  }
+
+  @Test
+  public void createAccessChange() throws Exception {
+    projectOperations
+        .project(newProjectName)
+        .forUpdate()
+        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .update();
+    // User can see the branch
+    requestScopeOperations.setApiUser(user.id());
+    pApi().branch("refs/heads/master").get();
+
+    ProjectAccessInput accessInput = newProjectAccessInput();
+
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    // Deny read to registered users.
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    read.exclusive = true;
+    accessSection.permissions.put(Permission.READ, read);
+    accessInput.add.put(REFS_HEADS, accessSection);
+
+    requestScopeOperations.setApiUser(user.id());
+    ChangeInfo out = pApi().accessChange(accessInput);
+
+    assertThat(out.project).isEqualTo(newProjectName.get());
+    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
+    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
+    assertThat(out.submitted).isNull();
+
+    requestScopeOperations.setApiUser(admin.id());
+
+    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
+    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
+
+    ReviewInput reviewIn = new ReviewInput();
+    reviewIn.label("Code-Review", (short) 2);
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // check that the change took effect.
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
+
+    // Restore.
+    accessInput.add.clear();
+    accessInput.remove.put(REFS_HEADS, accessSection);
+    requestScopeOperations.setApiUser(user.id());
+
+    requestScopeOperations.setApiUser(admin.id());
+    out = pApi().accessChange(accessInput);
+
+    gApi.changes().id(out._number).current().review(reviewIn);
+    gApi.changes().id(out._number).current().submit();
+
+    // Now it works again.
+    requestScopeOperations.setApiUser(user.id());
+    pApi().branch("refs/heads/master").get();
+  }
+
+  @Test
+  public void removePermission() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    accessSectionToRemove.permissions.put(
+        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 + LabelId.CODE_REVIEW);
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRule() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission rule
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    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 + LabelId.CODE_REVIEW, codeReview);
+    ProjectAccessInput removal = newProjectAccessInput();
+    removal.remove.put(REFS_HEADS, accessSectionToRemove);
+    pApi().access(removal);
+
+    // Remove locally
+    accessInput
+        .add
+        .get(REFS_HEADS)
+        .permissions
+        .get(Permission.LABEL + LabelId.CODE_REVIEW)
+        .rules
+        .remove(SystemGroupBackend.REGISTERED_USERS.get());
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    accessInput.add.put(REFS_HEADS, accessSectionInfo);
+    pApi().access(accessInput);
+
+    // Remove specific permission rules
+    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
+    PermissionInfo codeReview = newPermissionInfo();
+    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 + 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 + LabelId.CODE_REVIEW);
+
+    // Check
+    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  }
+
+  @Test
+  public void getPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    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());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
+  }
+
+  @Test
+  public void setPermissionsWithDisallowedUser() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
+
+    // Disallow READ
+    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
+    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
+    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
+  }
+
+  @Test
+  public void permissionsGroupMap() throws Exception {
+    // Add initial permission set
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo read = newPermissionInfo();
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    accessInput.add.put(REFS_ALL, accessSection);
+    ProjectAccessInfo result = pApi().access(accessInput);
+    assertThatMap(result.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    // Check the name, which is what the UI cares about; exhaustive
+    // coverage of GroupInfo should be in groups REST API tests.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
+        .isEqualTo("Project Owners");
+    // Strip the ID, since it is in the key.
+    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
+
+    // Get call returns groups too.
+    ProjectAccessInfo loggedInResult = pApi().access();
+    assertThatMap(loggedInResult.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+
+    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
+    assertThat(owners.name).isEqualTo("Project Owners");
+    assertThat(owners.id).isNull();
+    assertThat(owners.members).isNull();
+    assertThat(owners.includes).isNull();
+
+    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
+    requestScopeOperations.setApiUserAnonymous();
+    ProjectAccessInfo anonResult = pApi().access();
+    assertThatMap(anonResult.groups)
+        .keys()
+        .containsExactly(
+            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
+  }
+
+  @Test
+  public void updateParentAsUser() throws Exception {
+    // Create child
+    String newParentProjectName = projectOperations.newProject().create().get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    requestScopeOperations.setApiUser(user.id());
+    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
+  }
+
+  @Test
+  public void updateParentAsAdministrator() throws Exception {
+    // Create parent
+    String newParentProjectName = projectOperations.newProject().create().get();
+
+    // Set new parent
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = newParentProjectName;
+
+    pApi().access(accessInput);
+
+    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
+  }
+
+  @Test
+  public void addGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void addGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedAccessSectionInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void addPluginGlobalCapability() throws Exception {
+    try (Registration registration =
+        extensionRegistry
+            .newRegistration()
+            .add(
+                new CapabilityDefinition() {
+                  @Override
+                  public String getDescription() {
+                    return "A Plugin Global Capability";
+                  }
+                },
+                "fooCapability")) {
+      ProjectAccessInput accessInput = newProjectAccessInput();
+      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+      PermissionInfo foo = newPermissionInfo();
+      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
+
+      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+      ProjectAccessInfo updatedAccessSectionInfo =
+          gApi.projects().name(allProjects.get()).access(accessInput);
+      assertThatMap(
+              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+          .keys()
+          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+    }
+  }
+
+  @Test
+  public void addPermissionAsGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put(Permission.PUSH, push);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
+  }
+
+  @Test
+  public void addInvalidGlobalCapability() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    BadRequestException ex =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allProjects.get()).access(accessInput));
+    assertThat(ex)
+        .hasMessageThat()
+        .isEqualTo("Unknown global capability: Invalid Global Capability");
+  }
+
+  @Test
+  public void addGlobalCapabilityForNonRootProject() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+  }
+
+  @Test
+  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
+
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+    assertThrows(
+        BadRequestException.class,
+        () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsUser() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
+
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    requestScopeOperations.setApiUser(user.id());
+    assertThrows(
+        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
+  }
+
+  @Test
+  public void removeGlobalCapabilityAsAdmin() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
+
+    PermissionInfo permissionInfo = newPermissionInfo();
+    permissionInfo.rules.put(adminGroupUuid().get(), null);
+    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
+
+    // Add and validate first as removing existing privileges such as
+    // administrateServer would break upcoming tests
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    ProjectAccessInfo updatedProjectAccessInfo =
+        gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
+
+    // Remove
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
+
+    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
+    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
+        .keys()
+        .containsNoneIn(accessSectionInfo.permissions.keySet());
+  }
+
+  @Test
+  public void unknownPermissionRemainsUnchanged() throws Exception {
+    String access = "access";
+    String unknownPermission = "unknownPermission";
+    String registeredUsers = "group Registered Users";
+    String refsFor = "refs/for/*";
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push unknown permission
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    // Verify that unknownPermission is present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
+
+    // Make permission change through API
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+    accessInput.add.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+    accessInput.add.clear();
+    accessInput.remove.put(refsFor, accessSectionInfo);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Verify that unknownPermission is still present
+    config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+    cfg.fromText(config);
+    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
+  }
+
+  @Test
+  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    accessInput.parent = project.get();
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(allUsers.get()).access(accessInput));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(allUsers.get() + " must inherit from " + allProjects.get());
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules).values().containsExactly(pri);
+
+    // Revoke the permission
+    accessInput.add.clear();
+    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
+  }
+
+  @Test
+  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
+      throws Exception {
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+
+    // Grant CREATE_GROUP to Registered Users
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+    PermissionInfo createGroup = newPermissionInfo();
+    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Grant CREATE_GROUP to Administrators
+    accessInput = newProjectAccessInput();
+    accessSection = newAccessSectionInfo();
+    createGroup = newPermissionInfo();
+    createGroup.rules.put(adminGroupUuid().get(), pri);
+    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
+    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
+    gApi.projects().name(allProjects.get()).access(accessInput);
+
+    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
+    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
+    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
+    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
+    // READ is the default permission and should be preserved by the syncer
+    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
+    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
+    assertThatMap(rules)
+        .keys()
+        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
+    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
+    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
+  }
+
+  @Test
+  public void addAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  @Test
+  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
+    ProjectAccessInput accessInput = newProjectAccessInput();
+    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+
+    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
+    String invalidRef = Constants.R_HEADS + "stable_*";
+    accessInput.add.put(invalidRef, accessSectionInfo);
+
+    BadRequestException thrown =
+        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
+    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
+  }
+
+  @Test
+  public void grantAllowAndDenyForSameGroup() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    String access = "access";
+    List<String> allowThenDeny =
+        asList(registeredUsers.toConfigValue(), "deny " + registeredUsers.toConfigValue());
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push allowThenDeny permissions
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setStringList(access, AccessSection.HEADS, Permission.READ, allowThenDeny);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    ProjectAccessInfo pai = gApi.projects().name(allProjects.get()).access();
+    Map<String, AccessSectionInfo> local = pai.local;
+    AccessSectionInfo heads = local.get(AccessSection.HEADS);
+    Map<String, PermissionInfo> permissions = heads.permissions;
+    PermissionInfo read = permissions.get(Permission.READ);
+    Map<String, PermissionRuleInfo> rules = read.rules;
+    assertEquals(
+        rules.get(registeredUsers.getUUID().get()),
+        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false));
+  }
+
+  @Test
+  public void grantDenyAndAllowForSameGroup() throws Exception {
+    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
+    String access = "access";
+    List<String> denyThenAllow =
+        asList("deny " + registeredUsers.toConfigValue(), registeredUsers.toConfigValue());
+    // Clone repository to forcefully add permission
+    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
+
+    // Fetch permission ref
+    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
+    allProjectsRepo.reset("cfg");
+
+    // Load current permissions
+    String config =
+        gApi.projects()
+            .name(allProjects.get())
+            .branch(RefNames.REFS_CONFIG)
+            .file(ProjectConfig.PROJECT_CONFIG)
+            .asString();
+
+    // Append and push denyThenAllow permissions
+    Config cfg = new Config();
+    cfg.fromText(config);
+    cfg.setStringList(access, AccessSection.HEADS, Permission.READ, denyThenAllow);
+    config = cfg.toText();
+    PushOneCommit push =
+        pushFactory.create(
+            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
+    push.to(RefNames.REFS_CONFIG).assertOkStatus();
+
+    ProjectAccessInfo pai = gApi.projects().name(allProjects.get()).access();
+    Map<String, AccessSectionInfo> local = pai.local;
+    AccessSectionInfo heads = local.get(AccessSection.HEADS);
+    Map<String, PermissionInfo> permissions = heads.permissions;
+    PermissionInfo read = permissions.get(Permission.READ);
+    Map<String, PermissionRuleInfo> rules = read.rules;
+    assertEquals(
+        rules.get(registeredUsers.getUUID().get()),
+        new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false));
+  }
+
+  private ProjectApi pApi() throws Exception {
+    return gApi.projects().name(newProjectName.get());
+  }
+
+  private ProjectAccessInput newProjectAccessInput() {
+    ProjectAccessInput p = new ProjectAccessInput();
+    p.add = new HashMap<>();
+    p.remove = new HashMap<>();
+    return p;
+  }
+
+  private PermissionInfo newPermissionInfo() {
+    PermissionInfo p = new PermissionInfo(null, null);
+    p.rules = new HashMap<>();
+    return p;
+  }
+
+  private AccessSectionInfo newAccessSectionInfo() {
+    AccessSectionInfo a = new AccessSectionInfo();
+    a.permissions = new HashMap<>();
+    return a;
+  }
+
+  private AccessSectionInfo createDefaultAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo push = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(Permission.PUSH, push);
+
+    PermissionInfo codeReview = newPermissionInfo();
+    codeReview.label = LabelId.CODE_REVIEW;
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+
+    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    pri.max = 1;
+    pri.min = -1;
+    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
+    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo email = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
+    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
+    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
+
+    return accessSection;
+  }
+
+  private AccessSectionInfo createAccessSectionInfoDenyAll() {
+    AccessSectionInfo accessSection = newAccessSectionInfo();
+
+    PermissionInfo read = newPermissionInfo();
+    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
+    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
+    accessSection.permissions.put(Permission.READ, read);
+
+    return accessSection;
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
index 45a895a..b0de1c1 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/CheckAccessIT.java
@@ -236,10 +236,10 @@
                 Permission.VIEW_PRIVATE_CHANGES,
                 403,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/*'",
-                    "'user' cannot perform 'viewPrivateChanges' with force=false on project '"
+                    "'user1' cannot perform 'viewPrivateChanges' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/master'")),
             // Test 2
@@ -248,7 +248,7 @@
                 normalProject.get(),
                 200,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + normalProject.get()
                         + "' for ref 'refs/heads/*'")),
             // Test 3
@@ -257,10 +257,10 @@
                 secretProject.get(),
                 403,
                 ImmutableList.of(
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' 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 '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretProject.get()
                         + "' for ref 'refs/meta/version' because this permission is blocked")),
             // Test 4
@@ -270,10 +270,10 @@
                 "refs/heads/secret/master",
                 403,
                 ImmutableList.of(
-                    "'user' can perform 'read' with force=false on project '"
+                    "'user1' can perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/*'",
-                    "'user' cannot perform 'read' with force=false on project '"
+                    "'user1' cannot perform 'read' with force=false on project '"
                         + secretRefProject.get()
                         + "' for ref 'refs/heads/secret/master' because this permission is blocked")),
             // Test 5
diff --git a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
index 0f51095..9c74c48 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/ProjectIT.java
@@ -16,20 +16,25 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.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.block;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.INHERITED_FROM_PARENT;
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_GLOBAL;
 import static com.google.gerrit.server.project.ProjectState.OVERRIDDEN_BY_PARENT;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
 import com.google.common.util.concurrent.AtomicLongMap;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -41,6 +46,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.BranchNameKey;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.GroupReference;
 import com.google.gerrit.entities.LabelId;
@@ -74,9 +80,13 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Optional;
+import java.util.Set;
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
@@ -92,6 +102,7 @@
   private static final String JIRA = "jira";
   private static final String JIRA_LINK = "http://jira.example.com/?id=$2";
   private static final String JIRA_MATCH = "(jira\\\\s+#?)(\\\\d+)";
+  private static final String R_HEADS_MASTER = R_HEADS + "master";
 
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -842,7 +853,7 @@
     String otherLink = "https://other.example.com";
     input = new ConfigInput();
     addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
-    info = setConfig(child, input);
+    setConfig(child, input);
 
     expected = new HashMap<>();
     expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
@@ -866,7 +877,7 @@
     String otherLink = "https://other.example.com";
     input = new ConfigInput();
     addCommentLink(input, BUGZILLA, BUGZILLA_MATCH, otherLink);
-    info = setConfig(project, input);
+    setConfig(project, input);
 
     expected = new HashMap<>();
     expected.put(BUGZILLA, commentLinkInfo(BUGZILLA, BUGZILLA_MATCH, otherLink));
@@ -888,7 +899,7 @@
 
     input = new ConfigInput();
     addCommentLink(input, BUGZILLA, null);
-    info = setConfig(project, input);
+    setConfig(project, input);
 
     expected = new HashMap<>();
     expected.put(JIRA, commentLinkInfo(JIRA, JIRA_MATCH, JIRA_LINK));
@@ -1000,6 +1011,156 @@
     assertThat(afterRename).hasValue(newName);
   }
 
+  @Test
+  public void commitsIncludedInRefsEmptyCommitAndEmptyRefs() throws Exception {
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> getCommitsIncludedInRefs(Collections.emptyList(), Arrays.asList(R_HEADS_MASTER)));
+    assertThat(thrown).hasMessageThat().contains("commit is required");
+    PushOneCommit.Result result = createChange();
+    thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> getCommitsIncludedInRefs(result.getCommit().getName(), Collections.emptyList()));
+    assertThat(thrown).hasMessageThat().contains("ref is required");
+  }
+
+  @Test
+  public void commitsIncludedInRefsNonExistentCommits() throws Exception {
+    assertThat(
+            getCommitsIncludedInRefs(
+                Arrays.asList("foo", "4fa12ab8f257034ec793dacb2ae2752ae2e9f5f3"),
+                Arrays.asList(R_HEADS_MASTER)))
+        .isEmpty();
+  }
+
+  @Test
+  public void commitsIncludedInRefsNonExistentRefs() throws Exception {
+    PushOneCommit.Result change = createChange();
+    assertThat(
+            getCommitsIncludedInRefs(
+                Arrays.asList(change.getCommit().getName()), Arrays.asList(R_HEADS + "foo")))
+        .isEmpty();
+  }
+
+  @Test
+  public void commitsIncludedInRefsOpenChange() throws Exception {
+    PushOneCommit.Result change1 = createChange();
+    testRepo.reset(change1.getCommit().getParent(0));
+    PushOneCommit.Result change2 = createChange();
+
+    Map<String, Set<String>> refsByCommit =
+        getCommitsIncludedInRefs(
+            Arrays.asList(change1.getCommit().getName(), change2.getCommit().getName()),
+            Arrays.asList(
+                R_HEADS_MASTER, change1.getPatchSet().refName(), change2.getPatchSet().refName()));
+    assertThat(refsByCommit.get(change2.getCommit().getName()))
+        .containsExactly(change2.getPatchSet().refName());
+    assertThat(refsByCommit.get(change1.getCommit().getName()))
+        .containsExactly(change1.getPatchSet().refName());
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChange() throws Exception {
+    String branchWithoutChanges = R_HEADS + "branch-without-changes";
+    String tagWithChange1 = R_TAGS + "tag-with-change1";
+    String branchWithChange1 = R_HEADS + "branch-with-change1";
+
+    createBranch(BranchNameKey.create(project, "branch-without-changes"));
+    PushOneCommit.Result change1 = createAndSubmitChange("refs/for/master");
+
+    assertThat(
+            getCommitsIncludedInRefs(change1.getCommit().getName(), Arrays.asList(R_HEADS_MASTER)))
+        .containsExactly(R_HEADS_MASTER);
+    assertThat(
+            getCommitsIncludedInRefs(
+                change1.getCommit().getName(), Arrays.asList(R_HEADS_MASTER, branchWithoutChanges)))
+        .containsExactly(R_HEADS_MASTER);
+
+    pushHead(testRepo, tagWithChange1, false, false);
+    createBranch(BranchNameKey.create(project, "branch-with-change1"));
+
+    PushOneCommit.Result change2 = createAndSubmitChange("refs/for/master");
+    Map<String, Set<String>> refsByCommit =
+        getCommitsIncludedInRefs(
+            Arrays.asList(change1.getCommit().getName(), change2.getCommit().getName()),
+            Arrays.asList(R_HEADS_MASTER, tagWithChange1, branchWithoutChanges, branchWithChange1));
+    assertThat(refsByCommit.get(change1.getCommit().getName()))
+        .containsExactly(R_HEADS_MASTER, tagWithChange1, branchWithChange1);
+    assertThat(refsByCommit.get(change2.getCommit().getName())).containsExactly(R_HEADS_MASTER);
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChangeNonTipCommit() throws Exception {
+    String branchWithChange1 = R_HEADS + "branch-with-change1";
+    String tagWithChange1 = R_TAGS + "tag-with-change1";
+    String branchWithChange1Change2 = R_HEADS + "branch-with-change1-change2";
+    String tagWithChange1Change2 = R_TAGS + "tag-with-change1-change2";
+
+    createBranch(BranchNameKey.create(project, "branch-with-change1"));
+    PushOneCommit.Result change1 = createAndSubmitChange("refs/for/branch-with-change1");
+    pushHead(testRepo, tagWithChange1, false, false);
+    createBranch(BranchNameKey.create(project, "branch-with-change1-change2"));
+    PushOneCommit.Result change2 = createAndSubmitChange("refs/for/branch-with-change1-change2");
+    pushHead(testRepo, tagWithChange1Change2, false, false);
+
+    Map<String, Set<String>> refsByCommit =
+        getCommitsIncludedInRefs(
+            Arrays.asList(change1.getCommit().getName(), change2.getCommit().getName()),
+            Arrays.asList(
+                branchWithChange1,
+                branchWithChange1Change2,
+                tagWithChange1,
+                tagWithChange1Change2));
+    assertThat(refsByCommit.get(change1.getCommit().getName()))
+        .containsExactly(
+            branchWithChange1, branchWithChange1Change2, tagWithChange1, tagWithChange1Change2);
+    assertThat(refsByCommit.get(change2.getCommit().getName()))
+        .containsExactly(branchWithChange1Change2, tagWithChange1Change2);
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChangeFilterNonVisibleBranchRef() throws Exception {
+    String nonVisibleBranch = R_HEADS + "non-visible-branch";
+    PushOneCommit.Result change = createAndSubmitChange("refs/for/master");
+    createBranch(BranchNameKey.create(project, "non-visible-branch"));
+    blockReadPermission(nonVisibleBranch);
+
+    assertThat(
+            getCommitsIncludedInRefs(
+                change.getCommit().getName(), Arrays.asList(R_HEADS_MASTER, nonVisibleBranch)))
+        .containsExactly(R_HEADS_MASTER);
+  }
+
+  @Test
+  public void commitsIncludedInRefsMergedChangeFilterNonVisibleTagRef() throws Exception {
+    String nonVisibleTag = R_TAGS + "non-visible-tag";
+    PushOneCommit.Result change = createAndSubmitChange("refs/for/master");
+    pushHead(testRepo, nonVisibleTag, false, false);
+    // Tag permissions are controlled by read permissions on branches. Blocking read permission
+    // on master so that tag-with-change becomes non-visible
+    blockReadPermission(R_HEADS_MASTER);
+
+    assertThat(
+            getCommitsIncludedInRefs(
+                change.getCommit().getName(), Arrays.asList(R_HEADS_MASTER, nonVisibleTag)))
+        .isNull();
+  }
+
+  @Test
+  public void commitsIncludedInRefsFilterNonVisibleChangeRef() throws Exception {
+    PushOneCommit.Result change = createChange("refs/for/master");
+    // change ref permissions are controlled by read permissions on destination branch.
+    // Blocking read permission on master so that refs/changes/01/1/1 becomes non-visible
+    blockReadPermission(R_HEADS_MASTER);
+
+    assertThat(
+            getCommitsIncludedInRefs(
+                change.getCommit().getName(), Arrays.asList(change.getPatchSet().refName())))
+        .isNull();
+  }
+
   private CommentLinkInfo commentLinkInfo(String name, String match, String link) {
     CommentLinkInfo info = new CommentLinkInfo();
     info.name = name;
@@ -1039,6 +1200,30 @@
     return getConfig(project);
   }
 
+  private Set<String> getCommitsIncludedInRefs(String commit, List<String> refs) throws Exception {
+    return getCommitsIncludedInRefs(Lists.newArrayList(commit), refs).get(commit);
+  }
+
+  private Map<String, Set<String>> getCommitsIncludedInRefs(List<String> commits, List<String> refs)
+      throws Exception {
+    return gApi.projects().name(project.get()).commitsIn(commits, refs);
+  }
+
+  private PushOneCommit.Result createAndSubmitChange(String branch) throws Exception {
+    PushOneCommit.Result r = createChange(branch);
+    approve(r.getChangeId());
+    gApi.changes().id(r.getChangeId()).current().submit();
+    return r;
+  }
+
+  private void blockReadPermission(String ref) {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref(ref).group(REGISTERED_USERS))
+        .update();
+  }
+
   private ConfigInput createTestConfigInput() {
     ConfigInput input = new ConfigInput();
     input.description = "some description";
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
index 021a719..e645ecf 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionDiffIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
 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;
@@ -35,7 +36,10 @@
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.entities.Patch;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -45,7 +49,10 @@
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.webui.EditWebLink;
 import com.google.gerrit.extensions.webui.FileWebLink;
+import com.google.gerrit.server.patch.DiffOperations;
+import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.inject.Inject;
 import java.awt.image.BufferedImage;
 import java.io.ByteArrayOutputStream;
@@ -57,6 +64,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.function.Function;
+import java.util.stream.Collectors;
 import java.util.stream.IntStream;
 import javax.imageio.ImageIO;
 import org.eclipse.jgit.lib.ObjectId;
@@ -81,11 +89,12 @@
   private static final String FILE_CONTENT2 = "1st line\n2nd line\n3rd line\n";
 
   @Inject private ExtensionRegistry extensionRegistry;
+  @Inject private DiffOperations diffOperations;
+  @Inject private ProjectOperations projectOperations;
 
   private boolean intraline;
-  private boolean useNewDiffCacheListFiles;
-  private boolean useNewDiffCacheGetDiff;
 
+  private ObjectId initialCommit;
   private ObjectId commit1;
   private String changeId;
   private String initialPatchSetId;
@@ -94,16 +103,14 @@
   public void setUp() throws Exception {
     // Reduce flakiness of tests. (If tests aren't fast enough, we would use a fall-back
     // computation, which might yield different results.)
-    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+    baseConfig.setString("cache", "git_file_diff", "timeout", "1 minute");
     baseConfig.setString("cache", "diff_intraline", "timeout", "1 minute");
 
     intraline = baseConfig.getBoolean(TEST_PARAMETER_MARKER, "intraline", false);
-    useNewDiffCacheListFiles =
-        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_ListFiles", false);
-    useNewDiffCacheGetDiff =
-        baseConfig.getBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", false);
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
+    initialCommit = headCommit;
+
     commit1 =
         addCommit(headCommit, ImmutableMap.of(FILE_NAME, FILE_CONTENT, FILE_NAME2, FILE_CONTENT2));
 
@@ -124,10 +131,34 @@
     assertDiffForNewFile(result, COMMIT_MSG, result.getCommit().getFullMessage());
   }
 
-  @Ignore
   @Test
   public void diffWithRootCommit() throws Exception {
-    // TODO(ghareeb): Implement this test
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.PUSH).ref("refs/*").group(adminGroupUuid()).force(true))
+        .update();
+
+    testRepo.reset(initialCommit);
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "subject", ImmutableMap.of("f.txt", "content"))
+            .noParent();
+    push.setForce(true);
+    PushOneCommit.Result result = push.to("refs/heads/master");
+
+    Map<String, FileDiffOutput> modifiedFiles =
+        diffOperations.listModifiedFilesAgainstParent(
+            project, result.getCommit(), /* parentNum= */ 0);
+
+    assertThat(modifiedFiles.keySet()).containsExactly("/COMMIT_MSG", "f.txt");
+    assertThat(
+            modifiedFiles.values().stream()
+                .map(FileDiffOutput::oldCommitId)
+                .collect(Collectors.toSet()))
+        .containsExactly(ObjectId.zeroId());
+    assertThat(modifiedFiles.get("/COMMIT_MSG").changeType()).isEqualTo(Patch.ChangeType.ADDED);
+    assertThat(modifiedFiles.get("f.txt").changeType()).isEqualTo(Patch.ChangeType.ADDED);
   }
 
   @Test
@@ -149,6 +180,23 @@
   }
 
   @Test
+  public void editWebLinkIncludedInDiff() throws Exception {
+    try (Registration registration = newEditWebLink()) {
+      String fileName = "a_new_file.txt";
+      String fileContent = "First line\nSecond line\n";
+      PushOneCommit.Result result = createChange("Add a file", fileName, fileContent);
+      DiffInfo info =
+          gApi.changes()
+              .id(result.getChangeId())
+              .revision(result.getCommit().name())
+              .file(fileName)
+              .diff();
+      assertThat(info.editWebLinks).hasSize(1);
+      assertThat(info.editWebLinks.get(0).url).isEqualTo("http://edit/" + project + "/" + fileName);
+    }
+  }
+
+  @Test
   public void gitwebFileWebLinkIncludedInDiff() throws Exception {
     try (Registration registration = newGitwebFileWebLink()) {
       String fileName = "foo.txt";
@@ -984,9 +1032,9 @@
 
   @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.
+    // In some corner cases, intra-line diffs produce wrong results. In this case, the algorithm
+    // falls back to a single edit covering the whole range.
+    // See: bugs.chromium.org/p/gerrit/issues/detail?id=13563
 
     assume().that(intraline).isTrue();
 
@@ -997,10 +1045,10 @@
     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:
+    // Intra-line logic wrongly produces
     // replace [-9999{,99}99] with [-999{,}999].
-    // If this replace edit is done, the resulting string incorrectly becomes [-9999,99].
+    // which if done, results in an incorrect [-9999,99].
+    // the intra-line algorithm detects this case and falls back to a single region edit.
 
     DiffInfo diffInfo =
         getDiffRequest(changeId, CURRENT, FILE_NAME).withBase(previousPatchSetId).get();
@@ -1009,8 +1057,7 @@
     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);
+    assertThat(reconstructed).isEqualTo(replace);
   }
 
   @Test
@@ -1291,7 +1338,7 @@
   }
 
   @Test
-  public void addedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+  public void addedUnrelatedFileIsIgnored_forPatchSetDiffWithRebase() throws Exception {
     ObjectId commit2 = addCommit(commit1, "file_added_in_another_commit.txt", "Some file content");
 
     rebaseChangeOn(changeId, commit2);
@@ -1303,7 +1350,7 @@
   }
 
   @Test
-  public void removedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+  public void removedUnrelatedFileIsIgnored_forPatchSetDiffWithRebase() throws Exception {
     ObjectId commit2 = addCommitRemovingFiles(commit1, FILE_NAME2);
 
     rebaseChangeOn(changeId, commit2);
@@ -1315,7 +1362,7 @@
   }
 
   @Test
-  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase() throws Exception {
+  public void renamedUnrelatedFileIsIgnored_forPatchSetDiffWithRebase() throws Exception {
     ObjectId commit2 = addCommitRenamingFile(commit1, FILE_NAME2, "a_new_file_name.txt");
 
     rebaseChangeOn(changeId, commit2);
@@ -1327,10 +1374,10 @@
   }
 
   @Test
-  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenEquallyModifiedInBoth()
+  @Ignore
+  public void renamedUnrelatedFileIsIgnored_forPatchSetDiffWithRebase_whenEquallyModifiedInBoth()
       throws Exception {
     // TODO(ghareeb): fix this test for the new diff cache implementation
-    assume().that(useNewDiffCacheListFiles).isFalse();
 
     Function<String, String> contentModification =
         fileContent -> fileContent.replace("1st line\n", "First line\n");
@@ -1353,7 +1400,7 @@
   }
 
   @Test
-  public void renamedUnrelatedFileIsIgnored_ForPatchSetDiffWithRebase_WhenModifiedDuringRebase()
+  public void renamedUnrelatedFileIsIgnored_forPatchSetDiffWithRebase_whenModifiedDuringRebase()
       throws Exception {
     String renamedFilePath = "renamed_some_file.txt";
     ObjectId commit2 =
@@ -1421,9 +1468,9 @@
   }
 
   @Test
+  @Ignore
   public void filesTouchedByPatchSetsAndContainingOnlyRebaseHunksAreIgnored() throws Exception {
     // TODO(ghareeb): fix this test for the new diff cache implementation
-    assume().that(useNewDiffCacheListFiles).isFalse();
 
     addModifiedPatchSet(
         changeId, FILE_NAME, fileContent -> fileContent.replace("Line 50\n", "Line fifty\n"));
@@ -2145,7 +2192,7 @@
   }
 
   @Test
-  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedDuringRebase() throws Exception {
+  public void rebaseHunkInRenamedFileIsIdentified_whenFileIsRenamedDuringRebase() throws Exception {
     String renamedFilePath = "renamed_some_file.txt";
     ObjectId commit2 =
         addCommit(commit1, FILE_NAME, FILE_CONTENT.replace("Line 1\n", "Line one\n"));
@@ -2174,7 +2221,7 @@
   }
 
   @Test
-  public void rebaseHunkInRenamedFileIsIdentified_WhenFileIsRenamedInPatchSets() throws Exception {
+  public void rebaseHunkInRenamedFileIsIdentified_whenFileIsRenamedInPatchSets() throws Exception {
     String renamedFilePath = "renamed_some_file.txt";
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, renamedFilePath);
     gApi.changes().id(changeId).edit().publish();
@@ -2213,7 +2260,7 @@
   }
 
   @Test
-  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedBetweenPatchSets()
+  public void renamedFileWithOnlyRebaseHunksIsIdentified_whenRenamedBetweenPatchSets()
       throws Exception {
     String newFilePath1 = "renamed_some_file.txt";
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
@@ -2248,7 +2295,7 @@
   }
 
   @Test
-  public void renamedFileWithOnlyRebaseHunksIsIdentified_WhenRenamedForRebaseAndForPatchSets()
+  public void renamedFileWithOnlyRebaseHunksIsIdentified_whenRenamedForRebaseAndForPatchSets()
       throws Exception {
     String newFilePath1 = "renamed_some_file.txt";
     gApi.changes().id(changeId).edit().renameFile(FILE_NAME, newFilePath1);
@@ -2803,11 +2850,7 @@
   }
 
   @Test
-  public void symlinkConvertedToRegularFileIsIdentifiedAsAdded() throws Exception {
-    // TODO(ghareeb): fix this test for the new diff cache implementation
-    assume().that(useNewDiffCacheListFiles).isFalse();
-    assume().that(useNewDiffCacheGetDiff).isFalse();
-
+  public void addDeleteByJgit_isIdentifiedAsRewritten() throws Exception {
     String target = "file.txt";
     String symlink = "link.lnk";
 
@@ -2816,42 +2859,70 @@
         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;
+    String cId = result.getChangeId();
 
-    // Delete the symlink with patchset 2
-    gApi.changes().id(result.getChangeId()).edit().deleteFile(symlink);
-    gApi.changes().id(result.getChangeId()).edit().publish();
+    // Delete the symlink with PS2
+    gApi.changes().id(cId).edit().deleteFile(symlink);
+    gApi.changes().id(cId).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();
+    // Re-add the symlink as a regular file with PS3
+    gApi.changes().id(cId).edit().modifyFile(symlink, RawInputUtil.create("new content"));
+    gApi.changes().id(cId).edit().publish();
 
-    Map<String, FileInfo> changedFiles =
-        gApi.changes().id(result.getChangeId()).current().files(initialRev);
-
+    // Changed files: JGit returns two {DELETED/ADDED} entries for the file.
+    // The diff logic combines both into a single REWRITTEN entry.
+    Map<String, FileInfo> changedFiles = gApi.changes().id(cId).current().files(initialRev);
     assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
-    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewrite
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewritten
 
-    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);
+    // Detailed diff: Old diff cache returns ADDED entry. New Diff Cache returns REWRITE.
+    DiffInfo diffInfo = gApi.changes().id(cId).current().file(symlink).diff(initialRev);
     assertThat(diffInfo.content).hasSize(1);
+    assertThat(diffInfo).content().element(0).linesOfB().containsExactly("new content");
+    assertThat(diffInfo.changeType).isEqualTo(ChangeType.REWRITE);
+  }
+
+  @Test
+  public void renameDeleteByJgit_isIdentifiedAsRewritten() throws Exception {
+    String target = "file.txt";
+    String symlink = "link.lnk";
+    PushOneCommit push =
+        pushFactory
+            .create(admin.newIdent(), testRepo, "Commit Subject", target, "content")
+            .addSymlink(symlink, target);
+    PushOneCommit.Result result = push.to("refs/for/master");
+    String cId = result.getChangeId();
+    String initialRev = gApi.changes().id(cId).get().currentRevision;
+
+    // Delete both symlink and target with PS2
+    gApi.changes().id(cId).edit().deleteFile(symlink);
+    gApi.changes().id(cId).edit().deleteFile(target);
+    gApi.changes().id(cId).edit().publish();
+
+    // Re-create the symlink as a regular file with PS3
+    gApi.changes().id(cId).edit().modifyFile(symlink, RawInputUtil.create("content"));
+    gApi.changes().id(cId).edit().publish();
+
+    // Changed files: JGit returns two {DELETED/RENAMED} entries for the file.
+    // The diff logic combines both into a single REWRITTEN entry.
+    Map<String, FileInfo> changedFiles = gApi.changes().id(cId).current().files(initialRev);
+    assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", symlink);
+    assertThat(changedFiles.get(symlink).status).isEqualTo('W'); // Rewritten
+
+    // Detailed diff: Old diff cache returns RENAMED entry. New Diff Cache returns REWRITE.
+    DiffInfo diffInfo = gApi.changes().id(cId).current().file(symlink).diff(initialRev);
     assertThat(diffInfo)
-        .content()
-        .element(0)
-        .linesOfB()
-        .containsExactly("Content of the new file named 'symlink'");
+        .diffHeader()
+        .containsExactly(
+            "diff --git a/file.txt b/link.lnk",
+            "similarity index 100%",
+            "rename from file.txt",
+            "rename to link.lnk");
+    assertThat(diffInfo.content).hasSize(1);
+    assertThat(diffInfo).content().element(0).commonLines().containsExactly("content");
+    assertThat(diffInfo.changeType).isEqualTo(ChangeType.REWRITE);
   }
 
   @Test
@@ -2902,6 +2973,18 @@
     assertThat(e).hasMessageThat().isEqualTo("edit not allowed as base");
   }
 
+  private Registration newEditWebLink() {
+    EditWebLink webLink =
+        new EditWebLink() {
+          @Override
+          public WebLinkInfo getEditWebLink(String projectName, String revision, String fileName) {
+            return new WebLinkInfo(
+                "name", "imageURL", "http://edit/" + projectName + "/" + fileName);
+          }
+        };
+    return extensionRegistry.newRegistration().add(webLink);
+  }
+
   private Registration newGitwebFileWebLink() {
     FileWebLink fileWebLink =
         new FileWebLink() {
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index abfd7896..d3fe83f 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -47,6 +47,7 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.TestProjectInput;
+import com.google.gerrit.acceptance.UseClockStep;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -93,7 +94,9 @@
 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.extensions.webui.ResolveConflictsWebLink;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.inject.Inject;
 import java.io.ByteArrayOutputStream;
@@ -152,7 +155,8 @@
     PushOneCommit.Result r = createChange();
     String changeId = project.get() + "~master~" + r.getChangeId();
     gApi.changes().id(changeId).current().review(ReviewInput.approve());
-    gApi.changes().id(changeId).current().submit();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).current().submit();
+    assertThat(changeInfo.status).isEqualTo(ChangeStatus.MERGED);
     assertThat(gApi.changes().id(changeId).get().status).isEqualTo(ChangeStatus.MERGED);
   }
 
@@ -626,7 +630,7 @@
         assertThrows(
             ResourceConflictException.class,
             () -> changeApi.revision(r.getCommit().name()).cherryPickAsInfo(in));
-    assertThat(thrown).hasMessageThat().isEqualTo("Cherry pick failed: merge conflict");
+    assertThat(thrown).hasMessageThat().startsWith("Cherry pick failed: merge conflict");
 
     // Cherry-pick with auto merge should succeed.
     in.allowConflicts = true;
@@ -1196,6 +1200,29 @@
   }
 
   @Test
+  @UseClockStep
+  public void cherryPickSetsReadyChangeOnNewPatchset() throws Exception {
+    PushOneCommit.Result result = pushTo("refs/for/master");
+    CherryPickInput input = new CherryPickInput();
+    input.destination = "foo";
+    gApi.projects().name(project.get()).branch(input.destination).create(new BranchInput());
+    ChangeApi originalChange = gApi.changes().id(project.get() + "~master~" + result.getChangeId());
+
+    ChangeApi cherryPick = originalChange.revision(result.getCommit().name()).cherryPick(input);
+    String firstCherryPickChangeId = cherryPick.id();
+    cherryPick.setWorkInProgress();
+    gApi.changes()
+        .id(project.get() + "~master~" + result.getChangeId())
+        .revision(result.getCommit().name())
+        .cherryPick(input);
+
+    ChangeInfo secondCherryPickResult =
+        gApi.changes().id(firstCherryPickChangeId).get(ALL_REVISIONS);
+    assertThat(secondCherryPickResult.revisions).hasSize(2);
+    assertThat(secondCherryPickResult.workInProgress).isNull();
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -1396,22 +1423,12 @@
     BadRequestException thrown =
         assertThrows(
             BadRequestException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChangeId())
-                    .revision(r.getCommit().name())
-                    .files(3)
-                    .keySet());
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(3));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 3");
     thrown =
         assertThrows(
             BadRequestException.class,
-            () ->
-                gApi.changes()
-                    .id(r.getChangeId())
-                    .revision(r.getCommit().name())
-                    .files(-1)
-                    .keySet());
+            () -> gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).files(-1));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
   }
 
@@ -1424,14 +1441,13 @@
 
     BadRequestException thrown =
         assertThrows(
-            BadRequestException.class,
-            () -> gApi.changes().id(changeId).revision(revId2).files(2).keySet());
+            BadRequestException.class, () -> gApi.changes().id(changeId).revision(revId2).files(2));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: 2");
 
     thrown =
         assertThrows(
             BadRequestException.class,
-            () -> gApi.changes().id(changeId).revision(revId2).files(-1).keySet());
+            () -> gApi.changes().id(changeId).revision(revId2).files(-1));
     assertThat(thrown).hasMessageThat().isEqualTo("invalid parent number: -1");
   }
 
@@ -1614,16 +1630,26 @@
 
   @Test
   public void commit() throws Exception {
-    WebLinkInfo expectedWebLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
-    PatchSetWebLink link =
+    WebLinkInfo expectedPatchSetLinkInfo = new WebLinkInfo("foo", "imageUrl", "url");
+    PatchSetWebLink patchSetLink =
         new PatchSetWebLink() {
           @Override
           public WebLinkInfo getPatchSetWebLink(
               String projectName, String commit, String commitMessage, String branchName) {
-            return expectedWebLinkInfo;
+            return expectedPatchSetLinkInfo;
           }
         };
-    try (Registration registration = extensionRegistry.newRegistration().add(link)) {
+    WebLinkInfo expectedResolveConflictsLinkInfo = new WebLinkInfo("bar", "img", "resolve");
+    ResolveConflictsWebLink resolveConflictsLink =
+        new ResolveConflictsWebLink() {
+          @Override
+          public WebLinkInfo getResolveConflictsWebLink(
+              String projectName, String commit, String commitMessage, String branchName) {
+            return expectedResolveConflictsLinkInfo;
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(patchSetLink).add(resolveConflictsLink)) {
       PushOneCommit.Result r = createChange();
       RevCommit c = r.getCommit();
 
@@ -1640,11 +1666,20 @@
 
       commitInfo = gApi.changes().id(r.getChangeId()).current().commit(true);
       assertThat(commitInfo.webLinks).hasSize(1);
-      WebLinkInfo webLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
-      assertThat(webLinkInfo.name).isEqualTo(expectedWebLinkInfo.name);
-      assertThat(webLinkInfo.imageUrl).isEqualTo(expectedWebLinkInfo.imageUrl);
-      assertThat(webLinkInfo.url).isEqualTo(expectedWebLinkInfo.url);
-      assertThat(webLinkInfo.target).isEqualTo(expectedWebLinkInfo.target);
+      WebLinkInfo patchSetLinkInfo = Iterables.getOnlyElement(commitInfo.webLinks);
+      assertThat(patchSetLinkInfo.name).isEqualTo(expectedPatchSetLinkInfo.name);
+      assertThat(patchSetLinkInfo.imageUrl).isEqualTo(expectedPatchSetLinkInfo.imageUrl);
+      assertThat(patchSetLinkInfo.url).isEqualTo(expectedPatchSetLinkInfo.url);
+      assertThat(patchSetLinkInfo.target).isEqualTo(expectedPatchSetLinkInfo.target);
+
+      assertThat(commitInfo.resolveConflictsWebLinks).hasSize(1);
+      WebLinkInfo resolveCommentsLinkInfo =
+          Iterables.getOnlyElement(commitInfo.resolveConflictsWebLinks);
+      assertThat(resolveCommentsLinkInfo.name).isEqualTo(expectedResolveConflictsLinkInfo.name);
+      assertThat(resolveCommentsLinkInfo.imageUrl)
+          .isEqualTo(expectedResolveConflictsLinkInfo.imageUrl);
+      assertThat(resolveCommentsLinkInfo.url).isEqualTo(expectedResolveConflictsLinkInfo.url);
+      assertThat(resolveCommentsLinkInfo.target).isEqualTo(expectedResolveConflictsLinkInfo.target);
     }
   }
 
@@ -1876,7 +1911,11 @@
     ChangeInfo c = gApi.changes().id(r.getChangeId()).get();
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            String.format(
+                "Removed Code-Review+1 by %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id())));
     assertThat(getReviewers(c.reviewers.get(ReviewerState.REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
deleted file mode 100644
index 46954e98..0000000
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheForSingleFileIT.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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, enabled for the single file "Get
- * Diff" endpoint. This is temporary until the new diff cache is fully deployed. The new diff cache
- * will become the default in the future.
- */
-public class RevisionNewDiffCacheForSingleFileIT extends RevisionDiffIT {
-  @ConfigSuite.Default
-  public static Config newDiffCacheConfig() {
-    Config config = new Config();
-    config.setBoolean("cache", "diff_cache", "runNewDiffCache_GetDiff", true);
-    return config;
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
deleted file mode 100644
index ec0bcc6..0000000
--- a/javatests/com/google/gerrit/acceptance/api/revision/RevisionNewDiffCacheIT.java
+++ /dev/null
@@ -1,32 +0,0 @@
-// 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} with the list files endpoint using 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", "runNewDiffCache_ListFiles", true);
-    return config;
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
index 85a7b29..875ce97 100644
--- a/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -156,15 +156,15 @@
   @UseClockStep
   @Test
   public void addedRobotCommentsAreLinkedToChangeMessages() throws Exception {
-    TestTimeUtil.resetWithClockStep(0, TimeUnit.SECONDS);
-    createChange();
-    /* Advancing the time after creating the change so that the first robot comment is not in the same timestamp as with the change creation */
+    // Advancing the time after creating the change so that the first robot comment is not in the
+    // same timestamp as with the change creation.
     TestTimeUtil.incrementClock(10, TimeUnit.SECONDS);
 
     RobotCommentInput c1 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c2 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
     RobotCommentInput c3 = TestCommentHelper.createRobotCommentInput(FILE_NAME);
-    /* Give the robot comments identifiable names for testing */
+
+    // Give the robot comments identifiable names for testing
     c1.message = "robot comment 1";
     c2.message = "robot comment 2";
     c3.message = "robot comment 3";
diff --git a/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java b/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
index ac10e96..4b47668 100644
--- a/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
+++ b/javatests/com/google/gerrit/acceptance/config/InstanceIdFromPluginIT.java
@@ -36,10 +36,10 @@
 
 @TestPlugin(
     name = "instance-id-from-plugin",
-    sysModule = "com.google.gerrit.acceptance.config.InstanceIdFromPluginIT$Module")
+    sysModule = "com.google.gerrit.acceptance.config.InstanceIdFromPluginIT$TestModule")
 public class InstanceIdFromPluginIT extends LightweightPluginDaemonTest {
 
-  public static class Module extends AbstractModule {
+  public static class TestModule extends AbstractModule {
 
     @Override
     protected void configure() {
diff --git a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
index 8233f0c..0e0168e 100644
--- a/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
+++ b/javatests/com/google/gerrit/acceptance/edit/ChangeEditIT.java
@@ -46,11 +46,11 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.FileContentInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ChangeEditDetailOption;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.ListChangesOption;
@@ -154,7 +154,7 @@
   public void publishEdit() throws Exception {
     createArbitraryEditFor(changeId);
 
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -209,7 +209,7 @@
 
   @Test
   public void publishEditNotifyRest() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -224,7 +224,7 @@
 
   @Test
   public void publishEditWithDefaultNotify() throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
@@ -281,7 +281,7 @@
   }
 
   @Test
-  public void rebaseEditWithConflictsRest_Conflict() throws Exception {
+  public void rebaseEditWithConflictsRest_conflict() throws Exception {
     PatchSet currentPatchSet = getCurrentPatchSet(changeId2);
     createEmptyEditFor(changeId2);
     gApi.changes().id(changeId2).edit().modifyFile(FILE_NAME, RawInputUtil.create(CONTENT_NEW));
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
index 88d0937..f4918b6 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractForcePush.java
@@ -21,6 +21,7 @@
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.OK;
 import static org.eclipse.jgit.transport.RemoteRefUpdate.Status.REJECTED_OTHER_REASON;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
@@ -34,7 +35,7 @@
 import org.junit.Test;
 
 public abstract class AbstractForcePush extends AbstractDaemonTest {
-  @Inject private ProjectOperations projectOperations;
+  @Inject protected ProjectOperations projectOperations;
 
   @Test
   public void forcePushNotAllowed() throws Exception {
@@ -116,6 +117,28 @@
     assertDeleteRef(OK);
   }
 
+  @Test
+  public void directPushSendsEmail() throws Exception {
+    // create a change
+    PushOneCommit push1 =
+        pushFactory.create(admin.newIdent(), testRepo, "change1", "a.txt", "content");
+    PushOneCommit.Result r = push1.to("refs/for/master");
+    r.assertOkStatus();
+
+    // Add reviewer to receive notifications
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+    sender.clear();
+
+    // direct submit the change
+    PushOneCommit.Result r1 = push1.to("refs/heads/master");
+    r1.assertOkStatus();
+
+    // email received
+    assertThat(sender.getMessages()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(sender.getMessages()).body())
+        .contains("has submitted this change");
+  }
+
   private void assertDeleteRef(RemoteRefUpdate.Status expectedStatus) throws Exception {
     BranchInput in = new BranchInput();
     in.ref = "refs/heads/test";
diff --git a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
index eac0f1b..2253202 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractPushForReview.java
@@ -66,6 +66,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Address;
+import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.BooleanProjectConfig;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.ChangeMessage;
@@ -97,6 +98,7 @@
 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.extensions.restapi.testing.AttentionSetUpdateSubject;
 import com.google.gerrit.git.ObjectIds;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -674,7 +676,7 @@
     GroupInput gin = new GroupInput();
     gin.name = group;
     gin.members = ImmutableList.of(user.username(), user2.username());
-    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerModifier.
     gApi.groups().create(gin);
 
     PushOneCommit.Result r = pushTo("refs/for/master%cc=" + group);
@@ -752,7 +754,7 @@
     GroupInput gin = new GroupInput();
     gin.name = group;
     gin.members = ImmutableList.of(user.username(), user2.username());
-    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerAdder.
+    gin.visibleToAll = true; // TODO(dborowitz): Shouldn't be necessary; see ReviewerModifier.
     gApi.groups().create(gin);
 
     PushOneCommit.Result r = pushTo("refs/for/master%r=" + group);
@@ -1264,13 +1266,13 @@
   }
 
   @Test
-  public void pushForMasterWithApprovals_MissingLabel() throws Exception {
+  public void pushForMasterWithApprovals_missingLabel() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%l=Verify");
     r.assertErrorStatus("label \"Verify\" is not a configured label");
   }
 
   @Test
-  public void pushForMasterWithApprovals_ValueOutOfRange() throws Exception {
+  public void pushForMasterWithApprovals_valueOutOfRange() throws Exception {
     PushOneCommit.Result r = pushTo("refs/for/master%l=Code-Review-3");
     r.assertErrorStatus("label \"Code-Review\": -3 is not a valid value");
   }
@@ -2823,6 +2825,45 @@
     r.assertErrorStatus("\"--unknown\" is not a valid option");
   }
 
+  @Test
+  public void pushForMagicBranchWithSkipValidationOptionIsNotAllowed() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(ImmutableList.of(PUSH_OPTION_SKIP_VALIDATION));
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("\"--skip-validation\" option is only supported for direct push");
+  }
+
+  @Test
+  public void pushWithReviewerAddsToAttentionSet() throws Exception {
+    String pushSpec = "refs/for/master%r=" + user.email();
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+
+    AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
+    AttentionSetUpdateSubject.assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    AttentionSetUpdateSubject.assertThat(attentionSet)
+        .hasOperationThat()
+        .isEqualTo(AttentionSetUpdate.Operation.ADD);
+    AttentionSetUpdateSubject.assertThat(attentionSet)
+        .hasReasonThat()
+        .isEqualTo("Reviewer was added");
+  }
+
+  @Test
+  public void pushWithReviewerAndIgnoreAttentionSetDoesNotAddToAttentionSet() throws Exception {
+    // Create a change
+    String pushSpec = "refs/for/master%r=" + user.email() + ",-ignore-attention-set";
+    PushOneCommit.Result r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+
+    // push a new patch-set with another reviewer
+    pushSpec = "refs/for/master%r=" + accountCreator.user2().email() + ",-ignore-attention-set";
+    r = pushTo(pushSpec);
+    r.assertOkStatus();
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
   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/AbstractSubmitOnPush.java b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
index 23bcdec..31292d5 100644
--- a/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
+++ b/javatests/com/google/gerrit/acceptance/git/AbstractSubmitOnPush.java
@@ -36,7 +36,7 @@
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.events.ChangeMergedEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -396,7 +396,7 @@
         .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
         .update();
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     String pushSpec = "refs/for/master%reviewer=" + user.email();
     sender.clear();
 
@@ -433,7 +433,7 @@
         .add(allow(Permission.SUBMIT).ref("refs/for/refs/heads/master").group(adminGroupUuid()))
         .update();
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     String pushSpec = "refs/for/master%reviewer=" + user.email() + ",cc=" + user.email();
 
     TestAccount user2 = accountCreator.user2();
diff --git a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
index 46688dd..b16394d 100644
--- a/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/AutoMergeIT.java
@@ -166,6 +166,25 @@
     assertNoAutoMergeCreated(result.getCommit());
   }
 
+  @Test
+  public void pushWorksIfAutoMergeExists() throws Exception {
+    PushOneCommit m =
+        pushFactory.create(
+            admin.newIdent(), testRepo, "merge", ImmutableMap.of("foo", "foo-1", "bar", "bar-2"));
+    m.setParents(ImmutableList.of(parent1, parent2));
+    PushOneCommit.Result result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+
+    // Delete change and push commit again.
+    gApi.changes().id(result.getChangeId()).delete();
+
+    // Push again successfully and check that AutoMerge commit is still there
+    result = m.to("refs/for/master");
+    result.assertOkStatus();
+    assertAutoMergeCreated(result.getCommit());
+  }
+
   private void assertAutoMergeCreated(ObjectId mergeCommit) throws Exception {
     try (Repository repo = repoManager.openRepository(project)) {
       assertThat(repo.exactRef(RefNames.refsCacheAutomerge(mergeCommit.name()))).isNotNull();
diff --git a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
index 385780b..9d1bdaa 100644
--- a/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/PushPermissionsIT.java
@@ -103,7 +103,7 @@
     assertThat(r)
         .hasMessages(
             "error: branch refs/heads/master:",
-            "To push into this reference you need 'Push' rights.",
+            "Push to refs/for/master to create a review, or get 'Push' rights to update the branch.",
             "User: admin",
             "Contact an administrator to fix the permissions");
     assertThat(r).hasProcessed(ImmutableMap.of("refs", 1));
@@ -183,7 +183,7 @@
             "You need 'Delete Reference' rights or 'Push' rights with the ",
             "'Force Push' flag set to delete references.",
             "error: branch refs/heads/master:",
-            "To push into this reference you need 'Push' rights.",
+            "Push to refs/for/master to create a review, or get 'Push' rights to update the branch.",
             "User: admin",
             "Contact an administrator to fix the permissions");
   }
diff --git a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
index b7acbe2..194f5f9 100644
--- a/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
+++ b/javatests/com/google/gerrit/acceptance/git/RefAdvertisementIT.java
@@ -38,7 +38,6 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.entities.AccessSection;
 import com.google.gerrit.entities.AccountGroup;
-import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.Patch;
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.Permission;
@@ -52,7 +51,6 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.receive.ReceiveCommitsAdvertiseRefsHookChain;
 import com.google.gerrit.server.git.receive.testing.TestRefAdvertiser;
-import com.google.gerrit.server.notedb.ChangeNoteUtil;
 import com.google.gerrit.server.notedb.Sequences;
 import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.permissions.PermissionBackend.RefFilterOptions;
@@ -69,7 +67,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -82,7 +79,6 @@
 @NoHttpd
 public class RefAdvertisementIT extends AbstractDaemonTest {
   @Inject private AllUsersName allUsersName;
-  @Inject private ChangeNoteUtil noteUtil;
   @Inject private PermissionBackend permissionBackend;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
@@ -1106,37 +1102,10 @@
 
   @Test
   public void receivePackOmitsMissingObject() throws Exception {
-    String rev = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
     try (Repository repo = repoManager.openRepository(project);
         TestRepository<Repository> tr = new TestRepository<>(repo)) {
-      String subject = "Subject for missing commit";
-      Change c = new Change(cd3.change());
-      PatchSet.Id psId = PatchSet.id(cd3.getId(), 2);
-      c.setCurrentPatchSet(psId, subject, c.getOriginalSubject());
-
-      PersonIdent committer = serverIdent.get();
-      PersonIdent author =
-          noteUtil.newAccountIdIdent(getAccount(admin.id()).id(), committer.getWhen(), committer);
-      tr.branch(RefNames.changeMetaRef(cd3.getId()))
-          .commit()
-          .author(author)
-          .committer(committer)
-          .message(
-              "Update patch set "
-                  + psId.get()
-                  + "\n"
-                  + "\n"
-                  + "Patch-set: "
-                  + psId.get()
-                  + "\n"
-                  + "Commit: "
-                  + rev
-                  + "\n"
-                  + "Subject: "
-                  + subject
-                  + "\n")
-          .create();
-      indexer.index(c.getProject(), c.getId());
+      PatchSet.Id psId = PatchSet.id(cd3.getId(), 1);
+      tr.delete(psId.toRefName());
     }
 
     assertThat(getReceivePackRefs().additionalHaves()).containsExactly(obj(cd4, 1));
@@ -1465,7 +1434,6 @@
    * Assert that refs seen by a non-admin user match the expected refs.
    *
    * @param expectedRefs expected refs.
-   * @throws Exception
    */
   private void assertUploadPackRefs(String... expectedRefs) throws Exception {
     assertRefs(project, user, true, expectedRefs);
diff --git a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
index cad0b83..cac376f 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/AbstractReindexTests.java
@@ -31,14 +31,18 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.launcher.GerritLauncher;
 import com.google.gerrit.server.index.GerritIndexStatus;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Provider;
+import com.google.inject.TypeLiteral;
 import java.nio.file.Files;
+import java.util.Collection;
 import java.util.Set;
 import java.util.function.Consumer;
 import org.eclipse.jgit.lib.Config;
@@ -48,9 +52,6 @@
 
 @NoHttpd
 public abstract class AbstractReindexTests extends StandaloneSiteTest {
-  /** @param injector injector */
-  public abstract void configureIndex(Injector injector) throws Exception;
-
   private static final String CHANGES = ChangeSchemaDefinitions.NAME;
 
   private Project.NameKey project;
@@ -223,10 +224,18 @@
     }
   }
 
+  protected static void createAllIndexes(Injector injector) {
+    Collection<IndexDefinition<?, ?, ?>> indexDefs =
+        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
+    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
+      indexDef.getIndexCollection().getSearchIndex().deleteAll();
+    }
+  }
+
   private void setUpChange() throws Exception {
     project = Project.nameKey("reindex-project-test");
     try (ServerContext ctx = startServer()) {
-      configureIndex(ctx.getInjector());
+      createAllIndexes(ctx.getInjector());
       GerritApi gApi = ctx.getInjector().getInstance(GerritApi.class);
       gApi.projects().create(project.get());
 
diff --git a/javatests/com/google/gerrit/acceptance/pgm/BUILD b/javatests/com/google/gerrit/acceptance/pgm/BUILD
index 5b18d02..9fab01b 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/BUILD
+++ b/javatests/com/google/gerrit/acceptance/pgm/BUILD
@@ -4,7 +4,6 @@
 acceptance_tests(
     srcs = glob(
         ["*IT.java"],
-        exclude = ["ElasticReindexIT.java"],
     ),
     group = "pgm",
     labels = [
@@ -21,24 +20,6 @@
     ],
 )
 
-acceptance_tests(
-    srcs = ["ElasticReindexIT.java"],
-    group = "elastic",
-    labels = [
-        "docker",
-        "elastic",
-        "pgm",
-        "no_windows",
-    ],
-    vm_args = ["-Xmx1024m"],
-    deps = [
-        ":util",
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/server/schema",
-        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
-    ],
-)
-
 java_library(
     name = "util",
     testonly = True,
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java b/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java
new file mode 100644
index 0000000..83df896
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/pgm/ChangeExternalIdCaseSensitivityIT.java
@@ -0,0 +1,239 @@
+// 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.pgm;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Test;
+
+@NoHttpd
+public class ChangeExternalIdCaseSensitivityIT extends StandaloneSiteTest {
+
+  private static final boolean CASE_SENSITIVE = false;
+  private static final boolean CASE_INSENSITIVE = true;
+
+  private ServerContext ctx;
+  private ExternalIdNotes extIdNotes;
+  private ExternalIdFactory extIdFactory;
+  private MetaDataUpdate md;
+  private FileBasedConfig config;
+
+  @After
+  public void cleanup() throws Exception {
+    if (ctx != null) {
+      ctx.close();
+    }
+  }
+
+  @Test
+  public void externalIdNoteNameIsMigratedToCaseInsensitive() throws Exception {
+    prepareExternalIdNotes(CASE_SENSITIVE);
+
+    ctx.close();
+    runChangeExternalIdCaseSensitivity();
+    ctx = startServer();
+    extIdNotes = getExternalIdNotes(ctx);
+
+    assertExternalIdNotes(CASE_INSENSITIVE);
+  }
+
+  @Test
+  public void externalIdNoteNameIsMigratedToCaseSensitive() throws Exception {
+    prepareExternalIdNotes(CASE_INSENSITIVE);
+
+    ctx.close();
+    runChangeExternalIdCaseSensitivity();
+    ctx = startServer();
+    extIdNotes = getExternalIdNotes(ctx);
+
+    assertExternalIdNotes(CASE_SENSITIVE);
+  }
+
+  @Test
+  public void migrationFailsWithDuplicates() throws Exception {
+    prepareExternalIdNotes(CASE_SENSITIVE);
+    extIdNotes.insert(extIdFactory.create(SCHEME_USERNAME, "JohnDoe", Account.id(1)));
+    extIdNotes.commit(md);
+
+    assertThat(extIdNotes.get(ExternalId.Key.parse("username:johndoe", false)).isPresent())
+        .isTrue();
+    assertThat(extIdNotes.get(ExternalId.Key.parse("username:JohnDoe", false)).isPresent())
+        .isTrue();
+
+    ctx.close();
+    assertThrows(DuplicateExternalIdKeyException.class, () -> runChangeExternalIdCaseSensitivity());
+    ctx = startServer();
+    extIdNotes = getExternalIdNotes(ctx);
+
+    assertExternalIdNotes(CASE_SENSITIVE);
+    assertThat(extIdNotes.get(ExternalId.Key.parse("username:johndoe", false)).isPresent())
+        .isTrue();
+    assertThat(extIdNotes.get(ExternalId.Key.parse("username:JohnDoe", false)).isPresent())
+        .isTrue();
+  }
+
+  @Test
+  public void userNameCaseInsensitiveOptionIsSwitched() throws Exception {
+    configureUserNameCaseInsensitive(CASE_SENSITIVE);
+    assertThat(config.getBoolean("auth", "userNameCaseInsensitive", false)).isFalse();
+    runChangeExternalIdCaseSensitivity();
+    config.load();
+    assertThat(config.getBoolean("auth", "userNameCaseInsensitive", false)).isTrue();
+    runChangeExternalIdCaseSensitivity();
+    config.load();
+    assertThat(config.getBoolean("auth", "userNameCaseInsensitive", false)).isFalse();
+  }
+
+  @Test
+  public void dryrunDoesNotPersistChanges() throws Exception {
+    prepareExternalIdNotes(CASE_SENSITIVE);
+    ctx.close();
+    runGerrit("ChangeExternalIdCaseSensitivity", "-d", sitePaths.site_path.toString(), "--dryrun");
+
+    config.load();
+    assertThat(config.getBoolean("auth", "userNameCaseInsensitive", false)).isFalse();
+
+    ctx = startServer();
+    assertExternalIdNotes(CASE_SENSITIVE);
+  }
+
+  private void prepareExternalIdNotes(boolean userNameCaseInsensitive) throws Exception {
+    configureUserNameCaseInsensitive(userNameCaseInsensitive);
+    initSite();
+    ctx = startServer();
+    extIdFactory = ctx.getInjector().getInstance(ExternalIdFactory.class);
+    Project.NameKey allUsers = ctx.getInjector().getInstance(AllUsersName.class);
+    extIdNotes = getExternalIdNotes(ctx, allUsers);
+    md = getMetaDataUpdate(ctx, allUsers);
+
+    extIdNotes.insert(extIdFactory.create(SCHEME_USERNAME, "johndoe", Account.id(0)));
+    extIdNotes.insert(extIdFactory.create(SCHEME_GERRIT, "johndoe", Account.id(0)));
+
+    extIdNotes.insert(extIdFactory.create(SCHEME_USERNAME, "JaneDoe", Account.id(1)));
+    extIdNotes.insert(extIdFactory.create(SCHEME_GERRIT, "JaneDoe", Account.id(1)));
+
+    extIdNotes.insert(extIdFactory.create(SCHEME_MAILTO, "Jane@Doe.com", Account.id(1)));
+    extIdNotes.insert(extIdFactory.create(SCHEME_UUID, "Abc123", Account.id(1)));
+    extIdNotes.insert(extIdFactory.create(SCHEME_GPGKEY, "Abc123", Account.id(1)));
+    extIdNotes.insert(extIdFactory.create(SCHEME_EXTERNAL, "saml/JaneDoe", Account.id(1)));
+    extIdNotes.commit(md);
+
+    assertExternalIdNotes(userNameCaseInsensitive);
+  }
+
+  private void assertExternalIdNotes(boolean userNameCaseInsensitive) throws Exception {
+    assertThat(
+            extIdNotes
+                .get(ExternalId.Key.parse("username:johndoe", userNameCaseInsensitive))
+                .isPresent())
+        .isTrue();
+    assertThat(
+            extIdNotes
+                .get(ExternalId.Key.parse("username:JaneDoe", !userNameCaseInsensitive))
+                .isPresent())
+        .isFalse();
+    assertThat(
+            extIdNotes
+                .get(ExternalId.Key.parse("username:JaneDoe", userNameCaseInsensitive))
+                .isPresent())
+        .isTrue();
+
+    assertThat(
+            extIdNotes
+                .get(ExternalId.Key.parse("gerrit:johndoe", userNameCaseInsensitive))
+                .isPresent())
+        .isTrue();
+    assertThat(
+            extIdNotes
+                .get(ExternalId.Key.parse("gerrit:JaneDoe", !userNameCaseInsensitive))
+                .isPresent())
+        .isFalse();
+    assertThat(
+            extIdNotes
+                .get(ExternalId.Key.parse("gerrit:JaneDoe", userNameCaseInsensitive))
+                .isPresent())
+        .isTrue();
+
+    assertThat(extIdNotes.get(ExternalId.Key.parse("mailto:Jane@Doe.com", false)).isPresent())
+        .isTrue();
+    assertThat(extIdNotes.get(ExternalId.Key.parse("uuid:Abc123", false)).isPresent()).isTrue();
+    assertThat(extIdNotes.get(ExternalId.Key.parse("gpgkey:Abc123", false)).isPresent()).isTrue();
+    assertThat(extIdNotes.get(ExternalId.Key.parse("external:saml/JaneDoe", false)).isPresent())
+        .isTrue();
+  }
+
+  private void configureUserNameCaseInsensitive(boolean userNameCaseInsensitive)
+      throws IOException, ConfigInvalidException {
+    config = new FileBasedConfig(baseConfig, sitePaths.gerrit_config.toFile(), FS.DETECTED);
+    config.load();
+    config.setBoolean("auth", null, "userNameCaseInsensitive", userNameCaseInsensitive);
+    config.save();
+    if (userNameCaseInsensitive) {
+      assertThat(config.getBoolean("auth", "userNameCaseInsensitive", false)).isTrue();
+    } else {
+      assertThat(config.getBoolean("auth", "userNameCaseInsensitive", false)).isFalse();
+    }
+  }
+
+  private void initSite() throws Exception {
+    runGerrit("init", "-d", sitePaths.site_path.toString(), "--show-stack-trace");
+  }
+
+  private void runChangeExternalIdCaseSensitivity() throws Exception {
+    runGerrit("ChangeExternalIdCaseSensitivity", "-d", sitePaths.site_path.toString(), "--batch");
+  }
+
+  private static ExternalIdNotes getExternalIdNotes(ServerContext ctx) throws Exception {
+    return getExternalIdNotes(ctx, ctx.getInjector().getInstance(AllUsersName.class));
+  }
+
+  private static ExternalIdNotes getExternalIdNotes(ServerContext ctx, Project.NameKey allUsers)
+      throws Exception {
+    GitRepositoryManager repoManager = ctx.getInjector().getInstance(GitRepositoryManager.class);
+    ExternalIdNotes.FactoryNoReindex extIdNotesFactory =
+        ctx.getInjector().getInstance(ExternalIdNotes.FactoryNoReindex.class);
+    return extIdNotesFactory.load(repoManager.openRepository(allUsers));
+  }
+
+  private static MetaDataUpdate getMetaDataUpdate(ServerContext ctx, Project.NameKey allUsers)
+      throws Exception {
+    MetaDataUpdate.Server metaDataUpdateFactory =
+        ctx.getInjector().getInstance(MetaDataUpdate.Server.class);
+    return metaDataUpdateFactory.create(allUsers);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
deleted file mode 100644
index 8480a6d..0000000
--- a/javatests/com/google/gerrit/acceptance/pgm/ElasticReindexIT.java
+++ /dev/null
@@ -1,36 +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.acceptance.pgm;
-
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
-
-import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-
-public class ElasticReindexIT extends AbstractReindexTests {
-
-  @ConfigSuite.Default
-  public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_16);
-  }
-
-  @Override
-  public void configureIndex(Injector injector) {
-    createAllIndexes(injector);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
index 4db0177..073f427 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/InitIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.pgm;
 
 import static com.google.common.truth.Truth8.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.collect.ImmutableSet;
 import com.google.errorprone.annotations.MustBeClosed;
@@ -61,6 +62,10 @@
 
   @Test
   public void initDoesNotReindexProjectsOnExistingSites() throws Exception {
+    // These tests expect the Lucene index to modify files on disk which the fake index doesn't do.
+    String configuredIndexBackend = baseConfig.getString("index", null, "type");
+    configuredIndexBackend = configuredIndexBackend == null ? "fake" : configuredIndexBackend;
+    assume().that(configuredIndexBackend).isEqualTo("lucene");
     initSite();
 
     // Simulate a projects indexes files modified in the past by 3 seconds
diff --git a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java b/javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
similarity index 68%
rename from javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
rename to javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
index 223851e..e630bca 100644
--- a/javatests/com/google/gerrit/acceptance/pgm/ReindexIT.java
+++ b/javatests/com/google/gerrit/acceptance/pgm/LuceneReindexIT.java
@@ -14,9 +14,14 @@
 
 package com.google.gerrit.acceptance.pgm;
 
-import com.google.inject.Injector;
+import com.google.gerrit.testing.ConfigSuite;
+import org.eclipse.jgit.lib.Config;
 
-public class ReindexIT extends AbstractReindexTests {
-  @Override
-  public void configureIndex(Injector injector) {}
+public class LuceneReindexIT extends AbstractReindexTests {
+  @ConfigSuite.Default
+  public static Config luceneConfig() {
+    Config cfg = new Config();
+    cfg.setString("index", null, "type", "lucene");
+    return cfg;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/BUILD b/javatests/com/google/gerrit/acceptance/rest/BUILD
index 84887da..a62d551 100644
--- a/javatests/com/google/gerrit/acceptance/rest/BUILD
+++ b/javatests/com/google/gerrit/acceptance/rest/BUILD
@@ -5,6 +5,7 @@
     group = "rest_bindings_collection",
     labels = ["rest"],
     deps = [
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
     ],
 )
diff --git a/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
new file mode 100644
index 0000000..ed5e559
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/rest/CancellationIT.java
@@ -0,0 +1,681 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.SC_CLIENT_CLOSED_REQUEST;
+import static org.apache.http.HttpStatus.SC_REQUEST_TIMEOUT;
+
+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.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.project.CreateProjectArgs;
+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 org.apache.http.message.BasicHeader;
+import org.junit.Test;
+
+public class CancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us to verify the HTTP status code that is
+            // set when a request is cancelled.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_CLIENT_CLOSED_REQUEST);
+      assertThat(response.getEntityContent()).isEqualTo("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void handleWrappedRequestCancelledException() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RuntimeException(
+                new RequestCancelledException(
+                    RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m"));
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndeadline = 10m");
+    }
+  }
+
+  @Test
+  public void abortIfClientProvidedDeadlineExceeded() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "1ms"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Client Provided Deadline Exceeded\n\nclient.timeout=1ms");
+  }
+
+  @Test
+  public void requestRejectedIfInvalidDeadlineIsProvided_missingTimeUnit() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "1"));
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("Invalid deadline. Missing time unit: 1");
+  }
+
+  @Test
+  public void requestRejectedIfInvalidDeadlineIsProvided_invalidTimeUnit() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "1x"));
+    response.assertBadRequest();
+    assertThat(response.getEntityContent())
+        .isEqualTo("Invalid deadline. Invalid time unit value: 1x");
+  }
+
+  @Test
+  public void requestRejectedIfInvalidDeadlineIsProvided_invalidValue() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"),
+            new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "invalid"));
+    response.assertBadRequest();
+    assertThat(response.getEntityContent()).isEqualTo("Invalid deadline. Invalid value: invalid");
+  }
+
+  @Test
+  public void requestSucceedsWithinDeadline() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "10m"));
+    response.assertCreated();
+  }
+
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void abortIfServerDeadlineExceeded() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent()).isEqualTo("Server Deadline Exceeded\n\ntimeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.foo.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.bar.timeout", value = "100ms")
+  public void stricterDeadlineTakesPrecedence() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\nfoo.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestType", value = "REST")
+  public void abortIfServerDeadlineExceeded_requestType() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestUriPattern", value = "/projects/.*")
+  public void abortIfServerDeadlineExceeded_requestUriPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(
+      name = "deadline.default.excludedRequestUriPattern",
+      value = "/projects/non-matching")
+  public void abortIfServerDeadlineExceeded_excludedRequestUriPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(
+      name = "deadline.default.excludedRequestUriPattern",
+      value = "/projects/non-matching")
+  public void abortIfServerDeadlineExceeded_requestUriPatternAndExcludedRequestUriPattern()
+      throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.projectPattern", value = ".*new.*")
+  public void abortIfServerDeadlineExceeded_projectPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.account", value = "1000000")
+  public void abortIfServerDeadlineExceeded_account() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=1ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestType", value = "SSH")
+  public void nonMatchingServerDeadlineIsIgnored_requestType() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestUriPattern", value = "/changes/.*")
+  public void nonMatchingServerDeadlineIsIgnored_requestUriPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "/projects/.*")
+  public void nonMatchingServerDeadlineIsIgnored_excludedRequestUriPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "/projects/.*new")
+  public void nonMatchingServerDeadlineIsIgnored_requestUriPatternAndExcludedRequestUriPattern()
+      throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.projectPattern", value = ".*foo.*")
+  public void nonMatchingServerDeadlineIsIgnored_projectPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.account", value = "999")
+  public void nonMatchingServerDeadlineIsIgnored_account() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.isAdvisory", value = "true")
+  public void advisoryServerDeadlineIsIgnored() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.test.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.test.isAdvisory", value = "true")
+  @GerritConfig(name = "deadline.default.timeout", value = "2ms")
+  public void nonAdvisoryDeadlineIsAppliedIfStricterAdvisoryDeadlineExists() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=2ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1")
+  public void invalidServerDeadlineIsIgnored_missingTimeUnit() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1x")
+  public void invalidServerDeadlineIsIgnored_invalidTimeUnit() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "invalid")
+  public void invalidServerDeadlineIsIgnored_invalidValue() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestType", value = "INVALID")
+  public void invalidServerDeadlineIsIgnored_invalidRequestType() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.requestUriPattern", value = "][")
+  public void invalidServerDeadlineIsIgnored_invalidRequestUriPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.excludedRequestUriPattern", value = "][")
+  public void invalidServerDeadlineIsIgnored_invalidExcludedRequestUriPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.projectPattern", value = "][")
+  public void invalidServerDeadlineIsIgnored_invalidProjectPattern() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.account", value = "invalid")
+  public void invalidServerDeadlineIsIgnored_invalidAccount() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.requestType", value = "REST")
+  public void deadlineConfigWithoutTimeoutIsIgnored() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "0ms")
+  @GerritConfig(name = "deadline.default.requestType", value = "REST")
+  public void deadlineConfigWithZeroTimeoutIsIgnored() throws Exception {
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "500ms")
+  public void exceededDeadlineForOneRequestDoesntAbortFollowUpRequest() throws Exception {
+    ProjectCreationValidationListener projectCreationValidationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            try {
+              Thread.sleep(1000);
+            } catch (InterruptedException e) {
+              throw new RuntimeException("interrupted during sleep", e);
+            }
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationValidationListener)) {
+      RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new"));
+      assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+      assertThat(response.getEntityContent())
+          .isEqualTo("Server Deadline Exceeded\n\ndefault.timeout=500ms");
+    }
+    // verify that the exceeded deadline for the previous request, isn't applied to a new request
+    RestResponse response = adminRestSession.putWithHeaders("/projects/" + name("new2"));
+    response.assertCreated();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void clientProvidedDeadlineOverridesServerDeadline() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "2ms"));
+    assertThat(response.getStatusCode()).isEqualTo(SC_REQUEST_TIMEOUT);
+    assertThat(response.getEntityContent())
+        .isEqualTo("Client Provided Deadline Exceeded\n\nclient.timeout=2ms");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void clientCanDisableDeadlineBySettingZeroAsDeadline() throws Exception {
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/projects/" + name("new"), new BasicHeader(RestApiServlet.X_GERRIT_DEADLINE, "0"));
+    response.assertCreated();
+  }
+
+  @Test
+  public void handleClientDisconnectedForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate a request cancellation by throwing RequestCancelledException. In contrast to
+            // an actual request cancellation this allows us verify the error message that is sent
+            // to the client.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceededForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleWrappedRequestCancelledExceptionForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RuntimeException(
+                new RequestCancelledException(
+                    RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m"));
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessageForPush() throws Exception {
+    CommitValidationListener commitValidationListener =
+        new CommitValidationListener() {
+          @Override
+          public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+              throws CommitValidationException {
+            // Simulate an exceeded deadline by throwing RequestCancelledException.
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(commitValidationListener)) {
+      PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+      PushOneCommit.Result r = push.to("refs/heads/master");
+      r.assertErrorStatus("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void abortPushIfServerDeadlineExceeded() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Server Deadline Exceeded (default.timeout=1ms)");
+  }
+
+  @Test
+  @GerritConfig(name = "receive.timeout", value = "1ms")
+  public void abortPushIfTimeoutExceeded() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
+  }
+
+  @Test
+  @GerritConfig(name = "receive.timeout", value = "1ms")
+  @GerritConfig(name = "deadline.default.timeout", value = "10s")
+  public void receiveTimeoutTakesPrecedence() throws Exception {
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
+  }
+
+  @Test
+  public void abortPushIfClientProvidedDeadlineExceeded() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=1ms");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Client Provided Deadline Exceeded (client.timeout=1ms)");
+  }
+
+  @Test
+  public void pushRejectedIfInvalidDeadlineIsProvided_missingTimeUnit() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=1");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Invalid deadline. Missing time unit: 1");
+  }
+
+  @Test
+  public void pushRejectedIfInvalidDeadlineIsProvided_invalidTimeUnit() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=1x");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Invalid deadline. Invalid time unit value: 1x");
+  }
+
+  @Test
+  public void pushRejectedIfInvalidDeadlineIsProvided_invalidValue() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=invalid");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Invalid deadline. Invalid value: invalid");
+  }
+
+  @Test
+  public void pushSucceedsWithinDeadline() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=10m");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void clientProvidedDeadlineOnPushOverridesServerDeadline() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=2ms");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Client Provided Deadline Exceeded (client.timeout=2ms)");
+  }
+
+  @Test
+  @GerritConfig(name = "receive.timeout", value = "1ms")
+  public void clientProvidedDeadlineOnPushDoesntOverrideServerTimeout() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=10m");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertErrorStatus("Server Deadline Exceeded (receive.timeout=1ms)");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void clientCanDisableDeadlineOnPushBySettingZeroAsDeadline() throws Exception {
+    List<String> pushOptions = new ArrayList<>();
+    pushOptions.add("deadline=0");
+    PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo);
+    push.setPushOptions(pushOptions);
+    PushOneCommit.Result r = push.to("refs/for/master");
+    r.assertOkStatus();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
index bc45460..4efdbba 100644
--- a/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/RestApiServletIT.java
@@ -14,25 +14,47 @@
 
 package com.google.gerrit.acceptance.rest;
 
+import static com.google.common.net.HttpHeaders.ORIGIN;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.X_GERRIT_UPDATED_REF;
+import static com.google.gerrit.httpd.restapi.RestApiServlet.X_GERRIT_UPDATED_REF_ENABLED;
 import static org.apache.http.HttpStatus.SC_OK;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.client.ChangeStatus;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.httpd.restapi.ParameterParser;
 import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.inject.Inject;
 import java.io.IOException;
+import java.util.List;
 import java.util.regex.Pattern;
 import org.apache.http.message.BasicHeader;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.Test;
 
 public class RestApiServletIT extends AbstractDaemonTest {
   private static String ANY_REST_API = "/accounts/self/capabilities";
   private static BasicHeader ACCEPT_STAR_HEADER = new BasicHeader("Accept", "*/*");
+  private static BasicHeader X_GERRIT_UPDATED_REF_ENABLED_HEADER =
+      new BasicHeader(X_GERRIT_UPDATED_REF_ENABLED, "true");
   private static Pattern ANY_SPACE = Pattern.compile("\\s");
 
+  @Inject private ProjectOperations projectOperations;
+
   @Test
   public void restResponseBodyShouldBeCompactWithoutSpaces() throws Exception {
-    RestResponse response = adminRestSession.getWithHeader(ANY_REST_API, ACCEPT_STAR_HEADER);
+    RestResponse response = adminRestSession.getWithHeaders(ANY_REST_API, ACCEPT_STAR_HEADER);
     assertThat(response.getStatusCode()).isEqualTo(SC_OK);
 
     assertThat(contentWithoutMagicJson(response)).doesNotContainMatch(ANY_SPACE);
@@ -61,9 +83,356 @@
         .containsMatch(ANY_SPACE);
   }
 
+  @Test
+  public void experimentRequestParamIsReserved() throws Exception {
+    assertRestResponseWithParameters(SC_OK, ParameterParser.EXPERIMENT_PARAMETER, "exp1");
+  }
+
+  @Test
+  public void xGerritUpdatedRefNotSetByDefault() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "A",
+            new BasicHeader(ORIGIN, origin));
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+
+    // Meta ref updated because of topic update, but updated refs are not set by default.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefNotSetWhenUpdatedRefNotEnabled() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "A",
+            new BasicHeader(ORIGIN, origin),
+            new BasicHeader(X_GERRIT_UPDATED_REF_ENABLED, "false"));
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+
+    // Meta ref updated because of topic update, but updated refs are not enabled.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefNotSetForReadRequests() throws Exception {
+    RestResponse response =
+        adminRestSession.getWithHeaders(
+            ANY_REST_API, ACCEPT_STAR_HEADER, X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    assertThat(response.getStatusCode()).isEqualTo(SC_OK);
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF)).isNull();
+  }
+
+  @Test
+  public void xGerritUpdatedRefSetForDifferentWriteRequests() throws Exception {
+    Result change = createChange();
+    String origin = adminRestSession.url();
+    String project = change.getChange().project().get();
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+
+    RestResponse response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "A",
+            new BasicHeader(ORIGIN, origin),
+            X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("A");
+    ObjectId firstMetaRefSha1 = getMetaRefSha1(change);
+
+    // Meta ref updated because of topic update.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF))
+        .isEqualTo(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                firstMetaRefSha1.getName()));
+
+    response =
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content= */ "B",
+            new BasicHeader(ORIGIN, origin),
+            X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertOK();
+    assertThat(gApi.changes().id(change.getChangeId()).topic()).isEqualTo("B");
+
+    ObjectId secondMetaRefSha1 = getMetaRefSha1(change);
+
+    // Meta ref updated again because of another topic update.
+    assertThat(response.getHeader(X_GERRIT_UPDATED_REF))
+        .isEqualTo(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                firstMetaRefSha1.getName(),
+                secondMetaRefSha1.getName()));
+
+    // Ensure the meta ref SHA-1 changed for the project~metaRef which means we return different
+    // X-Gerrit-UpdatedRef headers.
+    assertThat(secondMetaRefSha1).isNotEqualTo(firstMetaRefSha1);
+  }
+
+  @Test
+  public void xGerritUpdatedRefDeleted() throws Exception {
+    Result change = createChange();
+    String project = change.getChange().project().get();
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+    String patchSetRef = RefNames.patchSetRef(change.getPatchSetId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+    ObjectId originalchangeRefSha1 = change.getCommit().getId();
+
+    RestResponse response =
+        adminRestSession.deleteWithHeaders(
+            "/changes/" + change.getChangeId(), X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertNoContent();
+
+    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+    // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
+    assertThat(headers)
+        .containsExactly(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                ObjectId.zeroId().getName()),
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project),
+                Url.encode(patchSetRef),
+                originalchangeRefSha1.getName(),
+                ObjectId.zeroId().getName()));
+  }
+
+  @Test
+  public void xGerritUpdatedRefWithProjectNameContainingTilde() throws Exception {
+    Project.NameKey project = createProjectOverAPI("~~pr~oje~ct~~~~", null, true, null);
+    Result change = createChange(cloneProject(project, admin));
+    String metaRef = RefNames.changeMetaRef(change.getChange().getId());
+    String patchSetRef = RefNames.patchSetRef(change.getPatchSetId());
+
+    ObjectId originalMetaRefSha1 = getMetaRefSha1(change);
+    ObjectId originalchangeRefSha1 = change.getCommit().getId();
+
+    RestResponse response =
+        adminRestSession.deleteWithHeaders(
+            "/changes/" + change.getChangeId(), X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+    response.assertNoContent();
+
+    List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+    // The change was deleted, so the refs were deleted which means they are ObjectId.zeroId().
+    assertThat(headers)
+        .containsExactly(
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project.get()),
+                Url.encode(metaRef),
+                originalMetaRefSha1.getName(),
+                ObjectId.zeroId().getName()),
+            String.format(
+                "%s~%s~%s~%s",
+                Url.encode(project.get()),
+                Url.encode(patchSetRef),
+                originalchangeRefSha1.getName(),
+                ObjectId.zeroId().getName()));
+
+    // Ensures ~ gets encoded to %7E.
+    assertThat(Url.encode(project.get())).endsWith("%7E%7Epr%7Eoje%7Ect%7E%7E%7E%7E");
+  }
+
+  @Test
+  public void xGerritUpdatedRefSetMultipleHeadersForSubmit() throws Exception {
+    Result change1 = createChange();
+    Result change2 = createChange();
+    String metaRef1 = RefNames.changeMetaRef(change1.getChange().getId());
+    String metaRef2 = RefNames.changeMetaRef(change2.getChange().getId());
+
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.approve());
+
+    Project.NameKey project = change1.getChange().project();
+
+    try (Repository repository = repoManager.openRepository(project)) {
+      ObjectId originalFirstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId originalSecondMetaRefSha1 = getMetaRefSha1(change2);
+      ObjectId originalDestinationBranchSha1 =
+          repository.resolve(change1.getChange().change().getDest().branch());
+
+      RestResponse response =
+          adminRestSession.postWithHeaders(
+              "/changes/" + change2.getChangeId() + "/submit",
+              /* content = */ null,
+              X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+      response.assertOK();
+
+      ObjectId firstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId secondMetaRefSha1 = getMetaRefSha1(change2);
+
+      List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+      String branch = change1.getChange().change().getDest().branch();
+      String branchSha1 =
+          repository
+              .getRefDatabase()
+              .exactRef(change1.getChange().change().getDest().branch())
+              .getObjectId()
+              .name();
+
+      // During submit, all relevant meta refs of the latest patchset are updated + the destination
+      // branch/es.
+      assertThat(headers)
+          .containsExactly(
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(metaRef1),
+                  originalFirstMetaRefSha1.getName(),
+                  firstMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(metaRef2),
+                  originalSecondMetaRefSha1.getName(),
+                  secondMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project.get()),
+                  Url.encode(branch),
+                  originalDestinationBranchSha1.getName(),
+                  branchSha1));
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void xGerritUpdatedRefSetMultipleHeadersForSubmitTopic() throws Exception {
+    String secondProject = "secondProject";
+    projectOperations.newProject().name(secondProject).create();
+    TestRepository<InMemoryRepository> secondRepo =
+        cloneProject(Project.nameKey("secondProject"), admin);
+    String topic = "topic";
+    String branch = "refs/heads/master";
+    Result change1 = createChange(testRepo, branch, "first change", "a.txt", "message", topic);
+    Result change2 = createChange(secondRepo, branch, "second change", "b.txt", "message", topic);
+
+    String metaRef1 = RefNames.changeMetaRef(change1.getChange().getId());
+    String metaRef2 = RefNames.changeMetaRef(change2.getChange().getId());
+
+    gApi.changes().id(change1.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(change2.getChangeId()).current().review(ReviewInput.approve());
+
+    Project.NameKey project1 = change1.getChange().project();
+    Project.NameKey project2 = change2.getChange().project();
+
+    try (Repository repository1 = repoManager.openRepository(project1);
+        Repository repository2 = repoManager.openRepository(project2)) {
+      ObjectId originalFirstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId originalSecondMetaRefSha1 = getMetaRefSha1(change2);
+      ObjectId originalDestinationBranchSha1Project1 =
+          repository1.resolve(change1.getChange().change().getDest().branch());
+      ObjectId originalDestinationBranchSha1Project2 =
+          repository2.resolve(change2.getChange().change().getDest().branch());
+
+      RestResponse response =
+          adminRestSession.postWithHeaders(
+              "/changes/" + change2.getChangeId() + "/submit",
+              /* content = */ null,
+              X_GERRIT_UPDATED_REF_ENABLED_HEADER);
+      response.assertOK();
+      assertThat(gApi.changes().id(change1.getChangeId()).get().status)
+          .isEqualTo(ChangeStatus.MERGED);
+
+      ObjectId firstMetaRefSha1 = getMetaRefSha1(change1);
+      ObjectId secondMetaRefSha1 = getMetaRefSha1(change2);
+
+      List<String> headers = response.getHeaders(X_GERRIT_UPDATED_REF);
+
+      String branchSha1Project1 =
+          repository1
+              .getRefDatabase()
+              .exactRef(change1.getChange().change().getDest().branch())
+              .getObjectId()
+              .name();
+
+      String branchSha1Project2 =
+          repository2
+              .getRefDatabase()
+              .exactRef(change2.getChange().change().getDest().branch())
+              .getObjectId()
+              .name();
+
+      // During submit, all relevant meta refs of the latest patchset are updated + the destination
+      // branch/es.
+      // TODO(paiking): This doesn't work well for torn submissions: If the changes are in
+      // different projects in the same topic, and we tried to submit those changes together, it's
+      // possible that the first submission only submitted one of the changes, and then the retry
+      // submitted the other change. If that happens, when the user retries, they will not get the
+      // meta ref updates for the change that got submitted on the previous submission attempt.
+      // Ideally, submit should be idempotent and always return all meta refs on all submission
+      // attempts.
+      assertThat(headers)
+          .containsExactly(
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project1.get()),
+                  Url.encode(metaRef1),
+                  originalFirstMetaRefSha1.getName(),
+                  firstMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project2.get()),
+                  Url.encode(metaRef2),
+                  originalSecondMetaRefSha1.getName(),
+                  secondMetaRefSha1.getName()),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project1.get()),
+                  Url.encode(branch),
+                  originalDestinationBranchSha1Project1.getName(),
+                  branchSha1Project1),
+              String.format(
+                  "%s~%s~%s~%s",
+                  Url.encode(project2.get()),
+                  Url.encode(branch),
+                  originalDestinationBranchSha1Project2.getName(),
+                  branchSha1Project2));
+    }
+  }
+
+  private ObjectId getMetaRefSha1(Result change) {
+    return change.getChange().notes().getRevision();
+  }
+
+  private RestResponse assertRestResponseWithParameters(int status, String k, String v)
+      throws Exception {
+    RestResponse response =
+        adminRestSession.getWithHeaders(ANY_REST_API + "?" + k + "=" + v, ACCEPT_STAR_HEADER);
+    assertThat(response.getStatusCode()).isEqualTo(status);
+
+    return response;
+  }
+
   private RestResponse prettyJsonRestResponse(String ppArgument, int ppValue) throws Exception {
     RestResponse response =
-        adminRestSession.getWithHeader(
+        adminRestSession.getWithHeaders(
             ANY_REST_API + "?" + ppArgument + "=" + ppValue, ACCEPT_STAR_HEADER);
     assertThat(response.getStatusCode()).isEqualTo(SC_OK);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
index 02916c7..7e40b2b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/TraceIT.java
@@ -24,7 +24,7 @@
 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 static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.ImmutableList;
@@ -160,7 +160,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new4", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNotNull();
@@ -177,7 +177,7 @@
     try (Registration registration =
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       RestResponse response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new5", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
@@ -195,7 +195,7 @@
         extensionRegistry.newRegistration().add(projectCreationListener)) {
       // trace ID only specified by trace header
       RestResponse response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new6?trace", new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
       assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isEqualTo("issue/123");
@@ -205,7 +205,7 @@
 
       // trace ID only specified by trace request parameter
       response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new7?trace=issue/123",
               new BasicHeader(RestApiServlet.X_GERRIT_TRACE, null));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
@@ -216,7 +216,7 @@
 
       // same trace ID specified by trace header and trace request parameter
       response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new8?trace=issue/123",
               new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/123"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
@@ -227,7 +227,7 @@
 
       // different trace IDs specified by trace header and trace request parameter
       response =
-          adminRestSession.putWithHeader(
+          adminRestSession.putWithHeaders(
               "/projects/new9?trace=issue/123",
               new BasicHeader(RestApiServlet.X_GERRIT_TRACE, "issue/456"));
       assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
@@ -409,7 +409,7 @@
       PushOneCommit.Result r = push.to("refs/heads/master");
       r.assertOkStatus();
 
-      verifyZeroInteractions(testPerformanceLogger);
+      verifyNoInteractions(testPerformanceLogger);
     }
   }
 
@@ -711,6 +711,94 @@
   }
 
   @Test
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/.*")
+  public void traceExcludedRequestUriPattern() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz1");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz1");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/no-match")
+  public void traceExcludedRequestUriPatternNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz3");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz3");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/xyz2")
+  public void traceRequestUriPatternAndExcludedRequestUriPattern() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz2");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz2");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.requestUriPattern", value = "/projects/.*")
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "/projects/no-match")
+  public void traceRequestUriPatternAndExcludedRequestUriPatternNoMatch() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz3");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isEqualTo("issue123");
+      assertThat(projectCreationListener.isLoggingForced).isTrue();
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz3");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "tracing.issue123.excludedRequestUriPattern", value = "][")
+  public void traceExcludedRequestUriInvalidRegEx() throws Exception {
+    TraceValidatingProjectCreationValidationListener projectCreationListener =
+        new TraceValidatingProjectCreationValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      RestResponse response = adminRestSession.put("/projects/xyz4");
+      assertThat(response.getStatusCode()).isEqualTo(SC_CREATED);
+      assertThat(response.getHeader(RestApiServlet.X_GERRIT_TRACE)).isNull();
+      assertThat(projectCreationListener.traceId).isNull();
+      assertThat(projectCreationListener.isLoggingForced).isFalse();
+
+      // The logging tag with the project name is also set if tracing is off.
+      assertThat(projectCreationListener.tags.get("project")).containsExactly("xyz4");
+    }
+  }
+
+  @Test
   @GerritConfig(name = "retry.retryWithTraceOnFailure", value = "true")
   public void noAutoRetryIfExceptionCausesNormalRetrying() throws Exception {
     String changeId = createChange().getChangeId();
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
index 0b0f2ec..79e8ab0 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/AccountAssert.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.acceptance.rest.account;
 
-import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.Iterables;
@@ -39,8 +38,7 @@
     if (testAccount.tags().isEmpty()) {
       assertThat(accountInfo.tags).isNull();
     } else {
-      assertThat(accountInfo.tags.stream().map(Enum::name).collect(toImmutableList()))
-          .containsExactlyElementsIn(testAccount.tags());
+      assertThat(accountInfo.tags).containsExactlyElementsIn(testAccount.tags());
     }
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
index 1ca019e..7598062 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/CapabilityInfo.java
@@ -32,12 +32,12 @@
   public boolean runAs;
   public boolean runGC;
   public boolean streamEvents;
+  public boolean viewAccess;
   public boolean viewAllAccounts;
   public boolean viewCaches;
   public boolean viewConnections;
   public boolean viewPlugins;
   public boolean viewQueue;
-  public boolean viewAccess;
 
   static class QueryLimit {
     short min;
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
index 8feac20..d055875 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/EmailIT.java
@@ -40,6 +40,8 @@
 import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AuthConfig;
@@ -63,6 +65,8 @@
   @Inject private ExternalIds externalIds;
   @Inject private Provider<Emails> emails;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
 
   @Test
   public void addEmail() throws Exception {
@@ -138,7 +142,7 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    ExternalId.createWithEmail(
+                    externalIdFactory.createWithEmail(
                         ExternalId.SCHEME_EXTERNAL, "foo", admin.id(), email)));
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
@@ -182,7 +186,7 @@
   public void setPreferredEmailToEmailFromCustomRealmThatDoesntExistAsExternalId()
       throws Exception {
     String email = "foo@example.com";
-    ExternalId.Key mailtoExtIdKey = ExternalId.Key.create(ExternalId.SCHEME_MAILTO, email);
+    ExternalId.Key mailtoExtIdKey = externalIdKeyFactory.create(ExternalId.SCHEME_MAILTO, email);
     assertThat(externalIds.get(mailtoExtIdKey)).isEmpty();
     assertThat(gApi.accounts().self().get().email).isNotEqualTo(email);
 
@@ -200,7 +204,7 @@
 
   @Test
   public void setPreferredEmailToEmailFromCustomRealmThatBelongsToOtherAccount() throws Exception {
-    ExternalId mailToExtId = ExternalId.createEmail(user.id(), user.email());
+    ExternalId mailToExtId = externalIdFactory.createEmail(user.id(), user.email());
     assertThat(externalIds.get(mailToExtId.key())).isPresent();
 
     Context oldCtx =
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
index ac82a78..1e89a85 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ExternalIdIT.java
@@ -21,6 +21,8 @@
 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.allowCapability;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GPGKEY;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_UUID;
@@ -38,6 +40,7 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 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.common.data.GlobalCapability;
@@ -54,7 +57,10 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIdNotes;
 import com.google.gerrit.server.account.externalids.ExternalIdReader;
 import com.google.gerrit.server.account.externalids.ExternalIds;
@@ -79,8 +85,6 @@
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RemoteRefUpdate;
@@ -89,12 +93,18 @@
 import org.junit.Test;
 
 public class ExternalIdIT extends AbstractDaemonTest {
+  private static final boolean IS_USER_NAME_CASE_INSENSITIVE_MIGRATION_MODE = false;
+  private static final boolean CASE_SENSITIVE_USERNAME = false;
+  private static final boolean CASE_INSENSITIVE_USERNAME = true;
+
   @Inject @ServerInitiated private Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private ExternalIds externalIds;
   @Inject private ExternalIdReader externalIdReader;
   @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
 
   @ConfigSuite.Default
   public static Config partialCacheReloadingEnabled() {
@@ -127,7 +137,7 @@
   }
 
   @Test
-  public void getExternalIdsOfOtherUserNotAllowed() throws Exception {
+  public void getExternalIdsOfOtherUserNotAllowed() {
     requestScopeOperations.setApiUser(user.id());
     AuthException thrown =
         assertThrows(
@@ -197,7 +207,7 @@
   }
 
   @Test
-  public void deleteExternalIdOfOtherUserUnderOwnAccount_UnprocessableEntity() throws Exception {
+  public void deleteExternalIdOfOtherUserUnderOwnAccount_unprocessableEntity() throws Exception {
     List<AccountExternalIdInfo> extIds = gApi.accounts().self().getExternalIds();
     requestScopeOperations.setApiUser(user.id());
     UnprocessableEntityException thrown =
@@ -252,19 +262,60 @@
     gApi.accounts()
         .self()
         .deleteExternalIds(
-            ImmutableList.of(ExternalId.Key.create(SCHEME_MAILTO, preferredEmail).get()));
+            ImmutableList.of(externalIdKeyFactory.create(SCHEME_MAILTO, preferredEmail).get()));
     assertThat(gApi.accounts().self().get().email).isNull();
   }
 
   @Test
-  public void deleteExternalIds_Conflict() throws Exception {
+  public void deleteExternalIdOfUsernameByNonAdminForbidden() throws Exception {
     List<String> toDelete = new ArrayList<>();
     String externalIdStr = "username:" + user.username();
     toDelete.add(externalIdStr);
-    RestResponse response = userRestSession.post("/accounts/self/external.ids:delete", toDelete);
-    response.assertConflict();
-    assertThat(response.getEntityContent())
-        .isEqualTo(String.format("External id %s cannot be deleted", externalIdStr));
+    RestResponse response =
+        userRestSession.post("/accounts/" + admin.id() + "/external.ids:delete", toDelete);
+    response.assertForbidden();
+  }
+
+  @Test
+  public void deleteExternalIdOfUsernameSelfForbidden() throws Exception {
+    List<String> toDelete = new ArrayList<>();
+    String externalIdStr = "username:" + admin.username();
+    toDelete.add(externalIdStr);
+    RestResponse response = adminRestSession.post("/accounts/self/external.ids:delete", toDelete);
+    response.assertForbidden();
+  }
+
+  @Test
+  public void deleteExternalIdOfUsernameByAdmin() throws Exception {
+    List<String> toDelete = new ArrayList<>();
+    String externalIdStr = "username:" + user.username();
+    toDelete.add(externalIdStr);
+    RestResponse response =
+        adminRestSession.post("/accounts/" + user.id() + "/external.ids:delete", toDelete);
+    response.assertNoContent();
+    List<AccountExternalIdInfo> results = gApi.accounts().id(user.id().get()).getExternalIds();
+    assertThat(results).hasSize(1);
+    assertThat(results.get(0).identity).isEqualTo("mailto:user1@example.com");
+  }
+
+  @Test
+  public void deleteExternalIdOfUsernameMaintainServer() throws Exception {
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.MAINTAIN_SERVER).group(REGISTERED_USERS))
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
+
+    List<String> toDelete = new ArrayList<>();
+    TestAccount user2 = accountCreator.user2();
+    String externalIdStr = "username:" + user2.username();
+    toDelete.add(externalIdStr);
+    RestResponse response =
+        userRestSession.post("/accounts/" + user2.id() + "/external.ids:delete", toDelete);
+    response.assertNoContent();
+    List<AccountExternalIdInfo> results = gApi.accounts().id(user2.id().get()).getExternalIds();
+    assertThat(results).hasSize(1);
+    assertThat(results.get(0).identity).isEqualTo("mailto:user2@example.com");
   }
 
   @Test
@@ -510,7 +561,7 @@
   }
 
   @Test
-  public void checkConsistencyNotAllowed() throws Exception {
+  public void checkConsistencyNotAllowed() {
     AuthException thrown =
         assertThrows(
             AuthException.class,
@@ -528,12 +579,12 @@
 
     // create valid external IDs
     insertExtId(
-        ExternalId.createWithPassword(
-            ExternalId.Key.parse(nextId(scheme, i)),
+        externalIdFactory.createWithPassword(
+            externalIdKeyFactory.parse(nextId(scheme, i)),
             admin.id(),
             "admin.other@example.com",
             "secret-password"));
-    insertExtId(ExternalId.createEmail(admin.id(), "admin.other@example.com"));
+    insertExtId(externalIdFactory.createEmail(admin.id(), "admin.other@example.com"));
     insertExtId(createExternalIdWithOtherCaseEmail(nextId(scheme, i)));
   }
 
@@ -633,29 +684,30 @@
   }
 
   private ExternalId createExternalIdWithOtherCaseEmail(String externalId) {
-    return ExternalId.createWithPassword(
-        ExternalId.Key.parse(externalId),
+    return externalIdFactory.createWithPassword(
+        externalIdKeyFactory.parse(externalId),
         admin.id(),
         admin.email().toUpperCase(Locale.US),
         "password");
   }
 
   private ExternalId createExternalIdForNonExistingAccount(String externalId) {
-    return ExternalId.create(ExternalId.Key.parse(externalId), Account.id(1));
+    return externalIdFactory.create(externalIdKeyFactory.parse(externalId), Account.id(1));
   }
 
   private ExternalId createExternalIdWithInvalidEmail(String externalId) {
-    return ExternalId.createWithEmail(
-        ExternalId.Key.parse(externalId), admin.id(), "invalid-email");
+    return externalIdFactory.createWithEmail(
+        externalIdKeyFactory.parse(externalId), admin.id(), "invalid-email");
   }
 
   private ExternalId createExternalIdWithDuplicateEmail(String externalId) {
-    return ExternalId.createWithEmail(ExternalId.Key.parse(externalId), user.id(), admin.email());
+    return externalIdFactory.createWithEmail(
+        externalIdKeyFactory.parse(externalId), user.id(), admin.email());
   }
 
   private ExternalId createExternalIdWithBadPassword(String username) {
-    return ExternalId.create(
-        ExternalId.Key.create(SCHEME_USERNAME, username),
+    return externalIdFactory.create(
+        externalIdKeyFactory.create(SCHEME_USERNAME, username),
         admin.id(),
         null,
         "non-hashed-password-is-not-allowed");
@@ -667,14 +719,14 @@
 
   @Test
   public void readExternalIdWithAccountIdThatCanBeExpressedInKiB() throws Exception {
-    ExternalId.Key extIdKey = ExternalId.Key.parse("foo:bar");
+    ExternalId.Key extIdKey = externalIdKeyFactory.parse("foo:bar");
     Account.Id accountId = Account.id(1024 * 100);
     accountsUpdateProvider
         .get()
         .insert(
             "Create Account with Bad External ID",
             accountId,
-            u -> u.addExternalId(ExternalId.create(extIdKey, accountId)));
+            u -> u.addExternalId(externalIdFactory.create(extIdKey, accountId)));
     Optional<ExternalId> extId = externalIds.get(extIdKey);
     assertThat(extId.map(ExternalId::accountId)).hasValue(accountId);
   }
@@ -684,7 +736,7 @@
     Set<ExternalId> expectedExtIds = new HashSet<>(externalIds.byAccount(admin.id()));
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // insert external ID
-      ExternalId extId = ExternalId.create("foo", "bar", admin.id());
+      ExternalId extId = externalIdFactory.create("foo", "bar", admin.id());
       insertExtId(extId);
       expectedExtIds.add(extId);
       assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExtIds);
@@ -692,7 +744,7 @@
       // update external ID
       expectedExtIds.remove(extId);
       ExternalId extId2 =
-          ExternalId.createWithEmail("foo", "bar", admin.id(), "foo.bar@example.com");
+          externalIdFactory.createWithEmail("foo", "bar", admin.id(), "foo.bar@example.com");
       accountsUpdateProvider
           .get()
           .update("Update External ID", admin.id(), u -> u.updateExternalId(extId2));
@@ -714,7 +766,7 @@
 
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
-      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
+      insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
 
       assertThrows(IOException.class, () -> externalIds.byAccount(admin.id()));
     }
@@ -726,7 +778,7 @@
 
     try (AutoCloseable ctx = createFailOnLoadContext()) {
       // update external ID branch so that external IDs need to be reloaded
-      insertExtIdBehindGerritsBack(ExternalId.create("foo", "bar", admin.id()));
+      insertExtIdBehindGerritsBack(externalIdFactory.create("foo", "bar", admin.id()));
 
       assertThrows(IOException.class, () -> externalIds.byEmail(admin.email()));
     }
@@ -735,7 +787,7 @@
   @Test
   public void byAccountUpdateExternalIdsBehindGerritsBack() throws Exception {
     Set<ExternalId> expectedExternalIds = new HashSet<>(externalIds.byAccount(admin.id()));
-    ExternalId newExtId = ExternalId.create("foo", "bar", admin.id());
+    ExternalId newExtId = externalIdFactory.create("foo", "bar", admin.id());
     insertExtIdBehindGerritsBack(newExtId);
     expectedExternalIds.add(newExtId);
     assertThat(externalIds.byAccount(admin.id())).containsExactlyElementsIn(expectedExternalIds);
@@ -743,10 +795,10 @@
 
   @Test
   public void unsetEmail() throws Exception {
-    ExternalId extId = ExternalId.createWithEmail("x", "1", user.id(), "x@example.com");
+    ExternalId extId = externalIdFactory.createWithEmail("x", "1", user.id(), "x@example.com");
     insertExtId(extId);
 
-    ExternalId extIdWithoutEmail = ExternalId.create("x", "1", user.id());
+    ExternalId extIdWithoutEmail = externalIdFactory.create("x", "1", user.id());
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
@@ -760,10 +812,11 @@
   @Test
   public void unsetHttpPassword() throws Exception {
     ExternalId extId =
-        ExternalId.createWithPassword(ExternalId.Key.create("y", "1"), user.id(), null, "secret");
+        externalIdFactory.createWithPassword(
+            externalIdKeyFactory.create("y", "1"), user.id(), null, "secret");
     insertExtId(extId);
 
-    ExternalId extIdWithoutPassword = ExternalId.create("y", "1", user.id());
+    ExternalId extIdWithoutPassword = externalIdFactory.create("y", "1", user.id());
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
@@ -775,96 +828,211 @@
   }
 
   @Test
-  public void footers() throws Exception {
-    // Insert external ID for different accounts
-    TestAccount user1 = accountCreator.create("user1");
-    TestAccount user2 = accountCreator.create("user2");
-    ExternalId extId1 = ExternalId.create("foo", "1", user1.id());
-    ExternalId extId2 = ExternalId.create("foo", "2", user1.id());
-    ExternalId extId3 = ExternalId.create("foo", "3", user2.id());
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  public void createCaseInsensitiveExternalId_DuplicateKey() throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(ImmutableSet.of(extId1, extId2, extId3));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Account: " + user2.id())
-          .inOrder();
+      testCaseInsensitiveExternalIdKey(md, extIdNotes, SCHEME_USERNAME, "JohnDoe", Account.id(42));
+      assertThrows(
+          DuplicateExternalIdKeyException.class,
+          () ->
+              extIdNotes.insert(
+                  externalIdFactory.create(SCHEME_USERNAME, "johndoe", Account.id(23))));
     }
+  }
 
-    // Insert external ID with different emails
-    ExternalId extId4 = ExternalId.createWithEmail("foo", "4", user1.id(), "foo4@example.com");
-    ExternalId extId5 = ExternalId.createWithEmail("foo", "5", user2.id(), "foo5@example.com");
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  public void createCaseInsensitiveExternalId_SchemeWithUsername() throws Exception {
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.insert(ImmutableSet.of(extId4, extId5));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly(
-              "Account: " + user1.id(),
-              "Account: " + user2.id(),
-              "Email: foo4@example.com",
-              "Email: foo5@example.com")
-          .inOrder();
-    }
 
-    // Update external ID - Add Email
-    ExternalId extId1a = ExternalId.createWithEmail("foo", "1", user1.id(), "foo1@example.com");
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(extId1a);
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
-          .inOrder();
+      testCaseInsensitiveExternalIdKey(md, extIdNotes, SCHEME_USERNAME, "janedoe", Account.id(66));
+      testCaseInsensitiveExternalIdKey(md, extIdNotes, SCHEME_GERRIT, "JaneDoe", Account.id(66));
     }
+  }
 
-    // Update external ID - Remove Email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.upsert(extId1);
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo1@example.com")
-          .inOrder();
-    }
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void createCaseInsensitiveMigrationModeExternalIdBeforeTheMigration() throws Exception {
+    Account.Id accountId = Account.id(66);
+    boolean isUserNameCaseInsensitive = false;
 
-    // Delete external IDs
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(ImmutableSet.of(extId1, extId5));
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly(
-              "Account: " + user1.id(), "Account: " + user2.id(), "Email: foo5@example.com")
-          .inOrder();
-    }
 
-    // Delete external ID by key without email
-    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
-        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
-      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(extId2.accountId(), extId2.key());
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c)).containsExactly("Account: " + user1.id()).inOrder();
-    }
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JaneDoe", accountId, isUserNameCaseInsensitive);
 
-    // Delete external ID by key with email
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getExternalId(extIdNotes, SCHEME_GERRIT, "janedoe").isPresent()).isFalse();
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getExternalId(extIdNotes, SCHEME_USERNAME, "janedoe").isPresent()).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void createCaseInsensitiveMigrationModeExternalIdAccountAfterTheMigration()
+      throws Exception {
+    Account.Id accountId = Account.id(66);
+    boolean isUserNameCaseInsensitive = true;
+
     try (Repository allUsersRepo = repoManager.openRepository(allUsers);
         MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
-      extIdNotes.delete(extId4.accountId(), extId4.key());
-      RevCommit c = extIdNotes.commit(md);
-      assertThat(getFooters(c))
-          .containsExactly("Account: " + user1.id(), "Email: foo4@example.com")
-          .inOrder();
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JaneDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "janedoe")).isEqualTo(accountId.get());
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "janedoe")).isEqualTo(accountId.get());
     }
   }
 
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldTolerateDuplicateExternalIdsWhenInMigrationMode() throws Exception {
+    Account.Id firstAccountId = Account.id(1);
+    Account.Id secondAccountId = Account.id(2);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "janedoe", firstAccountId, CASE_SENSITIVE_USERNAME);
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", secondAccountId, CASE_SENSITIVE_USERNAME);
+
+      ExternalId.Key firstAccountExternalId =
+          externalIdKeyFactory.create(SCHEME_GERRIT, "janedoe", CASE_INSENSITIVE_USERNAME);
+      assertThat(externalIds.get(firstAccountExternalId).get().accountId())
+          .isEqualTo(firstAccountId);
+
+      ExternalId.Key secondAccountExternalId =
+          externalIdKeyFactory.create(SCHEME_GERRIT, "JaneDoe", CASE_INSENSITIVE_USERNAME);
+      assertThat(externalIds.get(secondAccountExternalId).get().accountId())
+          .isEqualTo(secondAccountId);
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void createCaseInsensitiveMigrationModeExternalIdAccountDuringTheMigration()
+      throws Exception {
+    Account.Id accountId = Account.id(66);
+    boolean userNameCaseInsensitive = true;
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, !userNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, !userNameCaseInsensitive);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JaneDoe", accountId, userNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JaneDoe", accountId, userNameCaseInsensitive);
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JonDoe")).isEqualTo(accountId.get());
+      assertThat(getExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isFalse();
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JonDoe")).isEqualTo(accountId.get());
+
+      assertThat(getExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "JaneDoe")).isEqualTo(accountId.get());
+
+      assertThat(getAccountId(extIdNotes, SCHEME_GERRIT, "janedoe")).isEqualTo(accountId.get());
+
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "JaneDoe")).isEqualTo(accountId.get());
+      assertThat(getAccountId(extIdNotes, SCHEME_USERNAME, "janedoe")).isEqualTo(accountId.get());
+    }
+  }
+
+  protected int getAccountId(ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return getExternalId(extIdNotes, scheme, id).get().accountId().get();
+  }
+
+  protected Optional<ExternalId> getExternalId(ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return extIdNotes.get(externalIdKeyFactory.create(scheme, id));
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  public void createCaseSensitiveExternalId_SchemeWithoutUsername() throws Exception {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      testCaseSensitiveExternalIdKey(md, extIdNotes, SCHEME_MAILTO, "Jane@doe.com", Account.id(66));
+      testCaseSensitiveExternalIdKey(md, extIdNotes, SCHEME_UUID, "1234ABCD", Account.id(66));
+      testCaseSensitiveExternalIdKey(md, extIdNotes, SCHEME_GPGKEY, "1234ABCD", Account.id(66));
+    }
+  }
+
+  private void testCaseSensitiveExternalIdKey(
+      MetaDataUpdate md, ExternalIdNotes extIdNotes, String scheme, String id, Account.Id accountId)
+      throws DuplicateExternalIdKeyException, IOException, ConfigInvalidException {
+    ExternalId extId = externalIdFactory.create(scheme, id, accountId);
+    extIdNotes.insert(extId);
+    extIdNotes.commit(md);
+    assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
+    assertThat(getExternalId(extIdNotes, scheme, id.toLowerCase()).isPresent()).isFalse();
+  }
+
+  private void testCaseInsensitiveExternalIdKey(
+      MetaDataUpdate md, ExternalIdNotes extIdNotes, String scheme, String id, Account.Id accountId)
+      throws DuplicateExternalIdKeyException, IOException, ConfigInvalidException {
+    ExternalId extId = externalIdFactory.create(scheme, id, accountId);
+    extIdNotes.insert(extId);
+    extIdNotes.commit(md);
+    assertThat(getAccountId(extIdNotes, scheme, id)).isEqualTo(accountId.get());
+    assertThat(getAccountId(extIdNotes, scheme, id.toLowerCase())).isEqualTo(accountId.get());
+  }
+
+  /**
+   * Create external id object
+   *
+   * <p>This method skips gerrit.config auth.userNameCaseInsensitiveMigrationMode and allow to
+   * create case sensitive/insensitive external id
+   */
+  protected void createExternalId(
+      MetaDataUpdate md,
+      ExternalIdNotes extIdNotes,
+      String scheme,
+      String id,
+      Account.Id accountId,
+      boolean isUserNameCaseInsensitive)
+      throws IOException {
+    ExternalId extId =
+        externalIdFactory.create(
+            externalIdKeyFactory.create(scheme, id, isUserNameCaseInsensitive), accountId);
+    extIdNotes.insert(extId);
+    extIdNotes.commit(md);
+  }
+
   private boolean isPartialCacheReloadingEnabled() {
     return cfg.getBoolean("cache", "external_ids_map", "enablePartialReloads", true);
   }
@@ -882,14 +1050,17 @@
       ExternalIdNotes extIdNotes = externalIdNotesFactory.load(repo);
       extIdNotes.insert(extId);
       extIdNotes.commit(update);
-      extIdNotes.updateCaches();
+      externalIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          extIdNotes, ImmutableList.of());
     }
   }
 
   private void insertExtIdBehindGerritsBack(ExternalId extId) throws Exception {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       // Inserting an external ID "behind Gerrit's back" means that the caches are not updated.
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(allUsers, repo);
+      ExternalIdNotes extIdNotes =
+          ExternalIdNotes.loadNoCacheUpdate(
+              allUsers, repo, externalIdFactory, IS_USER_NAME_CASE_INSENSITIVE_MIGRATION_MODE);
       extIdNotes.insert(extId);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
@@ -909,14 +1080,11 @@
       metaDataUpdate.getCommitBuilder().setAuthor(admin.newIdent());
       metaDataUpdate.getCommitBuilder().setCommitter(admin.newIdent());
       extIdNotes.commit(metaDataUpdate);
-      extIdNotes.updateCaches();
+      externalIdNotesFactory.updateExternalIdCacheAndMaybeReindexAccounts(
+          extIdNotes, ImmutableList.of());
     }
   }
 
-  private List<String> getFooters(RevCommit c) {
-    return c.getFooterLines().stream().map(FooterLine::toString).collect(toList());
-  }
-
   private List<AccountExternalIdInfo> toExternalIdInfos(Collection<ExternalId> extIds) {
     return extIds.stream().map(this::toExternalIdInfo).collect(toList());
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
index b999abd..a1e9bf1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/GetAccountDetailIT.java
@@ -17,22 +17,42 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.rest.account.AccountAssert.assertAccountInfo;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.group.GroupOperations;
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.common.AccountDetailInfo;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.account.AccountTagProvider;
 import com.google.gerrit.server.account.ServiceUserClassifier;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
+import com.google.inject.Module;
+import java.util.List;
 import org.junit.Test;
 
 public class GetAccountDetailIT extends AbstractDaemonTest {
   @Inject private GroupOperations groupOperations;
   @Inject private AccountOperations accountOperations;
 
+  @Override
+  public Module createModule() {
+    return new FactoryModule() {
+      @Override
+      public void configure() {
+        bind(AccountTagProvider.class)
+            .annotatedWith(Exports.named("CustomAccountTagProvider"))
+            .to(CustomAccountTagProvider.class);
+      }
+    };
+  }
+
   @Test
   public void getDetail() throws Exception {
     RestResponse r = adminRestSession.get("/accounts/" + admin.username() + "/detail/");
@@ -56,6 +76,51 @@
         .update();
     RestResponse r = adminRestSession.get("/accounts/" + serviceUser.get() + "/detail/");
     AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
-    assertThat(info.tags).containsExactly(AccountInfo.Tag.SERVICE_USER);
+    assertThat(info.tags).containsExactly(AccountInfo.Tags.SERVICE_USER);
+  }
+
+  @Test
+  public void getDetailForExtensionPointAccountTag() throws Exception {
+    RestResponse r = userRestSession.get("/accounts/" + user.username() + "/detail/");
+    AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+    assertThat(info.tags).containsExactly("BASIC_USER");
+  }
+
+  @Test
+  public void searchForSecondaryEmailRequiresModifyAccountPermission() throws Exception {
+    Account.Id id =
+        accountOperations
+            .newAccount()
+            .preferredEmail("preferred@email")
+            .addSecondaryEmail("secondary@email")
+            .create();
+    RestResponse r = userRestSession.get("/accounts/secondary/detail/");
+    r.assertStatus(404);
+    // The admin has MODIFY_ACCOUNT permission and can see the user.
+    r = adminRestSession.get("/accounts/secondary/detail/");
+    r.assertStatus(200);
+    AccountDetailInfo info = newGson().fromJson(r.getReader(), AccountDetailInfo.class);
+    assertThat(info._accountId).isEqualTo(id.get());
+  }
+
+  private static class CustomAccountTagProvider implements AccountTagProvider {
+    private PermissionBackend permissions;
+
+    @Inject
+    public CustomAccountTagProvider(PermissionBackend permissions) {
+      this.permissions = permissions;
+    }
+
+    @Override
+    public List<String> getTags(Account.Id id) {
+      try {
+        if (!permissions.currentUser().test(GlobalPermission.ADMINISTRATE_SERVER)) {
+          return ImmutableList.of("BASIC_USER");
+        }
+      } catch (Exception e) {
+        throw new IllegalStateException("can't check admin permissions", e);
+      }
+      return ImmutableList.of();
+    }
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index bf8de93..4da4da4 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -63,10 +63,10 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.project.testing.TestLabels;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
@@ -436,7 +436,7 @@
   @Test
   public void runAsValidUser() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id()));
+    RestResponse res = adminRestSession.getWithHeaders("/accounts/self", runAsHeader(user.id()));
     res.assertOK();
     AccountInfo account = newGson().fromJson(res.getEntityContent(), AccountInfo.class);
     assertThat(account._accountId).isEqualTo(user.id().get());
@@ -446,7 +446,7 @@
   @Test
   public void runAsDisabledByConfig() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
+    RestResponse res = adminRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
@@ -454,7 +454,7 @@
 
   @Test
   public void runAsNotPermitted() throws Exception {
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
+    RestResponse res = adminRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
@@ -462,7 +462,7 @@
   @Test
   public void runAsNeverPermittedForAnonymousUsers() throws Exception {
     allowRunAs();
-    RestResponse res = anonRestSession.getWithHeader("/changes/", runAsHeader(user.id()));
+    RestResponse res = anonRestSession.getWithHeaders("/changes/", runAsHeader(user.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("not permitted to use X-Gerrit-RunAs");
   }
@@ -470,7 +470,7 @@
   @Test
   public void runAsInvalidUser() throws Exception {
     allowRunAs();
-    RestResponse res = adminRestSession.getWithHeader("/changes/", runAsHeader("doesnotexist"));
+    RestResponse res = adminRestSession.getWithHeaders("/changes/", runAsHeader("doesnotexist"));
     res.assertForbidden();
     assertThat(res.getEntityContent()).isEqualTo("no account matches X-Gerrit-RunAs");
   }
@@ -496,10 +496,10 @@
     in.message = "message";
     in.drafts = DraftHandling.PUBLISH;
     RestResponse res =
-        adminRestSession.postWithHeader(
+        adminRestSession.postWithHeaders(
             "/changes/" + r.getChangeId() + "/revisions/current/review",
-            runAsHeader(user.id()),
-            in);
+            in,
+            runAsHeader(user.id()));
     res.assertOK();
 
     ChangeMessageInfo m = Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages);
@@ -532,13 +532,13 @@
     in.message = "Message on behalf of";
 
     String endpoint = "/changes/" + r.getChangeId() + "/revisions/current/review";
-    RestResponse res = adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in);
+    RestResponse res = adminRestSession.postWithHeaders(endpoint, in, runAsHeader(user2.id()));
     res.assertForbidden();
     assertThat(res.getEntityContent())
         .isEqualTo("label required to post review on behalf of \"" + in.onBehalfOf + '"');
 
     in.label("Code-Review", 1);
-    adminRestSession.postWithHeader(endpoint, runAsHeader(user2.id()), in).assertOK();
+    adminRestSession.postWithHeaders(endpoint, in, runAsHeader(user2.id())).assertOK();
 
     PatchSetApproval psa = Iterables.getOnlyElement(r.getChange().approvals().values());
     assertThat(psa.patchSetId().get()).isEqualTo(1);
diff --git a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
index e05d0db..f46cf0c 100644
--- a/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/account/PutUsernameIT.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.extensions.api.accounts.UsernameInput;
 import org.junit.Test;
 
@@ -42,6 +43,16 @@
   }
 
   @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  public void setExistingCaseInsensitive_Conflict() throws Exception {
+    UsernameInput in = new UsernameInput();
+    in.username = admin.username().toUpperCase();
+    adminRestSession
+        .put("/accounts/" + accountCreator.create().id().get() + "/username", in)
+        .assertConflict();
+  }
+
+  @Test
   public void setNew_MethodNotAllowed() throws Exception {
     UsernameInput in = new UsernameInput();
     in.username = "newUsername";
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
index eb125a0..13353bd 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/AccountsRestApiBindingsIT.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.gpg.testing.TestKey;
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountsUpdate;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.junit.Test;
@@ -43,6 +43,7 @@
 public class AccountsRestApiBindingsIT extends AbstractDaemonTest {
   @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExternalIdFactory externalIdFactory;
 
   /**
    * Account REST endpoints to be tested, each URL contains a placeholder for the account
@@ -86,7 +87,6 @@
           RestCall.get("/accounts/%s/preferences.edit"),
           RestCall.put("/accounts/%s/preferences.edit"),
           RestCall.get("/accounts/%s/starred.changes"),
-          RestCall.get("/accounts/%s/stars.changes"),
           RestCall.post("/accounts/%s/index"),
           RestCall.get("/accounts/%s/agreements"),
           RestCall.put("/accounts/%s/agreements"),
@@ -141,9 +141,7 @@
   private static final ImmutableList<RestCall> STAR_ENDPOINTS =
       ImmutableList.of(
           RestCall.put("/accounts/%s/starred.changes/%s"),
-          RestCall.delete("/accounts/%s/starred.changes/%s"),
-          RestCall.get("/accounts/%s/stars.changes/%s"),
-          RestCall.post("/accounts/%s/stars.changes/%s"));
+          RestCall.delete("/accounts/%s/starred.changes/%s"));
 
   @Test
   public void accountEndpoints() throws Exception {
@@ -169,7 +167,7 @@
             admin.id(),
             u ->
                 u.addExternalId(
-                    ExternalId.createWithEmail(name("test"), email, admin.id(), email)));
+                    externalIdFactory.createWithEmail(name("test"), email, admin.id(), email)));
 
     requestScopeOperations.setApiUser(admin.id());
     gApi.accounts()
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
index 2e702c10..79484ca 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/ChangesRestApiBindingsIT.java
@@ -25,11 +25,11 @@
 import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
 import com.google.gerrit.acceptance.rest.util.RestCall;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.Comment;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -76,8 +76,6 @@
           RestCall.post("/changes/%s/ready"),
           RestCall.put("/changes/%s/ignore"),
           RestCall.put("/changes/%s/unignore"),
-          RestCall.put("/changes/%s/reviewed"),
-          RestCall.put("/changes/%s/unreviewed"),
           RestCall.get("/changes/%s/messages"),
           RestCall.put("/changes/%s/message"),
           RestCall.post("/changes/%s/merge"),
@@ -289,15 +287,15 @@
   public void reviewerEndpoints() throws Exception {
     String changeId = createChange().getChangeId();
 
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
 
     RestApiCallHelper.execute(
         adminRestSession,
         REVIEWER_ENDPOINTS,
-        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        () -> gApi.changes().id(changeId).addReviewer(reviewerInput),
         changeId,
-        addReviewerInput.reviewer);
+        reviewerInput.reviewer);
   }
 
   @Test
@@ -323,16 +321,16 @@
   public void revisionReviewerEndpoints() throws Exception {
     String changeId = createChange().getChangeId();
 
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.reviewer = user.email();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user.email();
 
     RestApiCallHelper.execute(
         adminRestSession,
         REVISION_REVIEWER_ENDPOINTS,
-        () -> gApi.changes().id(changeId).addReviewer(addReviewerInput),
+        () -> gApi.changes().id(changeId).addReviewer(reviewerInput),
         changeId,
         "current",
-        addReviewerInput.reviewer);
+        reviewerInput.reviewer);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
index 23a1d23..d2c1430 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedChildRestApiBindingsIT.java
@@ -56,8 +56,7 @@
   @Inject private TestCommentHelper testCommentHelper;
 
   /** Resource to bind a child collection. */
-  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
-      new TypeLiteral<RestView<TestPluginResource>>() {};
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND = new TypeLiteral<>() {};
 
   private static final String PLUGIN_NAME = "my-plugin";
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
index 16dc294..24ce605 100644
--- a/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/binding/PluginProvidedRootRestApiBindingsIT.java
@@ -60,8 +60,7 @@
 public class PluginProvidedRootRestApiBindingsIT extends AbstractDaemonTest {
 
   /** Resource to bind a child collection. */
-  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND =
-      new TypeLiteral<RestView<TestPluginResource>>() {};
+  public static final TypeLiteral<RestView<TestPluginResource>> TEST_KIND = new TypeLiteral<>() {};
 
   private static final String PLUGIN_NAME = "my-plugin";
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
index 796ce38..d967f48 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AbstractSubmit.java
@@ -85,8 +85,8 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.approval.ApprovalsUtil;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.change.TestSubmitInput;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
index 36cd3cb..be94cdf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AssigneeIT.java
@@ -147,7 +147,7 @@
                 + "' only matches inactive accounts. To use an inactive account, retry with one"
                 + " of the following exact account IDs:\n"
                 + user.id()
-                + ": User <user@example.com>");
+                + ": User1 <user1@example.com>");
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
index efd27d0..576b5512 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/AttentionSetIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.change;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
 import static com.google.gerrit.extensions.restapi.testing.AttentionSetUpdateSubject.assertThat;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -28,31 +29,41 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.acceptance.UseClockStep;
+import com.google.gerrit.acceptance.VerifyNoPiiInChangeNotes;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 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.AccountGroup;
 import com.google.gerrit.entities.AttentionSetUpdate;
 import com.google.gerrit.entities.AttentionSetUpdate.Operation;
 import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.Patch;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AttentionSetInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.server.account.ServiceUserClassifier;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.restapi.change.GetAttentionSet;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
-import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import com.google.inject.Inject;
@@ -70,15 +81,17 @@
 
 @NoHttpd
 @UseClockStep(clockStepUnit = TimeUnit.MINUTES)
+@VerifyNoPiiInChangeNotes(true)
 public class AttentionSetIT extends AbstractDaemonTest {
 
   @Inject private ChangeOperations changeOperations;
   @Inject private AccountOperations accountOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
 
-  @Inject private FakeEmailSender email;
   @Inject private TestCommentHelper testCommentHelper;
   @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private GetAttentionSet getAttentionSet;
 
   /** Simulates a fake clock. Uses second granularity. */
   private static class FakeClock implements LongSupplier {
@@ -130,7 +143,7 @@
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Only one email since the second add was ignored.
-    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
     assertThat(emailBody)
         .contains(
             String.format(
@@ -139,6 +152,77 @@
   }
 
   @Test
+  public void addUserWithTemplateReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    String manualReason = "Added by " + AccountTemplateUtil.getAccountTemplate(user.id());
+    int accountId =
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), manualReason))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, manualReason);
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+    AttentionSetInfo attentionSetInfo =
+        Iterables.getOnlyElement(change(r).get().attentionSet.values());
+    assertThat(attentionSetInfo.reason).isEqualTo(manualReason);
+    assertThat(attentionSetInfo.reasonAccount).isEqualTo(getAccountInfo(user.id()));
+    assertThat(attentionSetInfo.account).isEqualTo(getAccountInfo(admin.id()));
+
+    AttentionSetInfo getAttentionSetInfo =
+        Iterables.getOnlyElement(
+            getAttentionSet.apply(parseChangeResource(r.getChangeId())).value());
+    assertThat(getAttentionSetInfo.reason).isEqualTo(manualReason);
+    assertThat(getAttentionSetInfo.reasonAccount).isEqualTo(getAccountInfo(user.id()));
+    assertThat(getAttentionSetInfo.account).isEqualTo(getAccountInfo(admin.id()));
+  }
+
+  @Test
+  public void addUserWithTemplateReasonMultipleAccounts() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    String manualReason =
+        String.format(
+            "Added by %s with user %s",
+            AccountTemplateUtil.getAccountTemplate(user.id()),
+            AccountTemplateUtil.getAccountTemplate(admin.id()));
+    int accountId =
+        change(r).addToAttentionSet(new AttentionSetInput(admin.email(), manualReason))._accountId;
+    assertThat(accountId).isEqualTo(admin.id().get());
+    AttentionSetUpdate expectedAttentionSetUpdate =
+        AttentionSetUpdate.createFromRead(
+            fakeClock.now(), admin.id(), AttentionSetUpdate.Operation.ADD, manualReason);
+    assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
+    AttentionSetInfo attentionSetInfo =
+        Iterables.getOnlyElement(change(r).get().attentionSet.values());
+    assertThat(attentionSetInfo.reason).isEqualTo(manualReason);
+    assertThat(attentionSetInfo.reasonAccount).isNull();
+    assertThat(attentionSetInfo.account).isEqualTo(getAccountInfo(admin.id()));
+
+    AttentionSetInfo getAttentionSetInfo =
+        Iterables.getOnlyElement(
+            getAttentionSet.apply(parseChangeResource(r.getChangeId())).value());
+    assertThat(getAttentionSetInfo.reason).isEqualTo(manualReason);
+    assertThat(getAttentionSetInfo.reasonAccount).isNull();
+    assertThat(getAttentionSetInfo.account).isEqualTo(getAccountInfo(admin.id()));
+  }
+
+  @Test
+  public void reviewWithManuallyAddedUserAndTemplateReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    String manualReason = "Review by " + AccountTemplateUtil.getAccountTemplate(user.id());
+    ReviewInput reviewInput =
+        ReviewInput.create().addUserToAttentionSet(user.email(), manualReason);
+
+    change(r).current().review(reviewInput);
+    AttentionSetInfo attentionSetInfo = change(r).get().attentionSet.get(user.id().get());
+    assertThat(attentionSetInfo.reason).isEqualTo(manualReason);
+    assertThat(attentionSetInfo.reasonAccount).isEqualTo(getAccountInfo(user.id()));
+    assertThat(attentionSetInfo.account).isEqualTo(getAccountInfo(user.id()));
+  }
+
+  @Test
   public void addMultipleUsers() throws Exception {
     PushOneCommit.Result r = createChange();
     Instant timestamp1 = fakeClock.now();
@@ -183,7 +267,7 @@
     assertThat(r.getChange().attentionSet()).containsExactly(expectedAttentionSetUpdate);
 
     // Only one email since the second remove was ignored.
-    String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
+    String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
     assertThat(emailBody)
         .contains(
             user.fullName()
@@ -351,7 +435,7 @@
     PushOneCommit.Result r = createChange();
 
     // Add cc
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.CC;
     change(r).addReviewer(input);
@@ -403,10 +487,10 @@
   public void addingReviewerWhileMarkingWorkInProgressDoesntAddToAttentionSet() throws Exception {
     PushOneCommit.Result r = createChange();
     ReviewInput reviewInput = ReviewInput.create().setWorkInProgress(true);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.REVIEWER;
-    addReviewerInput.reviewer = user.email();
-    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.REVIEWER;
+    reviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
 
     change(r).current().review(reviewInput);
     assertThat(getAttentionSetUpdatesForUser(r, user)).isEmpty();
@@ -434,11 +518,11 @@
   @Test
   public void ccsAreIgnored() throws Exception {
     PushOneCommit.Result r = createChange();
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.CC;
-    addReviewerInput.reviewer = user.email();
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.reviewer = user.email();
 
-    change(r).addReviewer(addReviewerInput);
+    change(r).addReviewer(reviewerInput);
 
     assertThat(r.getChange().attentionSet()).isEmpty();
   }
@@ -448,10 +532,10 @@
     PushOneCommit.Result r = createChange();
     change(r).addReviewer(user.email());
 
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.CC;
-    addReviewerInput.reviewer = user.email();
-    change(r).addReviewer(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.reviewer = user.email();
+    change(r).addReviewer(reviewerInput);
 
     AttentionSetUpdate attentionSet = Iterables.getOnlyElement(r.getChange().attentionSet());
     assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
@@ -557,10 +641,10 @@
     change(r).addReviewer(user.email());
 
     ReviewInput reviewInput = ReviewInput.create().setReady(true);
-    AddReviewerInput addReviewerInput = new AddReviewerInput();
-    addReviewerInput.state = ReviewerState.CC;
-    addReviewerInput.reviewer = user.email();
-    reviewInput.reviewers = ImmutableList.of(addReviewerInput);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.state = ReviewerState.CC;
+    reviewerInput.reviewer = user.email();
+    reviewInput.reviewers = ImmutableList.of(reviewerInput);
     change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason"));
     change(r).current().review(reviewInput);
 
@@ -619,7 +703,7 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
 
     // No emails for adding to attention set were sent.
-    email.getMessages().isEmpty();
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   @Test
@@ -628,6 +712,7 @@
     // implictly adds the user to the attention set when adding as reviewer
     change(r).addReviewer(user.email());
     requestScopeOperations.setApiUser(user.id());
+    sender.clear();
 
     ReviewInput reviewInput =
         ReviewInput.create().removeUserFromAttentionSet(user.email(), "reason");
@@ -640,7 +725,7 @@
     assertThat(attentionSet).hasReasonThat().isEqualTo("reason");
 
     // No emails for removing from attention set were sent.
-    email.getMessages().isEmpty();
+    assertThat(sender.getMessages()).isEmpty();
   }
 
   @Test
@@ -702,7 +787,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -719,7 +804,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -756,7 +841,7 @@
 
     assertThat(exception.getMessage())
         .isEqualTo(
-            "user can not be added/removed twice, and can not be added and removed at the same"
+            "user1 can not be added/removed twice, and can not be added and removed at the same"
                 + " time");
   }
 
@@ -1356,6 +1441,21 @@
   }
 
   @Test
+  public void robotReviewWithNegativeLabelDoesNotAddOwnerOnWorkInProgressChanges()
+      throws Exception {
+    TestAccount robot =
+        accountCreator.create(
+            "robot2", "robot2@example.com", "Ro Bot", "Ro", ServiceUserClassifier.SERVICE_USERS);
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).setWorkInProgress();
+
+    requestScopeOperations.setApiUser(robot.id());
+    change(r).current().review(ReviewInput.dislike());
+
+    assertThat(r.getChange().attentionSet()).isEmpty();
+  }
+
+  @Test
   public void robotReviewWithNegativeLabelAddsOwner() throws Exception {
     TestAccount robot =
         accountCreator.create(
@@ -1434,6 +1534,45 @@
   }
 
   @Test
+  public void addToAttentionSetEmail_withTemplateReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    requestScopeOperations.setApiUser(user.id());
+    String templateReason = "Added by " + AccountTemplateUtil.getAccountTemplate(user.id());
+    int accountId =
+        change(r)
+            .addToAttentionSet(new AttentionSetInput(admin.email(), templateReason))
+            ._accountId;
+
+    assertThat(accountId).isEqualTo(admin.id().get());
+    String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "%s requires the attention of %s to this change.\n The reason is: Added by %s.",
+                user.fullName(), admin.fullName(), user.getNameEmail()));
+  }
+
+  @Test
+  public void removeFromAttentionSetEmail_withTemplateReason() throws Exception {
+    PushOneCommit.Result r = createChange();
+    // implicitly adds the user to the attention set when adding as reviewer
+    change(r).addReviewer(user.email());
+    sender.clear();
+    requestScopeOperations.setApiUser(user.id());
+
+    String templateReason = "Removed by " + AccountTemplateUtil.getAccountTemplate(user.id());
+    change(r).attention(user.id().toString()).remove(new AttentionSetInput(templateReason));
+
+    String emailBody = Iterables.getOnlyElement(sender.getMessages()).body();
+    assertThat(emailBody)
+        .contains(
+            String.format(
+                "%s removed themselves from the attention set of this change.\n"
+                    + " The reason is: Removed by %s.",
+                user.fullName(), user.getNameEmail()));
+  }
+
+  @Test
   public void attentionSetEmailFooter() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1732,6 +1871,30 @@
   }
 
   @Test
+  public void usersNotPartOfTheChangeAreNeverInTheAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    AttentionSetUpdate attentionSetUpdate =
+        Iterables.getOnlyElement(getAttentionSetUpdates(r.getChange().getId()));
+    assertThat(attentionSetUpdate).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSetUpdate).hasOperationThat().isEqualTo(Operation.ADD);
+
+    ReviewInput reviewInput = ReviewInput.create();
+    reviewInput.reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true);
+    reviewInput.ignoreAutomaticAttentionSetRules = true;
+    change(r).current().review(reviewInput);
+
+    // user removed from the attention set although we ignored automatic attention set rules.
+    attentionSetUpdate = Iterables.getOnlyElement(getAttentionSetUpdates(r.getChange().getId()));
+    assertThat(attentionSetUpdate).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSetUpdate).hasOperationThat().isEqualTo(Operation.REMOVE);
+    assertThat(attentionSetUpdate)
+        .hasReasonThat()
+        .isEqualTo("Only change owner, uploader, reviewers, and cc can be in the attention set");
+  }
+
+  @Test
   @GerritConfig(name = "accounts.visibility", value = "NONE")
   public void canModifyAttentionSetForInvisibleUsersOnVisibleChanges() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -1751,6 +1914,78 @@
   }
 
   @Test
+  public void deleteSelfVotesDoesNotAddToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+    approve(r.getChangeId());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(admin.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+
+    assertThat(getAttentionSetUpdates(r.getChange().getId())).isEmpty();
+  }
+
+  @Test
+  public void deleteVotesOfOthersAddThemToAttentionSet() throws Exception {
+    PushOneCommit.Result r = createChange();
+
+    requestScopeOperations.setApiUser(user.id());
+    recommend(r.getChangeId());
+
+    requestScopeOperations.setApiUser(admin.id());
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .reviewer(user.id().toString())
+        .deleteVote(LabelId.CODE_REVIEW);
+
+    AttentionSetUpdate attentionSet =
+        Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user));
+    assertThat(attentionSet).hasAccountIdThat().isEqualTo(user.id());
+    assertThat(attentionSet).hasOperationThat().isEqualTo(AttentionSetUpdate.Operation.ADD);
+    assertThat(attentionSet).hasReasonThat().isEqualTo("Their vote was deleted");
+  }
+
+  @Test
+  public void accountsWithNoReadPermissionIgnoredOnReply() throws Exception {
+    // Create a group with user.
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name("User");
+    groupInput.members = ImmutableList.of(String.valueOf(user.id()));
+    GroupInfo group = gApi.groups().create(groupInput).get();
+
+    PushOneCommit.Result r = createChange();
+    gApi.changes().id(r.getChangeId()).addReviewer(user.email());
+
+    // remove read permission for user.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(AccountGroup.uuid(group.id)))
+        .update();
+
+    // removing user without permissions from attention set is allowed on reply.
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().removeUserFromAttentionSet(user.email(), "reason"));
+
+    // Add user to attention throws an exception.
+    assertThrows(
+        AuthException.class,
+        () -> change(r).addToAttentionSet(new AttentionSetInput(user.email(), "reason")));
+
+    // Add user to attention set is ignored on reply.
+    gApi.changes()
+        .id(r.getChangeId())
+        .current()
+        .review(new ReviewInput().addUserToAttentionSet(user.email(), "reason"));
+    assertThat(Iterables.getOnlyElement(getAttentionSetUpdatesForUser(r, user)).operation())
+        .isEqualTo(Operation.REMOVE);
+  }
+
+  @Test
   public void outsideAttentionSet_watchProjectEmailReceived() throws Exception {
     setEmailStrategyForUser(EmailStrategy.ATTENTION_SET_ONLY);
 
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
index ad3a3c1..a6f3917 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeMessagesIT.java
@@ -11,6 +11,7 @@
 // WITHOUT 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;
@@ -18,15 +19,17 @@
 import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_ACCOUNTS;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.parseCommitMessageRange;
-import static com.google.gerrit.server.restapi.change.DeleteChangeMessage.createNewChangeMessage;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.util.RawParseUtils.decode;
 
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -46,9 +49,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.server.notedb.ChangeNoteUtil;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.inject.Inject;
 import java.nio.charset.Charset;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Optional;
@@ -80,6 +85,28 @@
   }
 
   @Test
+  public void messageWithAccountTemplate() throws Exception {
+    String changeId = createChange().getChangeId();
+    String messageTemplate =
+        String.format(
+            "Review added by %s: some nits need to be fixed by %s.",
+            AccountTemplateUtil.getAccountTemplate(admin.id()),
+            AccountTemplateUtil.getAccountTemplate(user.id()));
+    postMessage(changeId, messageTemplate);
+    ChangeInfo c = get(changeId, MESSAGES, DETAILED_ACCOUNTS);
+    assertThat(c.messages).isNotNull();
+    assertThat(c.messages).hasSize(2);
+    Iterator<ChangeMessageInfo> it = c.messages.iterator();
+    ChangeMessageInfo defaultMessage = it.next();
+    assertThat(defaultMessage.message).isEqualTo("Uploaded patch set 1.");
+    assertThat(defaultMessage.accountsInMessage).isEmpty();
+    ChangeMessageInfo messageWithTemplate = it.next();
+    assertMessage(messageTemplate, messageWithTemplate.message);
+    assertThat(messageWithTemplate.accountsInMessage)
+        .containsExactly(getAccountInfo(admin.id()), getAccountInfo(user.id()));
+  }
+
+  @Test
   public void messagesReturnedInChronologicalOrder() throws Exception {
     String changeId = createChange().getChangeId();
     String firstMessage = "Some nits need to be fixed.";
@@ -145,6 +172,29 @@
   }
 
   @Test
+  public void getChangeMessagesWithTemplate() throws Exception {
+    String changeId = createChange().getChangeId();
+    String messageTemplate = "Review by " + AccountTemplateUtil.getAccountTemplate(admin.id());
+    postMessage(changeId, messageTemplate);
+    assertMessage(
+        messageTemplate,
+        Iterables.getLast(gApi.changes().id(changeId).get(MESSAGES).messages).message);
+
+    Collection<ChangeMessageInfo> listMessages = gApi.changes().id(changeId).messages();
+    assertThat(listMessages).hasSize(2);
+    ChangeMessageInfo changeMessageApi = Iterables.getLast(gApi.changes().id(changeId).messages());
+    assertMessage("Review by " + admin.getNameEmail(), changeMessageApi.message);
+    assertMessage(
+        "Review by " + admin.getNameEmail(),
+        gApi.changes().id(changeId).message(changeMessageApi.id).get().message);
+    DeleteChangeMessageInput input = new DeleteChangeMessageInput("message deleted");
+    assertThat(gApi.changes().id(changeId).message(changeMessageApi.id).delete(input).message)
+        .isEqualTo(
+            String.format(
+                "Change message removed by: %s\nReason: message deleted", admin.getNameEmail()));
+  }
+
+  @Test
   public void deleteCannotBeAppliedWithoutAdministrateServerCapability() throws Exception {
     int changeNum = createOneChangeWithMultipleChangeMessagesInHistory();
     requestScopeOperations.setApiUser(user.id());
@@ -278,10 +328,14 @@
     ChangeMessageInfo info = gApi.changes().id(changeNum).message(id).delete(input);
 
     // Verify the return change message info is as expect.
-    assertThat(info.message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), reason));
+    String expectedMessage = "Change message removed by: " + deletedBy.getNameEmail();
+    if (!Strings.isNullOrEmpty(reason)) {
+      expectedMessage = expectedMessage + "\nReason: " + reason;
+    }
+    assertThat(info.message).isEqualTo(expectedMessage);
     List<ChangeMessageInfo> messagesAfterDeletion = gApi.changes().id(changeNum).messages();
     assertMessagesAfterDeletion(
-        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, deletedBy, reason);
+        messagesBeforeDeletion, messagesAfterDeletion, deletedMessageIndex, expectedMessage);
     assertCommentsAfterDeletion(changeNum, commentsBefore);
 
     // Verify change index is updated after deletion.
@@ -296,8 +350,7 @@
       List<ChangeMessageInfo> messagesBeforeDeletion,
       List<ChangeMessageInfo> messagesAfterDeletion,
       int deletedMessageIndex,
-      TestAccount deletedBy,
-      String deleteReason) {
+      String expectedDeleteMessage) {
     assertWithMessage("after: %s; before: %s", messagesAfterDeletion, messagesBeforeDeletion)
         .that(messagesAfterDeletion)
         .hasSize(messagesBeforeDeletion.size());
@@ -317,8 +370,7 @@
       assertThat(after._revisionNumber).isEqualTo(before._revisionNumber);
 
       if (i == deletedMessageIndex) {
-        assertThat(after.message)
-            .isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+        assertThat(after.message).isEqualTo(expectedDeleteMessage);
       } else {
         assertThat(after.message).isEqualTo(before.message);
       }
@@ -381,7 +433,12 @@
                 rawAfter,
                 rangeAfter.get().changeMessageStart(),
                 rangeAfter.get().changeMessageEnd() + 1);
-        assertThat(message).isEqualTo(createNewChangeMessage(deletedBy.fullName(), deleteReason));
+        String expectedMessageTemplate =
+            "Change message removed by: " + AccountTemplateUtil.getAccountTemplate(deletedBy.id());
+        if (!Strings.isNullOrEmpty(deleteReason)) {
+          expectedMessageTemplate = expectedMessageTemplate + "\nReason: " + deleteReason;
+        }
+        assertThat(message).isEqualTo(expectedMessageTemplate);
       } else {
         assertThat(commitAfter.getFullMessage()).isEqualTo(commitBefore.getFullMessage());
       }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
index 6bffdf7..6a748a5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeOwnerIT.java
@@ -51,20 +51,20 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_OwnerACLNotGranted() throws Exception {
     assertApproveFails(user, createMyChange(testRepo));
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_OwnerACLGranted() throws Exception {
     grantApproveToChangeOwner(project);
     approve(user, createMyChange(testRepo));
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void testChangeOwner_NotOwnerACLGranted() throws Exception {
     grantApproveToChangeOwner(project);
     assertApproveFails(user2, createMyChange(testRepo));
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
index 012e98d..88e5f10 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersByEmailIT.java
@@ -25,10 +25,10 @@
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.Address;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ReviewerState;
@@ -59,7 +59,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -79,12 +79,12 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput inputByEmail = new AddReviewerInput();
+      ReviewerInput inputByEmail = new ReviewerInput();
       inputByEmail.reviewer = toRfcAddressString(byEmail);
       inputByEmail.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(inputByEmail);
 
-      AddReviewerInput inputById = new AddReviewerInput();
+      ReviewerInput inputById = new ReviewerInput();
       inputById.reviewer = user.email();
       inputById.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(inputById);
@@ -103,7 +103,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -130,7 +130,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput addInput = new AddReviewerInput();
+      ReviewerInput addInput = new ReviewerInput();
       addInput.reviewer = toRfcAddressString(acc);
       addInput.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(addInput);
@@ -148,12 +148,12 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput addInput = new AddReviewerInput();
+    ReviewerInput addInput = new ReviewerInput();
     addInput.reviewer = toRfcAddressString(acc);
     addInput.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(addInput);
 
-    AddReviewerInput modifyInput = new AddReviewerInput();
+    ReviewerInput modifyInput = new ReviewerInput();
     modifyInput.reviewer = addInput.reviewer;
     modifyInput.state = ReviewerState.REVIEWER;
     gApi.changes().id(r.getChangeId()).addReviewer(modifyInput);
@@ -170,7 +170,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -189,7 +189,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput addInput = new AddReviewerInput();
+      ReviewerInput addInput = new ReviewerInput();
       addInput.reviewer = toRfcAddressString(acc);
       addInput.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(addInput);
@@ -221,7 +221,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -241,7 +241,7 @@
     PushOneCommit.Result r = createChange();
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       for (int i = 0; i < 10; i++) {
-        AddReviewerInput input = new AddReviewerInput();
+        ReviewerInput input = new ReviewerInput();
         input.reviewer = String.format("%s-%s@gerritcodereview.com", state, i);
         input.state = state;
         gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -249,7 +249,7 @@
     }
 
     // Also add user as a regular reviewer
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.REVIEWER;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -280,7 +280,7 @@
   public void rejectMissingEmail() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("");
     assertThat(result.error).isEqualTo(" is not a valid user identifier");
     assertThat(result.reviewers).isNull();
   }
@@ -289,7 +289,7 @@
   public void rejectMalformedEmail() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@");
     assertThat(result.error).isEqualTo("Foo Bar <foo.bar@ is not a valid user identifier");
     assertThat(result.reviewers).isNull();
   }
@@ -302,7 +302,7 @@
 
     PushOneCommit.Result r = createChange();
 
-    AddReviewerResult result =
+    ReviewerResult result =
         gApi.changes().id(r.getChangeId()).addReviewer("Foo Bar <foo.bar@gerritcodereview.com>");
     assertThat(result.error)
         .isEqualTo(
@@ -319,7 +319,7 @@
     for (ReviewerState state : ImmutableList.of(ReviewerState.CC, ReviewerState.REVIEWER)) {
       PushOneCommit.Result r = createChange();
 
-      AddReviewerInput input = new AddReviewerInput();
+      ReviewerInput input = new ReviewerInput();
       input.reviewer = toRfcAddressString(acc);
       input.state = state;
       gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -337,11 +337,11 @@
   public void addExistingReviewerByEmailShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = "nonexisting@example.com";
     input.state = ReviewerState.REVIEWER;
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
     assertThat(result.reviewers).hasSize(1);
     ReviewerInfo info = result.reviewers.get(0);
     assertThat(info._accountId).isNull();
@@ -354,10 +354,10 @@
   public void addExistingCcByEmailShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = "nonexisting@example.com";
     input.state = ReviewerState.CC;
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
 
     assertThat(result.ccs).hasSize(1);
     AccountInfo info = result.ccs.get(0);
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
index f9493c2..647e26b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ChangeReviewersIT.java
@@ -41,14 +41,14 @@
 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;
-import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.NotifyInfo;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerResult;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
@@ -56,7 +56,7 @@
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.change.ReviewerAdder;
+import com.google.gerrit.server.change.ReviewerModifier;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gson.stream.JsonReader;
 import com.google.inject.Inject;
@@ -82,8 +82,8 @@
     String largeGroup = groupOperations.newGroup().name("largeGroup").create().get();
     String mediumGroup = groupOperations.newGroup().name("mediumGroup").create().get();
 
-    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "addGroupAsReviewer");
     List<String> largeGroupUsernames = new ArrayList<>(mediumGroupSize);
     for (TestAccount u : users) {
@@ -100,7 +100,7 @@
     // Attempt to add overly large group as reviewers.
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerResult result = addReviewer(changeId, largeGroup);
+    ReviewerResult result = addReviewer(changeId, largeGroup);
     assertThat(result.input).isEqualTo(largeGroup);
     assertThat(result.confirm).isNull();
     assertThat(result.error).contains("has too many members to add them all as reviewers");
@@ -115,7 +115,7 @@
     assertThat(result.reviewers).isNull();
 
     // Add medium group with confirmation.
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = mediumGroup;
     in.confirmed = true;
     result = addReviewer(changeId, in);
@@ -133,10 +133,10 @@
   public void addCcAccount() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     in.state = CC;
-    AddReviewerResult result = addReviewer(changeId, in);
+    ReviewerResult result = addReviewer(changeId, in);
 
     assertThat(result.input).isEqualTo(user.email());
     assertThat(result.confirm).isNull();
@@ -169,13 +169,13 @@
 
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = groupOperations.newGroup().name("cc1").create().get();
     in.state = CC;
     gApi.groups()
         .id(in.reviewer)
         .addMembers(firstUsernames.toArray(new String[firstUsernames.size()]));
-    AddReviewerResult result = addReviewer(changeId, in);
+    ReviewerResult result = addReviewer(changeId, in);
 
     assertThat(result.input).isEqualTo(in.reviewer);
     assertThat(result.confirm).isNull();
@@ -229,7 +229,7 @@
   public void transitionCcToReviewer() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     in.state = CC;
     addReviewer(changeId, in);
@@ -414,8 +414,8 @@
 
   @Test
   public void reviewAndAddGroupReviewers() throws Exception {
-    int largeGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS + 1;
-    int mediumGroupSize = ReviewerAdder.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
+    int largeGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS + 1;
+    int mediumGroupSize = ReviewerModifier.DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK + 1;
     List<TestAccount> users = createAccounts(largeGroupSize, "reviewAndAddGroupReviewers");
     List<String> usernames = new ArrayList<>(largeGroupSize);
     for (TestAccount u : users) {
@@ -442,7 +442,8 @@
     assertThat(result.labels).isNull();
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(3);
-    AddReviewerResult reviewerResult = result.reviewers.get(largeGroup);
+
+    ReviewerResult reviewerResult = result.reviewers.get(largeGroup);
     assertThat(reviewerResult).isNotNull();
     assertThat(reviewerResult.confirm).isNull();
     assertThat(reviewerResult.error).isNotNull();
@@ -495,7 +496,7 @@
   public void addReviewerToReviewerChangeInfo() throws Exception {
     PushOneCommit.Result r = createChange();
     String changeId = r.getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     in.state = CC;
     addReviewer(changeId, in);
@@ -539,7 +540,8 @@
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(1);
-    AddReviewerResult reviewerResult = result.reviewers.get(user.email());
+
+    ReviewerResult reviewerResult = result.reviewers.get(user.email());
     assertThat(reviewerResult).isNotNull();
     assertThat(reviewerResult.confirm).isNull();
     assertThat(reviewerResult.error).isNull();
@@ -564,7 +566,8 @@
     ReviewResult result = review(r.getChangeId(), r.getCommit().name(), input);
     assertThat(result.reviewers).isNotNull();
     assertThat(result.reviewers).hasSize(2);
-    AddReviewerResult reviewerResult = result.reviewers.get(group1);
+
+    ReviewerResult reviewerResult = result.reviewers.get(group1);
     assertThat(reviewerResult.error).isNull();
     assertThat(reviewerResult.reviewers).hasSize(2);
     reviewerResult = result.reviewers.get(group2);
@@ -646,7 +649,7 @@
     PushOneCommit.Result r = createChange();
     TestAccount userToNotify = createAccounts(1, "notify-details-post-reviewers").get(0);
 
-    AddReviewerInput addReviewer = new AddReviewerInput();
+    ReviewerInput addReviewer = new ReviewerInput();
     addReviewer.reviewer = user.email();
     addReviewer.notify = NotifyHandling.NONE;
     addReviewer.notifyDetails =
@@ -859,7 +862,7 @@
     PushOneCommit.Result r = createChange();
     TestAccount newUser = createAccounts(1, name("foo")).get(0);
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.CC;
     gApi.changes().id(r.getChangeId()).addReviewer(input);
@@ -877,11 +880,11 @@
   public void addExistingReviewerShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.REVIEWER;
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
     assertThat(result.reviewers).hasSize(1);
     ReviewerInfo info = result.reviewers.get(0);
     assertThat(info._accountId).isEqualTo(user.id().get());
@@ -893,11 +896,11 @@
   public void addExistingCcShortCircuits() throws Exception {
     PushOneCommit.Result r = createChange();
 
-    AddReviewerInput input = new AddReviewerInput();
+    ReviewerInput input = new ReviewerInput();
     input.reviewer = user.email();
     input.state = ReviewerState.CC;
 
-    AddReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
+    ReviewerResult result = gApi.changes().id(r.getChangeId()).addReviewer(input);
     assertThat(result.ccs).hasSize(1);
     AccountInfo info = result.ccs.get(0);
     assertThat(info._accountId).isEqualTo(user.id().get());
@@ -909,7 +912,7 @@
   public void moveCcToReviewer() throws Exception {
     // Create a change and add 'user' as CC.
     String changeId = createChange().getChangeId();
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = user.email();
     reviewerInput.state = ReviewerState.CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -967,7 +970,7 @@
 
     // Move 'user' from reviewer to CC.
     requestScopeOperations.setApiUser(admin.id());
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = user.id().toString();
     reviewerInput.state = CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -984,6 +987,19 @@
     assertThat(c.labels.get(LabelId.CODE_REVIEW).approved._accountId).isEqualTo(user.id().get());
   }
 
+  @Test
+  public void wipChangeDoesNotExposeReviewersInChangeSearch() throws Exception {
+    requestScopeOperations.setApiUser(admin.id());
+    String changeId = createChange().getChangeId();
+    gApi.changes().id(changeId).setWorkInProgress();
+    gApi.changes().id(changeId).addReviewer(user.email());
+    assertThat(
+            gApi.changes()
+                .query("project:" + project.get() + " AND reviewer:" + user.email() + "")
+                .get())
+        .isEmpty();
+  }
+
   private void assertThatUserIsOnlyReviewer(String changeId) throws Exception {
     AccountInfo userInfo = new AccountInfo(user.fullName(), user.getNameEmail().email());
     userInfo._accountId = user.id().get();
@@ -992,25 +1008,25 @@
         .containsExactly(ReviewerState.REVIEWER, ImmutableList.of(userInfo));
   }
 
-  private AddReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
+  private ReviewerResult addReviewer(String changeId, String reviewer) throws Exception {
     return addReviewer(changeId, reviewer, SC_OK);
   }
 
-  private AddReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
+  private ReviewerResult addReviewer(String changeId, String reviewer, int expectedStatus)
       throws Exception {
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = reviewer;
     return addReviewer(changeId, in, expectedStatus);
   }
 
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in) throws Exception {
+  private ReviewerResult addReviewer(String changeId, ReviewerInput in) throws Exception {
     return addReviewer(changeId, in, SC_OK);
   }
 
-  private AddReviewerResult addReviewer(String changeId, AddReviewerInput in, int expectedStatus)
+  private ReviewerResult addReviewer(String changeId, ReviewerInput in, int expectedStatus)
       throws Exception {
     RestResponse resp = adminRestSession.post("/changes/" + changeId + "/reviewers", in);
-    return readContentFromJson(resp, expectedStatus, AddReviewerResult.class);
+    return readContentFromJson(resp, expectedStatus, ReviewerResult.class);
   }
 
   private RestResponse deleteReviewer(String changeId, TestAccount account) throws Exception {
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
index ed21050..695bb90 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/ConfigChangeIT.java
@@ -60,14 +60,14 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void updateProjectConfig() throws Exception {
     String id = testUpdateProjectConfig();
     assertThat(gApi.changes().id(id).get().revisions).hasSize(1);
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user", submitType = SubmitType.CHERRY_PICK)
+  @TestProjectInput(cloneAs = "user1", submitType = SubmitType.CHERRY_PICK)
   public void updateProjectConfigWithCherryPick() throws Exception {
     String id = testUpdateProjectConfig();
     assertThat(gApi.changes().id(id).get().revisions).hasSize(2);
@@ -102,7 +102,7 @@
   }
 
   @Test
-  @TestProjectInput(cloneAs = "user")
+  @TestProjectInput(cloneAs = "user1")
   public void onlyAdminMayUpdateProjectParent() throws Exception {
     requestScopeOperations.setApiUser(admin.id());
     ProjectInput parent = new ProjectInput();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
index d3dd801..ae872b2b 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CorsIT.java
@@ -27,6 +27,7 @@
 import static com.google.common.net.HttpHeaders.VARY;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.testing.ConfigSuite;
+import java.lang.reflect.Method;
 import java.nio.charset.StandardCharsets;
 import java.util.Locale;
 import java.util.stream.Stream;
@@ -130,8 +132,10 @@
     Result change = createChange();
     String origin = adminRestSession.url();
     RestResponse r =
-        adminRestSession.putWithHeader(
-            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content = */ "A",
+            new BasicHeader(ORIGIN, origin));
     r.assertOK();
     checkCors(r, false, origin);
     checkTopic(change, "A");
@@ -142,8 +146,10 @@
     Result change = createChange();
     String origin = "http://example.com";
     RestResponse r =
-        adminRestSession.putWithHeader(
-            "/changes/" + change.getChangeId() + "/topic", new BasicHeader(ORIGIN, origin), "A");
+        adminRestSession.putWithHeaders(
+            "/changes/" + change.getChangeId() + "/topic",
+            /* content = */ "A",
+            new BasicHeader(ORIGIN, origin));
     r.assertOK();
     checkCors(r, true, origin);
   }
@@ -202,6 +208,10 @@
 
   @Test
   public void crossDomainPutTopic() throws Exception {
+    // Setting cookies with HttpOnly requires Servlet API 3+ which not all deployments might have
+    // available.
+    assume().that(cookieHasSetHttpOnlyMethod()).isTrue();
+
     Result change = createChange();
     BasicCookieStore cookies = new BasicCookieStore();
     Executor http = Executor.newInstance().use(cookies);
@@ -283,7 +293,7 @@
       String url, boolean accept, String origin, RestSession restSession, int httpStatusCode)
       throws Exception {
     Header hdr = new BasicHeader(ORIGIN, origin);
-    RestResponse r = restSession.getWithHeader(url, hdr);
+    RestResponse r = restSession.getWithHeaders(url, hdr);
     r.assertStatus(httpStatusCode);
     checkCors(r, accept, origin);
   }
@@ -323,4 +333,14 @@
       assertWithMessage(ACCESS_CONTROL_ALLOW_HEADERS).that(allowHeaders).isNull();
     }
   }
+
+  private static boolean cookieHasSetHttpOnlyMethod() {
+    Method setHttpOnly = null;
+    try {
+      setHttpOnly = Cookie.class.getMethod("setHttpOnly", boolean.class);
+    } catch (NoSuchMethodException | SecurityException e) {
+      return false;
+    }
+    return setHttpOnly != null;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
index 129b546..b0a14cf 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/CreateChangeIT.java
@@ -28,9 +28,12 @@
 import static org.eclipse.jgit.lib.Constants.SIGNED_OFF_BY_TAG;
 
 import com.google.common.base.Strings;
+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.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.PushOneCommit.Result;
 import com.google.gerrit.acceptance.RestResponse;
@@ -51,6 +54,7 @@
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -63,6 +67,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.submit.ChangeAlreadyMergedException;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -76,18 +84,37 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.RefSpec;
+import org.junit.Before;
 import org.junit.Test;
 
 @UseClockStep
 public class CreateChangeIT extends AbstractDaemonTest {
   @Inject private ProjectOperations projectOperations;
   @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Before
+  public void addNonCommitHead() throws Exception {
+    try (Repository repo = repoManager.openRepository(project);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId answer = ins.insert(Constants.OBJ_BLOB, new byte[] {42});
+      ins.flush();
+      ins.close();
+
+      RefUpdate update = repo.getRefDatabase().newUpdate("refs/heads/answer", false);
+      update.setNewObjectId(answer);
+      assertThat(update.forceUpdate()).isEqualTo(RefUpdate.Result.NEW);
+    }
+  }
 
   @Test
   public void createEmptyChange_MissingBranch() throws Exception {
@@ -394,6 +421,69 @@
   }
 
   @Test
+  public void createAuthorAddedAsCcAndNotified() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.email = user.email();
+    input.author.name = user.fullName();
+
+    ChangeInfo info = assertCreateSucceeds(input);
+    assertThat(info.reviewers.get(ReviewerState.CC)).hasSize(1);
+    assertThat(Iterables.getOnlyElement(info.reviewers.get(ReviewerState.CC)).email)
+        .isEqualTo(user.email());
+    assertThat(
+            Iterables.getOnlyElement(Iterables.getOnlyElement(sender.getMessages()).rcpt()).email())
+        .isEqualTo(user.email());
+  }
+
+  @Test
+  public void createAuthorAddedAsCcNotNotifiedWithNotifyNone() throws Exception {
+    ChangeInput input = newChangeInput(ChangeStatus.NEW);
+    input.author = new AccountInput();
+    input.author.email = user.email();
+    input.author.name = user.fullName();
+    input.notify = NotifyHandling.NONE;
+
+    ChangeInfo info = assertCreateSucceeds(input);
+    assertThat(info.reviewers.get(ReviewerState.CC)).hasSize(1);
+    assertThat(Iterables.getOnlyElement(info.reviewers.get(ReviewerState.CC)).email)
+        .isEqualTo(user.email());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
+  public void createWithMergeConflictAuthorAddedAsCcNotNotifiedWithNotifyNone() throws Exception {
+    String fileName = "shared.txt";
+    String sourceBranch = "sourceBranch";
+    String sourceSubject = "source change";
+    String sourceContent = "source content";
+    String targetBranch = "targetBranch";
+    String targetSubject = "target change";
+    String targetContent = "target content";
+    changeInTwoBranches(
+        sourceBranch,
+        sourceSubject,
+        fileName,
+        sourceContent,
+        targetBranch,
+        targetSubject,
+        fileName,
+        targetContent);
+    ChangeInput input = newMergeChangeInput(targetBranch, sourceBranch, "", true);
+    input.workInProgress = true;
+    input.author = new AccountInput();
+    input.author.email = user.email();
+    input.author.name = user.fullName();
+    input.notify = NotifyHandling.NONE;
+    ChangeInfo info = assertCreateSucceeds(input);
+
+    assertThat(info.reviewers.get(ReviewerState.CC)).hasSize(1);
+    assertThat(Iterables.getOnlyElement(info.reviewers.get(ReviewerState.CC)).email)
+        .isEqualTo(user.email());
+    assertThat(sender.getMessages()).isEmpty();
+  }
+
+  @Test
   public void createNewWorkInProgressChange() throws Exception {
     ChangeInput input = newChangeInput(ChangeStatus.NEW);
     input.workInProgress = true;
@@ -963,6 +1053,24 @@
     assertThrows(BadRequestException.class, () -> gApi.changes().create(in));
   }
 
+  @Test
+  public void createChangeWithValidationOptions() throws Exception {
+    ChangeInput changeInput = new ChangeInput();
+    changeInput.project = project.get();
+    changeInput.branch = "master";
+    changeInput.subject = "A change";
+    changeInput.status = ChangeStatus.NEW;
+    changeInput.validationOptions = ImmutableMap.of("key", "value");
+
+    TestCommitValidationListener testCommitValidationListener = new TestCommitValidationListener();
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(testCommitValidationListener)) {
+      assertCreateSucceeds(changeInput);
+      assertThat(testCommitValidationListener.receiveEvent.pushOptions)
+          .containsExactly("key", "value");
+    }
+  }
+
   private ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -1073,7 +1181,6 @@
    * @param branchB name of second branch to create
    * @param fileB name of file to commit to branchB
    * @return A {@code Map} of branchName => commit result.
-   * @throws Exception
    */
   private Map<String, Result> changeInTwoBranches(
       String branchA, String fileA, String branchB, String fileB) throws Exception {
@@ -1093,7 +1200,6 @@
    * @param fileB name of file to commit to branchB
    * @param contentB file content to commit to branchB
    * @return A {@code Map} of branchName => commit result.
-   * @throws Exception
    */
   private Map<String, Result> changeInTwoBranches(
       String branchA,
@@ -1132,4 +1238,15 @@
 
     return ImmutableMap.of("master", initialCommit, branchA, changeA, branchB, changeB);
   }
+
+  private static class TestCommitValidationListener implements CommitValidationListener {
+    public CommitReceivedEvent receiveEvent;
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      this.receiveEvent = receiveEvent;
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
index 0c2c3a1..c57d285 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/DeleteVoteIT.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.testing.FakeEmailSender;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
@@ -99,7 +100,11 @@
 
     ChangeMessageInfo message = Iterables.getLast(c.messages);
     assertThat(message.author._accountId).isEqualTo(admin.id().get());
-    assertThat(message.message).isEqualTo("Removed Code-Review+1 by User <user@example.com>\n");
+    assertThat(message.message)
+        .isEqualTo(
+            String.format(
+                "Removed Code-Review+1 by %s\n",
+                AccountTemplateUtil.getAccountTemplate(user.id())));
     assertThat(getReviewers(c.reviewers.get(REVIEWER)))
         .containsExactlyElementsIn(ImmutableSet.of(admin.id(), user.id()));
   }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
index def4ed8..99aa273 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/IndexChangeIT.java
@@ -67,7 +67,7 @@
     TestAccount user2 = accountCreator.user2();
     AccountGroup.UUID groupId = groupOperations.newGroup().name("test").create();
     String group = groupOperations.group(groupId).get().name();
-    gApi.groups().id(group).addMembers("admin", "user", user2.username());
+    gApi.groups().id(group).addMembers("admin", user.username(), user2.username());
 
     // Create a project and restrict its visibility to the group
     Project.NameKey p = projectOperations.newProject().create();
@@ -103,7 +103,7 @@
 
     // Remove the user from the group so they can no longer see the project
     requestScopeOperations.setApiUser(admin.id());
-    gApi.groups().id(group).removeMembers("user");
+    gApi.groups().id(group).removeMembers(user.username());
 
     // User can no longer see the change
     requestScopeOperations.setApiUser(user.id());
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
index 69f1d8e..d5c7610 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/MoveChangeIT.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.NoHttpd;
@@ -91,7 +92,8 @@
     expectedMessage.append("Change destination moved from master to moveTest");
     expectedMessage.append("\n\n");
     expectedMessage.append(moveMessage);
-    assertThat(r.getChange().messages().get(1).getMessage()).isEqualTo(expectedMessage.toString());
+    assertThat(Iterables.getLast(gApi.changes().id(r.getChangeId()).get().messages).message)
+        .isEqualTo(expectedMessage.toString());
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
index fff3cb6..b3592e3 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByCherryPickIT.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
+import static java.util.Comparator.comparing;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.acceptance.ExtensionRegistry;
@@ -26,6 +28,10 @@
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.entities.BranchNameKey;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project.NameKey;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.InheritableBoolean;
@@ -403,4 +409,54 @@
 
     assertSubmittable(change2Result.getChangeId());
   }
+
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withCopyCondition() throws Exception {
+    // Code-Review will be sticky.
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  private void stickyVoteStoredOnSubmitOnNewPatchset() 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);
+
+    // Submit, also keeping the Code-Review +2 vote.
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // The last approval is stored on the submitted patch-set which was created by cherry-pick
+    // during submit.
+    PatchSetApproval patchSetApprovals =
+        Iterables.getLast(
+            r.getChange().notes().getApprovalsWithCopied().values().stream()
+                .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
+                .sorted(comparing(a -> a.patchSetId().get()))
+                .collect(toImmutableList()));
+
+    assertThat(patchSetApprovals.patchSetId().get()).isEqualTo(2);
+    assertThat(patchSetApprovals.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(patchSetApprovals.value()).isEqualTo((short) 2);
+
+    // The approval is not copied since we don't need to persist copied votes on submit, only
+    // persist votes normally.
+    assertThat(patchSetApprovals.copied()).isFalse();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
index 66eb48c..58e48e9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByFastForwardIT.java
@@ -20,6 +20,7 @@
 
 import com.google.gerrit.acceptance.GitUtil;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.PatchSet;
@@ -107,6 +108,39 @@
   }
 
   @Test
+  @GerritConfig(name = "change.submitWholeTopic", value = "true")
+  public void submitTwoIndependentChangesWithFastForwardFail() throws Throwable {
+    RevCommit initialHead = projectOperations.project(project).getHead("master");
+    PushOneCommit.Result change1 = createChange("subject1", "file1.txt", "content", "topic");
+
+    testRepo.reset(initialHead);
+    PushOneCommit.Result change2 = createChange("subject2", "file2.txt", "content", "topic");
+
+    approve(change1.getChangeId());
+    approve(change2.getChangeId());
+
+    String fastForwardIndependentChangesError =
+        "Change could not be merged because the submission"
+            + " has two independent changes with the same destination branch. Independent changes can't "
+            + "be submitted to the same destination branch with FAST_FORWARD_ONLY submit strategy";
+
+    submitWithConflict(
+        change2.getChangeId(),
+        String.format(
+            "Failed to submit 2 changes due to the following problems:\n"
+                + "Change %d: %s\nChange %d: %s",
+            change1.getChange().getId().get(),
+            fastForwardIndependentChangesError,
+            change2.getChange().getId().get(),
+            fastForwardIndependentChangesError));
+
+    RevCommit updatedHead = projectOperations.project(project).getHead("master");
+    assertThat(updatedHead.getId()).isEqualTo(initialHead.getId());
+    assertRefUpdatedEvents();
+    assertChangeMergedEvents();
+  }
+
+  @Test
   public void submitFastForwardNotPossible_Conflict() throws Throwable {
     RevCommit initialHead = projectOperations.project(project).getHead("master");
     PushOneCommit.Result change = createChange("Change 1", "a.txt", "content");
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
index eeeac2a..aa93815 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SubmitByRebaseAlwaysIT.java
@@ -14,20 +14,27 @@
 
 package com.google.gerrit.acceptance.rest.change;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.extensions.client.ListChangesOption.CURRENT_REVISION;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static java.util.Comparator.comparing;
 
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
 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.project.ProjectOperations;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.entities.LabelId;
 import com.google.gerrit.entities.PatchSet;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.Project.NameKey;
 import com.google.gerrit.exceptions.InternalServerWithUserMessageException;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -159,6 +166,56 @@
     }
   }
 
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withoutCopyCondition() throws Exception {
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(
+              LabelId.CODE_REVIEW, b -> b.setCopyAllScoresIfListOfFilesDidNotChange(true));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  @Test
+  public void stickyVoteStoredOnSubmitOnNewPatchset_withCopyCondition() throws Exception {
+    // Code-Review will be sticky.
+    try (ProjectConfigUpdate u = updateProject(NameKey.parse("All-Projects"))) {
+      u.getConfig()
+          .updateLabelType(LabelId.CODE_REVIEW, b -> b.setCopyCondition("has:unchanged-files"));
+      u.save();
+    }
+    stickyVoteStoredOnSubmitOnNewPatchset();
+  }
+
+  private void stickyVoteStoredOnSubmitOnNewPatchset() 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);
+
+    // Submit, also keeping the Code-Review +2 vote.
+    gApi.changes().id(r.getChangeId()).current().submit();
+
+    // The last approval is stored on the submitted patch-set which was created by rebase during
+    // submit.
+    PatchSetApproval patchSetApprovals =
+        Iterables.getLast(
+            r.getChange().notes().getApprovalsWithCopied().values().stream()
+                .filter(a -> a.labelId().equals(LabelId.create(LabelId.CODE_REVIEW)))
+                .sorted(comparing(a -> a.patchSetId().get()))
+                .collect(toImmutableList()));
+
+    assertThat(patchSetApprovals.patchSetId().get()).isEqualTo(2);
+    assertThat(patchSetApprovals.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(patchSetApprovals.value()).isEqualTo((short) 2);
+
+    // The approval is not copied since we don't need to persist copied votes on submit, only
+    // persist votes normally.
+    assertThat(patchSetApprovals.copied()).isFalse();
+  }
+
   private void assertLatestRevisionHasFooters(PushOneCommit.Result change) throws Throwable {
     RevCommit c = getCurrentCommit(change);
     assertThat(c.getFooterLines(FooterConstants.CHANGE_ID)).isNotEmpty();
diff --git a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index ffde622..3850e13 100644
--- a/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -38,7 +38,7 @@
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
@@ -447,6 +447,8 @@
     gApi.accounts().id(foo2.username()).setActive(false);
     assertThat(gApi.accounts().id(foo2.id().get()).getActive()).isFalse();
     assertReviewers(suggestReviewers(changeId, name), ImmutableList.of(foo1), ImmutableList.of());
+    assertReviewers(
+        suggestReviewers(changeId, /*query=*/ ""), ImmutableList.of(foo1), ImmutableList.of());
   }
 
   @Test
@@ -502,7 +504,7 @@
     assertReviewers(
         suggestReviewers(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = foo2.id().toString();
     reviewerInput.state = ReviewerState.CC;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
@@ -525,7 +527,7 @@
 
     assertReviewers(suggestCcs(changeId, name), ImmutableList.of(foo1, foo2), ImmutableList.of());
 
-    AddReviewerInput reviewerInput = new AddReviewerInput();
+    ReviewerInput reviewerInput = new ReviewerInput();
     reviewerInput.reviewer = foo2.id().toString();
     reviewerInput.state = ReviewerState.REVIEWER;
     gApi.changes().id(changeId).addReviewer(reviewerInput);
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
index e4b4e4a..ab8e4d8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/CacheOperationsIT.java
@@ -52,14 +52,14 @@
   }
 
   @Test
-  public void flushAll_Forbidden() throws Exception {
+  public void flushAll_forbidden() throws Exception {
     userRestSession
         .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL))
         .assertForbidden();
   }
 
   @Test
-  public void flushAll_BadRequest() throws Exception {
+  public void flushAll_badRequest() throws Exception {
     adminRestSession
         .post("/config/server/caches/", new PostCaches.Input(FLUSH_ALL, Arrays.asList("projects")))
         .assertBadRequest();
diff --git a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
index c893214..97288a8 100644
--- a/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/config/ServerInfoIT.java
@@ -58,8 +58,6 @@
   @GerritConfig(name = "auth.httpPasswordUrl", value = "https://example.com/password")
 
   // change
-  @GerritConfig(name = "change.replyTooltip", value = "Publish votes and draft comments")
-  @GerritConfig(name = "change.replyLabel", value = "Vote")
   @GerritConfig(name = "change.updateDelay", value = "50s")
   @GerritConfig(name = "change.disablePrivateChanges", value = "true")
   @GerritConfig(name = "change.enableAttentionSet", value = "true")
@@ -102,8 +100,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.replyTooltip).startsWith("Publish votes and draft comments");
-    assertThat(i.change.replyLabel).isEqualTo("Vote\u2026");
     assertThat(i.change.updateDelay).isEqualTo(50);
     assertThat(i.change.disablePrivateChanges).isTrue();
     assertThat(i.change.enableAttentionSet).isTrue();
@@ -170,8 +166,6 @@
     assertThat(i.auth.httpPasswordUrl).isNull();
 
     // change
-    assertThat(i.change.replyTooltip).startsWith("Reply and score");
-    assertThat(i.change.replyLabel).isEqualTo("Reply\u2026");
     assertThat(i.change.updateDelay).isEqualTo(300);
     assertThat(i.change.disablePrivateChanges).isNull();
     assertThat(i.change.submitWholeTopic).isNull();
@@ -213,4 +207,12 @@
     ServerInfo i = gApi.config().server().getInfo();
     assertThat(i.change.mergeabilityComputationBehavior).isEqualTo("NEVER");
   }
+
+  @Test
+  @GerritConfig(name = "download.scheme", value = "fooBar")
+  @GerritConfig(name = "download.command", value = "fooBar")
+  public void misconfiguredDownloadCommands() throws Exception {
+    ServerInfo i = gApi.config().server().getInfo();
+    assertThat(i.download.schemes).isEmpty();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
index 7442425..b94ea37 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/AccessIT.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 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.
@@ -11,1091 +11,102 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.acceptance.rest.project;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth8.assertThat;
-import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
-import static com.google.gerrit.extensions.client.ListChangesOption.MESSAGES;
-import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
+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.schema.AclUtil.grant;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static com.google.gerrit.truth.ConfigSubject.assertThat;
-import static com.google.gerrit.truth.MapSubject.assertThatMap;
-import static java.util.Arrays.asList;
-import static org.junit.Assert.assertEquals;
 
 import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.ExtensionRegistry;
-import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
-import com.google.gerrit.acceptance.GitUtil;
-import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
-import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.entities.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;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
-import com.google.gerrit.extensions.api.access.ProjectAccessInput;
-import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.projects.ProjectApi;
-import com.google.gerrit.extensions.client.ChangeStatus;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.common.WebLinkInfo;
-import com.google.gerrit.extensions.config.CapabilityDefinition;
-import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.webui.FileHistoryWebLink;
-import com.google.gerrit.server.config.AllProjectsNameProvider;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.schema.GrantRevertPermission;
+import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
-import java.util.HashMap;
-import java.util.List;
 import java.util.Map;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
 import org.junit.Test;
 
 public class AccessIT extends AbstractDaemonTest {
-
-  private static final String REFS_ALL = Constants.R_REFS + "*";
-  private static final String REFS_HEADS = Constants.R_HEADS + "*";
-  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;
-  @Inject private ExtensionRegistry extensionRegistry;
-  @Inject private GrantRevertPermission grantRevertPermission;
 
-  private Project.NameKey newProjectName;
-
-  @Before
-  public void setUp() throws Exception {
-    newProjectName = projectOperations.newProject().create();
+  @Test
+  public void listAccessWithoutSpecifyingProject() throws Exception {
+    RestResponse r = adminRestSession.get("/access/");
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject).isEmpty();
   }
 
   @Test
-  public void grantRevertPermission() throws Exception {
-    String ref = "refs/*";
-    String groupId = "global:Registered-Users";
-
-    grantRevertPermission.execute(newProjectName);
-
-    ProjectAccessInfo info = pApi().access();
-    assertThat(info.local.containsKey(ref)).isTrue();
-    AccessSectionInfo accessSectionInfo = info.local.get(ref);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
-    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
-    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
-    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
-    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  public void listAccessWithoutSpecifyingAnEmptyProjectName() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?p=");
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject).isEmpty();
   }
 
   @Test
-  public void grantRevertPermissionByOnNewRefAndDeletingOnOldRef() throws Exception {
-    String refsHeads = "refs/heads/*";
-    String refsStar = "refs/*";
-    String groupId = "global:Registered-Users";
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.upsertAccessSection(
-          AccessSection.HEADS,
-          heads -> {
-            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-          });
-      md.getCommitBuilder().setAuthor(admin.newIdent());
-      md.getCommitBuilder().setCommitter(admin.newIdent());
-      md.setMessage("Add revert permission for all registered users\n");
-
-      projectConfig.commit(md);
-    }
-    grantRevertPermission.execute(newProjectName);
-
-    ProjectAccessInfo info = pApi().access();
-
-    // Revert permission is removed on refs/heads/*.
-    assertThat(info.local.containsKey(refsHeads)).isTrue();
-    AccessSectionInfo accessSectionInfo = info.local.get(refsHeads);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isFalse();
-
-    // new permission is added on refs/* with Registered-Users.
-    assertThat(info.local.containsKey(refsStar)).isTrue();
-    accessSectionInfo = info.local.get(refsStar);
-    assertThat(accessSectionInfo.permissions.containsKey(Permission.REVERT)).isTrue();
-    PermissionInfo permissionInfo = accessSectionInfo.permissions.get(Permission.REVERT);
-    assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
-    PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
-    assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
+  public void listAccessForNonExistingProject() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?project=non-existing");
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo("non-existing");
   }
 
   @Test
-  public void grantRevertPermissionDoesntDeleteAdminsPreferences() throws Exception {
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-    GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      projectConfig.upsertAccessSection(
-          AccessSection.HEADS,
-          heads -> {
-            grant(projectConfig, heads, Permission.REVERT, registeredUsers);
-            grant(projectConfig, heads, Permission.REVERT, otherGroup);
-          });
-      md.getCommitBuilder().setAuthor(admin.newIdent());
-      md.getCommitBuilder().setCommitter(admin.newIdent());
-      md.setMessage("Add revert permission for all registered users\n");
-
-      projectConfig.commit(md);
-    }
-    projectCache.evict(newProjectName);
-    ProjectAccessInfo expected = pApi().access();
-
-    grantRevertPermission.execute(newProjectName);
-    projectCache.evictAndReindex(newProjectName);
-    ProjectAccessInfo actual = pApi().access();
-    // Permissions don't change
-    assertThat(expected.local).isEqualTo(actual.local);
-  }
-
-  @Test
-  public void grantRevertPermissionOnlyWorksOnce() throws Exception {
-    grantRevertPermission.execute(newProjectName);
-    grantRevertPermission.execute(newProjectName);
-
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
-      ProjectConfig projectConfig = projectConfigFactory.read(md);
-      AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
-
-      Permission permission = all.getPermission(Permission.REVERT);
-      assertThat(permission.getRules()).hasSize(1);
-    }
-  }
-
-  @Test
-  public void getDefaultInheritance() throws Exception {
-    String inheritedName = pApi().access().inheritsFrom.name;
-    assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
-  }
-
-  private Registration newFileHistoryWebLink() {
-    FileHistoryWebLink weblink =
-        new FileHistoryWebLink() {
-          @Override
-          public WebLinkInfo getFileHistoryWebLink(
-              String projectName, String revision, String fileName) {
-            return new WebLinkInfo(
-                "name", "imageURL", "http://view/" + projectName + "/" + fileName);
-          }
-        };
-    return extensionRegistry.newRegistration().add(weblink);
-  }
-
-  @Test
-  public void webLink() throws Exception {
-    try (Registration registration = newFileHistoryWebLink()) {
-      ProjectAccessInfo info = pApi().access();
-      assertThat(info.configWebLinks).hasSize(1);
-      assertThat(info.configWebLinks.get(0).url)
-          .isEqualTo("http://view/" + newProjectName + "/project.config");
-    }
-  }
-
-  @Test
-  public void webLinkNoRefsMetaConfig() throws Exception {
-    try (Repository repo = repoManager.openRepository(newProjectName);
-        Registration registration = newFileHistoryWebLink()) {
-      RefUpdate u = repo.updateRef(RefNames.REFS_CONFIG);
-      u.setForceUpdate(true);
-      assertThat(u.delete()).isEqualTo(Result.FORCED);
-
-      // This should not crash.
-      pApi().access();
-    }
-  }
-
-  @Test
-  public void addAccessSection() throws Exception {
-    RevCommit initialHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
-
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-
-    RevCommit updatedHead = projectOperations.project(newProjectName).getHead(RefNames.REFS_CONFIG);
-    eventRecorder.assertRefUpdatedEvents(
-        newProjectName.get(), RefNames.REFS_CONFIG, null, initialHead, initialHead, updatedHead);
-  }
-
-  @Test
-  public void addAccessSectionForPluginPermission() throws Exception {
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                new PluginProjectPermissionDefinition() {
-                  @Override
-                  public String getDescription() {
-                    return "A Plugin Project Permission";
-                  }
-                },
-                "fooPermission")) {
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-      PermissionInfo foo = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSectionInfo.permissions.put(
-          "plugin-" + ExtensionRegistry.PLUGIN_NAME + "-fooPermission", foo);
-
-      accessInput.add.put(REFS_HEADS, accessSectionInfo);
-      ProjectAccessInfo updatedAccessSectionInfo = pApi().access(accessInput);
-      assertThat(updatedAccessSectionInfo.local).isEqualTo(accessInput.add);
-
-      assertThat(pApi().access().local).isEqualTo(accessInput.add);
-    }
-  }
-
-  @Test
-  public void addAccessSectionWithInvalidPermission() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("Invalid Permission", push);
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: Invalid Permission");
-  }
-
-  @Test
-  public void addAccessSectionWithInvalidLabelPermission() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("label-Invalid Permission", push);
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown permission: label-Invalid Permission");
-  }
-
-  @Test
-  public void createAccessChangeNop() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
-  }
-
-  @Test
-  public void createAccessChangeEmptyConfig() throws Exception {
-    try (Repository repo = repoManager.openRepository(newProjectName)) {
-      RefUpdate ru = repo.updateRef(RefNames.REFS_CONFIG);
-      ru.setForceUpdate(true);
-      assertThat(ru.delete()).isEqualTo(Result.FORCED);
-
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSection = newAccessSectionInfo();
-      PermissionInfo read = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false);
-      read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSection.permissions.put(Permission.READ, read);
-      accessInput.add.put(REFS_HEADS, accessSection);
-
-      ChangeInfo out = pApi().accessChange(accessInput);
-      assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    }
-  }
-
-  @Test
-  public void createAccessChange() throws Exception {
+  public void listAccessForNonVisibleProject() throws Exception {
     projectOperations
-        .project(newProjectName)
+        .project(project)
         .forUpdate()
-        .add(allow(Permission.READ).ref(RefNames.REFS_CONFIG).group(REGISTERED_USERS))
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
         .update();
-    // User can see the branch
-    requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
 
-    ProjectAccessInput accessInput = newProjectAccessInput();
-
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    // Deny read to registered users.
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    read.exclusive = true;
-    accessSection.permissions.put(Permission.READ, read);
-    accessInput.add.put(REFS_HEADS, accessSection);
-
-    requestScopeOperations.setApiUser(user.id());
-    ChangeInfo out = pApi().accessChange(accessInput);
-
-    assertThat(out.project).isEqualTo(newProjectName.get());
-    assertThat(out.branch).isEqualTo(RefNames.REFS_CONFIG);
-    assertThat(out.status).isEqualTo(ChangeStatus.NEW);
-    assertThat(out.submitted).isNull();
-
-    requestScopeOperations.setApiUser(admin.id());
-
-    ChangeInfo c = gApi.changes().id(out._number).get(MESSAGES);
-    assertThat(c.messages.stream().map(m -> m.message)).containsExactly("Uploaded patch set 1");
-
-    ReviewInput reviewIn = new ReviewInput();
-    reviewIn.label("Code-Review", (short) 2);
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // check that the change took effect.
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().branch("refs/heads/master").get());
-
-    // Restore.
-    accessInput.add.clear();
-    accessInput.remove.put(REFS_HEADS, accessSection);
-    requestScopeOperations.setApiUser(user.id());
-
-    requestScopeOperations.setApiUser(admin.id());
-    out = pApi().accessChange(accessInput);
-
-    gApi.changes().id(out._number).current().review(reviewIn);
-    gApi.changes().id(out._number).current().submit();
-
-    // Now it works again.
-    requestScopeOperations.setApiUser(user.id());
-    pApi().branch("refs/heads/master").get();
+    RestResponse r = userRestSession.get("/access/?project=" + project.get());
+    r.assertNotFound();
+    assertThat(r.getEntityContent()).isEqualTo(project.get());
   }
 
   @Test
-  public void removePermission() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    accessSectionToRemove.permissions.put(
-        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 + LabelId.CODE_REVIEW);
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+  public void listAccess() throws Exception {
+    RestResponse r = adminRestSession.get("/access/?project=" + project.get());
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(project.get());
   }
 
   @Test
-  public void removePermissionRule() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
+  public void listAccess_withUrlEncodedProjectName() throws Exception {
+    String fooBarBazProjectName = name("foo/bar/baz");
+    ProjectInput in = new ProjectInput();
+    in.name = fooBarBazProjectName;
+    gApi.projects().create(in);
 
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission rule
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    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 + LabelId.CODE_REVIEW, codeReview);
-    ProjectAccessInput removal = newProjectAccessInput();
-    removal.remove.put(REFS_HEADS, accessSectionToRemove);
-    pApi().access(removal);
-
-    // Remove locally
-    accessInput
-        .add
-        .get(REFS_HEADS)
-        .permissions
-        .get(Permission.LABEL + LabelId.CODE_REVIEW)
-        .rules
-        .remove(SystemGroupBackend.REGISTERED_USERS.get());
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
+    RestResponse r =
+        adminRestSession.get("/access/?project=" + IdString.fromDecoded(fooBarBazProjectName));
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(fooBarBazProjectName);
   }
 
   @Test
-  public void removePermissionRulesAndCleanupEmptyEntries() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    accessInput.add.put(REFS_HEADS, accessSectionInfo);
-    pApi().access(accessInput);
-
-    // Remove specific permission rules
-    AccessSectionInfo accessSectionToRemove = newAccessSectionInfo();
-    PermissionInfo codeReview = newPermissionInfo();
-    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 + 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 + LabelId.CODE_REVIEW);
-
-    // Check
-    assertThat(pApi().access().local).isEqualTo(accessInput.add);
-  }
-
-  @Test
-  public void getPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    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());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
-  }
-
-  @Test
-  public void setPermissionsWithDisallowedUser() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createAccessSectionInfoDenyAll();
-
-    // Disallow READ
-    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
-    ProjectAccessInput accessInfoToApply = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfoToApply = createDefaultAccessSectionInfo();
-    accessInfoToApply.add.put(REFS_HEADS, accessSectionInfoToApply);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(ResourceNotFoundException.class, () -> pApi().access());
-  }
-
-  @Test
-  public void permissionsGroupMap() throws Exception {
-    // Add initial permission set
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo read = newPermissionInfo();
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    accessInput.add.put(REFS_ALL, accessSection);
-    ProjectAccessInfo result = pApi().access(accessInput);
-    assertThatMap(result.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    // Check the name, which is what the UI cares about; exhaustive
-    // coverage of GroupInfo should be in groups REST API tests.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).name)
-        .isEqualTo("Project Owners");
-    // Strip the ID, since it is in the key.
-    assertThat(result.groups.get(SystemGroupBackend.PROJECT_OWNERS.get()).id).isNull();
-
-    // Get call returns groups too.
-    ProjectAccessInfo loggedInResult = pApi().access();
-    assertThatMap(loggedInResult.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-
-    GroupInfo owners = loggedInResult.groups.get(SystemGroupBackend.PROJECT_OWNERS.get());
-    assertThat(owners.name).isEqualTo("Project Owners");
-    assertThat(owners.id).isNull();
-    assertThat(owners.members).isNull();
-    assertThat(owners.includes).isNull();
-
-    // PROJECT_OWNERS is invisible to anonymous user, but GetAccess disregards visibility.
-    requestScopeOperations.setApiUserAnonymous();
-    ProjectAccessInfo anonResult = pApi().access();
-    assertThatMap(anonResult.groups)
-        .keys()
-        .containsExactly(
-            SystemGroupBackend.PROJECT_OWNERS.get(), SystemGroupBackend.ANONYMOUS_USERS.get());
-  }
-
-  @Test
-  public void updateParentAsUser() throws Exception {
-    // Create child
-    String newParentProjectName = projectOperations.newProject().create().get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    requestScopeOperations.setApiUser(user.id());
-    AuthException thrown = assertThrows(AuthException.class, () -> pApi().access(accessInput));
-    assertThat(thrown).hasMessageThat().contains("administrate server not permitted");
-  }
-
-  @Test
-  public void updateParentAsAdministrator() throws Exception {
-    // Create parent
-    String newParentProjectName = projectOperations.newProject().create().get();
-
-    // Set new parent
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = newParentProjectName;
-
-    pApi().access(accessInput);
-
-    assertThat(pApi().access().inheritsFrom.name).isEqualTo(newParentProjectName);
-  }
-
-  @Test
-  public void addGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void addGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedAccessSectionInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void addPluginGlobalCapability() throws Exception {
-    try (Registration registration =
-        extensionRegistry
-            .newRegistration()
-            .add(
-                new CapabilityDefinition() {
-                  @Override
-                  public String getDescription() {
-                    return "A Plugin Global Capability";
-                  }
-                },
-                "fooCapability")) {
-      ProjectAccessInput accessInput = newProjectAccessInput();
-      AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-      PermissionInfo foo = newPermissionInfo();
-      PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-      foo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-      accessSectionInfo.permissions.put(ExtensionRegistry.PLUGIN_NAME + "-fooCapability", foo);
-
-      accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-      ProjectAccessInfo updatedAccessSectionInfo =
-          gApi.projects().name(allProjects.get()).access(accessInput);
-      assertThatMap(
-              updatedAccessSectionInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-          .keys()
-          .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-    }
-  }
-
-  @Test
-  public void addPermissionAsGlobalCapability() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put(Permission.PUSH, push);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).access(accessInput));
-    assertThat(ex).hasMessageThat().isEqualTo("Unknown global capability: " + Permission.PUSH);
-  }
-
-  @Test
-  public void addInvalidGlobalCapability() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    permissionInfo.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSectionInfo.permissions.put("Invalid Global Capability", permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    BadRequestException ex =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allProjects.get()).access(accessInput));
-    assertThat(ex)
-        .hasMessageThat()
-        .isEqualTo("Unknown global capability: Invalid Global Capability");
-  }
-
-  @Test
-  public void addGlobalCapabilityForNonRootProject() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-  }
-
-  @Test
-  public void addNonGlobalCapabilityToGlobalCapabilities() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroupUuid().get(), null);
-    accessSectionInfo.permissions.put(Permission.PUSH, permissionInfo);
-
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-    assertThrows(
-        BadRequestException.class,
-        () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsUser() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultGlobalCapabilitiesAccessSectionInfo();
-
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    requestScopeOperations.setApiUser(user.id());
-    assertThrows(
-        AuthException.class, () -> gApi.projects().name(allProjects.get()).access(accessInput));
-  }
-
-  @Test
-  public void removeGlobalCapabilityAsAdmin() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = newAccessSectionInfo();
-
-    PermissionInfo permissionInfo = newPermissionInfo();
-    permissionInfo.rules.put(adminGroupUuid().get(), null);
-    accessSectionInfo.permissions.put(GlobalCapability.ACCESS_DATABASE, permissionInfo);
-
-    // Add and validate first as removing existing privileges such as
-    // administrateServer would break upcoming tests
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    ProjectAccessInfo updatedProjectAccessInfo =
-        gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsAtLeastElementsIn(accessSectionInfo.permissions.keySet());
-
-    // Remove
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSectionInfo);
-
-    updatedProjectAccessInfo = gApi.projects().name(allProjects.get()).access(accessInput);
-    assertThatMap(updatedProjectAccessInfo.local.get(AccessSection.GLOBAL_CAPABILITIES).permissions)
-        .keys()
-        .containsNoneIn(accessSectionInfo.permissions.keySet());
-  }
-
-  @Test
-  public void unknownPermissionRemainsUnchanged() throws Exception {
-    String access = "access";
-    String unknownPermission = "unknownPermission";
-    String registeredUsers = "group Registered Users";
-    String refsFor = "refs/for/*";
-    // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
-
-    // Fetch permission ref
-    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
-    allProjectsRepo.reset("cfg");
-
-    // Load current permissions
-    String config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-
-    // Append and push unknown permission
-    Config cfg = new Config();
-    cfg.fromText(config);
-    cfg.setString(access, refsFor, unknownPermission, registeredUsers);
-    config = cfg.toText();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
-    push.to(RefNames.REFS_CONFIG).assertOkStatus();
-
-    // Verify that unknownPermission is present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
-
-    // Make permission change through API
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-    accessInput.add.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-    accessInput.add.clear();
-    accessInput.remove.put(refsFor, accessSectionInfo);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Verify that unknownPermission is still present
-    config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-    cfg.fromText(config);
-    assertThat(cfg).stringValue(access, refsFor, unknownPermission).isEqualTo(registeredUsers);
-  }
-
-  @Test
-  public void allUsersCanOnlyInheritFromAllProjects() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    accessInput.parent = project.get();
-    BadRequestException thrown =
-        assertThrows(
-            BadRequestException.class,
-            () -> gApi.projects().name(allUsers.get()).access(accessInput));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(allUsers.get() + " must inherit from " + allProjects.get());
-  }
-
-  @Test
-  public void syncCreateGroupPermission_addAndRemoveCreateGroupCapability() throws Exception {
-    // Grant CREATE_GROUP to Registered Users
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-    PermissionInfo createGroup = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
-    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThatMap(rules).values().containsExactly(pri);
-
-    // Revoke the permission
-    accessInput.add.clear();
-    accessInput.remove.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permission was synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local2 = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local2).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions2 = local2.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions2).keys().containsExactly(Permission.READ);
-  }
-
-  @Test
-  public void syncCreateGroupPermission_addCreateGroupCapabilityToMultipleGroups()
-      throws Exception {
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-
-    // Grant CREATE_GROUP to Registered Users
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-    PermissionInfo createGroup = newPermissionInfo();
-    createGroup.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Grant CREATE_GROUP to Administrators
-    accessInput = newProjectAccessInput();
-    accessSection = newAccessSectionInfo();
-    createGroup = newPermissionInfo();
-    createGroup.rules.put(adminGroupUuid().get(), pri);
-    accessSection.permissions.put(GlobalCapability.CREATE_GROUP, createGroup);
-    accessInput.add.put(AccessSection.GLOBAL_CAPABILITIES, accessSection);
-    gApi.projects().name(allProjects.get()).access(accessInput);
-
-    // Assert that the permissions were synced from All-Projects (global) to All-Users (ref)
-    Map<String, AccessSectionInfo> local = gApi.projects().name("All-Users").access().local;
-    assertThatMap(local).keys().contains(RefNames.REFS_GROUPS + "*");
-    Map<String, PermissionInfo> permissions = local.get(RefNames.REFS_GROUPS + "*").permissions;
-    // READ is the default permission and should be preserved by the syncer
-    assertThatMap(permissions).keys().containsExactly(Permission.READ, Permission.CREATE);
-    Map<String, PermissionRuleInfo> rules = permissions.get(Permission.CREATE).rules;
-    assertThatMap(rules)
-        .keys()
-        .containsExactly(SystemGroupBackend.REGISTERED_USERS.get(), adminGroupUuid().get());
-    assertThat(rules.get(SystemGroupBackend.REGISTERED_USERS.get())).isEqualTo(pri);
-    assertThat(rules.get(adminGroupUuid().get())).isEqualTo(pri);
-  }
-
-  @Test
-  public void addAccessSectionForInvalidRef() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
-    String invalidRef = Constants.R_HEADS + "stable_*";
-    accessInput.add.put(invalidRef, accessSectionInfo);
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> pApi().access(accessInput));
-    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
-  }
-
-  @Test
-  public void createAccessChangeWithAccessSectionForInvalidRef() throws Exception {
-    ProjectAccessInput accessInput = newProjectAccessInput();
-    AccessSectionInfo accessSectionInfo = createDefaultAccessSectionInfo();
-
-    // 'refs/heads/stable_*' is invalid, correct would be '^refs/heads/stable_.*'
-    String invalidRef = Constants.R_HEADS + "stable_*";
-    accessInput.add.put(invalidRef, accessSectionInfo);
-
-    BadRequestException thrown =
-        assertThrows(BadRequestException.class, () -> pApi().accessChange(accessInput));
-    assertThat(thrown).hasMessageThat().contains("Invalid Name: " + invalidRef);
-  }
-
-  private ProjectApi pApi() throws Exception {
-    return gApi.projects().name(newProjectName.get());
-  }
-
-  @Test
-  public void grantAllowAndDenyForSameGroup() throws Exception {
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-    String access = "access";
-    List<String> allowThenDeny =
-        asList(registeredUsers.toConfigValue(), "deny " + registeredUsers.toConfigValue());
-    // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
-
-    // Fetch permission ref
-    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
-    allProjectsRepo.reset("cfg");
-
-    // Load current permissions
-    String config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-
-    // Append and push allowThenDeny permissions
-    Config cfg = new Config();
-    cfg.fromText(config);
-    cfg.setStringList(access, AccessSection.HEADS, Permission.READ, allowThenDeny);
-    config = cfg.toText();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
-    push.to(RefNames.REFS_CONFIG).assertOkStatus();
-
-    ProjectAccessInfo pai = gApi.projects().name(allProjects.get()).access();
-    Map<String, AccessSectionInfo> local = pai.local;
-    AccessSectionInfo heads = local.get(AccessSection.HEADS);
-    Map<String, PermissionInfo> permissions = heads.permissions;
-    PermissionInfo read = permissions.get(Permission.READ);
-    Map<String, PermissionRuleInfo> rules = read.rules;
-    assertEquals(
-        rules.get(registeredUsers.getUUID().get()),
-        new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false));
-  }
-
-  @Test
-  public void grantDenyAndAllowForSameGroup() throws Exception {
-    GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
-    String access = "access";
-    List<String> denyThenAllow =
-        asList("deny " + registeredUsers.toConfigValue(), registeredUsers.toConfigValue());
-    // Clone repository to forcefully add permission
-    TestRepository<InMemoryRepository> allProjectsRepo = cloneProject(allProjects, admin);
-
-    // Fetch permission ref
-    GitUtil.fetch(allProjectsRepo, "refs/meta/config:cfg");
-    allProjectsRepo.reset("cfg");
-
-    // Load current permissions
-    String config =
-        gApi.projects()
-            .name(allProjects.get())
-            .branch(RefNames.REFS_CONFIG)
-            .file(ProjectConfig.PROJECT_CONFIG)
-            .asString();
-
-    // Append and push denyThenAllow permissions
-    Config cfg = new Config();
-    cfg.fromText(config);
-    cfg.setStringList(access, AccessSection.HEADS, Permission.READ, denyThenAllow);
-    config = cfg.toText();
-    PushOneCommit push =
-        pushFactory.create(
-            admin.newIdent(), allProjectsRepo, "Subject", ProjectConfig.PROJECT_CONFIG, config);
-    push.to(RefNames.REFS_CONFIG).assertOkStatus();
-
-    ProjectAccessInfo pai = gApi.projects().name(allProjects.get()).access();
-    Map<String, AccessSectionInfo> local = pai.local;
-    AccessSectionInfo heads = local.get(AccessSection.HEADS);
-    Map<String, PermissionInfo> permissions = heads.permissions;
-    PermissionInfo read = permissions.get(Permission.READ);
-    Map<String, PermissionRuleInfo> rules = read.rules;
-    assertEquals(
-        rules.get(registeredUsers.getUUID().get()),
-        new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false));
-  }
-
-  private ProjectAccessInput newProjectAccessInput() {
-    ProjectAccessInput p = new ProjectAccessInput();
-    p.add = new HashMap<>();
-    p.remove = new HashMap<>();
-    return p;
-  }
-
-  private PermissionInfo newPermissionInfo() {
-    PermissionInfo p = new PermissionInfo(null, null);
-    p.rules = new HashMap<>();
-    return p;
-  }
-
-  private AccessSectionInfo newAccessSectionInfo() {
-    AccessSectionInfo a = new AccessSectionInfo();
-    a.permissions = new HashMap<>();
-    return a;
-  }
-
-  private AccessSectionInfo createDefaultAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo push = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    push.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(Permission.PUSH, push);
-
-    PermissionInfo codeReview = newPermissionInfo();
-    codeReview.label = LabelId.CODE_REVIEW;
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    codeReview.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-
-    pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    pri.max = 1;
-    pri.min = -1;
-    codeReview.rules.put(SystemGroupBackend.PROJECT_OWNERS.get(), pri);
-    accessSection.permissions.put(Permission.LABEL + LabelId.CODE_REVIEW, codeReview);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createDefaultGlobalCapabilitiesAccessSectionInfo() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo email = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.ALLOW, false);
-    email.rules.put(SystemGroupBackend.REGISTERED_USERS.get(), pri);
-    accessSection.permissions.put(GlobalCapability.EMAIL_REVIEWERS, email);
-
-    return accessSection;
-  }
-
-  private AccessSectionInfo createAccessSectionInfoDenyAll() {
-    AccessSectionInfo accessSection = newAccessSectionInfo();
-
-    PermissionInfo read = newPermissionInfo();
-    PermissionRuleInfo pri = new PermissionRuleInfo(PermissionRuleInfo.Action.DENY, false);
-    read.rules.put(SystemGroupBackend.ANONYMOUS_USERS.get(), pri);
-    accessSection.permissions.put(Permission.READ, read);
-
-    return accessSection;
+  public void listAccess_projectNameAreTrimmed() throws Exception {
+    RestResponse r =
+        adminRestSession.get("/access/?project=" + IdString.fromDecoded(" " + project.get() + " "));
+    r.assertOK();
+    Map<String, ProjectAccessInfo> infoByProject =
+        newGson()
+            .fromJson(r.getReader(), new TypeToken<Map<String, ProjectAccessInfo>>() {}.getType());
+    assertThat(infoByProject.keySet()).containsExactly(project.get());
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
index 6a98b8b..dfe69f9 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateLabelIT.java
@@ -430,6 +430,57 @@
   }
 
   @Test
+  public void createWithCopyCondition() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "is:MAX";
+
+    LabelDefinitionInfo createdLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(createdLabel.copyCondition).isEqualTo("is:MAX");
+  }
+
+  @Test
+  public void createCopyConditionPerformsGroupVisibilityCheckWhenUserInPredicateIsUsed()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "uploaderin:" + administratorsUUID;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // User can't see admin group
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").create(input));
+    assertThat(thrown).hasMessageThat().contains("Group " + administratorsUUID + " not found");
+
+    // Admin can see admin group
+    requestScopeOperations.setApiUser(admin.id());
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").create(input).get();
+    assertThat(updatedLabel.copyCondition).isEqualTo(input.copyCondition);
+  }
+
+  @Test
+  public void createWithInvalidCopyCondition() throws Exception {
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.values = ImmutableMap.of("+1", "Looks Good", " 0", "Don't Know", "-1", "Looks Bad");
+    input.copyCondition = "blarg::asd";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("Bar").create(input));
+    assertThat(thrown).hasMessageThat().contains("unable to parse copy condition");
+  }
+
+  @Test
   public void createWithCopyMaxScore() 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 7090074..5f60250 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/CreateProjectIT.java
@@ -102,14 +102,14 @@
   }
 
   @Test
-  public void createProjectHttpWhenProjectAlreadyExists_Conflict() throws Exception {
+  public void createProjectHttpWhenProjectAlreadyExists_conflict() throws Exception {
     adminRestSession.put("/projects/" + allProjects.get()).assertConflict();
   }
 
   @Test
-  public void createProjectHttpWhenProjectAlreadyExists_PreconditionFailed() throws Exception {
+  public void createProjectHttpWhenProjectAlreadyExists_preconditionFailed() throws Exception {
     adminRestSession
-        .putWithHeader(
+        .putWithHeaders(
             "/projects/" + allProjects.get(), new BasicHeader(HttpHeaders.IF_NONE_MATCH, "*"))
         .assertPreconditionFailed();
   }
@@ -140,7 +140,7 @@
 
   @Test
   @UseLocalDisk
-  public void createProjectHttpWithUnreasonableName_BadRequest() throws Exception {
+  public void createProjectHttpWithUnreasonableName_badRequest() throws Exception {
     ImmutableList<String> forbiddenStrings =
         ImmutableList.of(
             "/../", "/./", "//", ".git/", "?", "%", "*", ":", "<", ">", "|", "$", "/+", "~");
@@ -153,14 +153,14 @@
   }
 
   @Test
-  public void createProjectHttpWithNameMismatch_BadRequest() throws Exception {
+  public void createProjectHttpWithNameMismatch_badRequest() throws Exception {
     ProjectInput in = new ProjectInput();
     in.name = name("otherName");
     adminRestSession.put("/projects/" + name("someName"), in).assertBadRequest();
   }
 
   @Test
-  public void createProjectHttpWithInvalidRefName_BadRequest() throws Exception {
+  public void createProjectHttpWithInvalidRefName_badRequest() throws Exception {
     ProjectInput in = new ProjectInput();
     in.branches = Collections.singletonList(name("invalid ref name"));
     adminRestSession.put("/projects/" + name("newProject"), in).assertBadRequest();
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
index c2db9f1..491a0d5 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/DeleteLabelIT.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 
@@ -109,4 +110,16 @@
             projectOperations.project(allProjects).getHead(RefNames.REFS_CONFIG).getShortMessage())
         .isEqualTo("Delete Code-Review label");
   }
+
+  @Test
+  public void deletedLabelDoesNotThrowExceptions_approvalInference() throws Exception {
+    String changeId = createChange().getChangeId();
+    approve(changeId);
+    gApi.projects().name(allProjects.get()).label(LabelId.CODE_REVIEW).delete();
+
+    amendChange(changeId);
+
+    // Assert no throws.
+    gApi.changes().id(changeId).get(DETAILED_LABELS);
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
index 74ba48e..fa8b79a 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/FilesInCommitIT.java
@@ -36,7 +36,7 @@
 
   @Before
   public void setUp() throws Exception {
-    baseConfig.setString("cache", "diff", "timeout", "1 minute");
+    baseConfig.setString("cache", "git_file_diff", "timeout", "1 minute");
 
     ObjectId headCommit = testRepo.getRepository().resolve("HEAD");
     addCommit(
diff --git a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
index 2e68b54..b4938c1 100644
--- a/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/rest/project/SetLabelIT.java
@@ -516,6 +516,83 @@
   }
 
   @Test
+  public void setCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "is:MAX";
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isEqualTo("is:MAX");
+  }
+
+  @Test
+  public void setCopyConditionPerformsGroupVisibilityCheckWhenUserInPredicateIsUsed()
+      throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "uploaderin:" + administratorsUUID;
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allow(Permission.OWNER).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+    // User can't see admin group
+    requestScopeOperations.setApiUser(user.id());
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").update(input));
+    assertThat(thrown).hasMessageThat().contains("Group " + administratorsUUID + " not found");
+
+    // Admin can see admin group
+    requestScopeOperations.setApiUser(admin.id());
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isEqualTo(input.copyCondition);
+  }
+
+  @Test
+  public void setInvalidCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.copyCondition = "foo:::bar";
+
+    BadRequestException thrown =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.projects().name(project.get()).label("foo").update(input));
+    assertThat(thrown).hasMessageThat().contains("unable to parse copy condition");
+  }
+
+  @Test
+  public void unsetCopyCondition() throws Exception {
+    configLabel("foo", LabelFunction.NO_OP);
+    try (ProjectConfigUpdate u = updateProject(project)) {
+      u.getConfig().updateLabelType("foo", lt -> lt.setCopyCondition("is:MAX"));
+      u.save();
+    }
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition)
+        .isEqualTo("is:MAX");
+
+    LabelDefinitionInput input = new LabelDefinitionInput();
+    input.unsetCopyCondition = true;
+
+    LabelDefinitionInfo updatedLabel =
+        gApi.projects().name(project.get()).label("foo").update(input);
+    assertThat(updatedLabel.copyCondition).isNull();
+
+    assertThat(gApi.projects().name(project.get()).label("foo").get().copyCondition).isNull();
+  }
+
+  @Test
   public void setCopyMinScore() throws Exception {
     configLabel("foo", LabelFunction.NO_OP);
     assertThat(gApi.projects().name(project.get()).label("foo").get().copyMinScore).isNull();
diff --git a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
index f98fb45..55735fc 100644
--- a/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
+++ b/javatests/com/google/gerrit/acceptance/rest/util/RestApiCallHelper.java
@@ -29,13 +29,13 @@
 /** Helper to execute REST API calls using the HTTP client. */
 @Ignore
 public class RestApiCallHelper {
-  /** @see #execute(RestSession, List, BeforeRestCall, String...) */
+  /** See {@link #execute(RestSession, List, BeforeRestCall, String...)} */
   public static void execute(RestSession restSession, List<RestCall> restCalls, String... args)
       throws Exception {
     execute(restSession, restCalls, () -> {}, args);
   }
 
-  /** @see #execute(RestSession, List, BeforeRestCall, String...) */
+  /** See {@link #execute(RestSession, RestCall, String...)} */
   public static void execute(
       RestSession restSession,
       List<RestCall> restCalls,
diff --git a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
index c7beb2d..0cfa0f8 100644
--- a/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/account/AccountResolverIT.java
@@ -23,6 +23,7 @@
 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.account.AccountOperations;
 import com.google.gerrit.acceptance.testsuite.account.TestAccount;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -160,6 +161,22 @@
   }
 
   @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  public void byUsernameCaseInsensitive() throws Exception {
+    String existingUsername = "myusername";
+    Account.Id idWithUsername = accountOperations.newAccount().username(existingUsername).create();
+
+    String existingMixedCaseUsername = "MyMixedCaseUsername";
+    Account.Id idWithMixedCaseUsername =
+        accountOperations.newAccount().username(existingMixedCaseUsername).create();
+
+    assertThat(resolve(existingUsername)).containsExactly(idWithUsername);
+    assertThat(resolve(existingMixedCaseUsername)).containsExactly(idWithMixedCaseUsername);
+    assertThat(resolve(existingMixedCaseUsername.toLowerCase()))
+        .containsExactly(idWithMixedCaseUsername);
+  }
+
+  @Test
   public void byNameAndEmail() throws Exception {
     String email = name("user@example.com");
     Account.Id idWithEmail = accountOperations.newAccount().preferredEmail(email).create();
diff --git a/javatests/com/google/gerrit/acceptance/server/account/externalids/BUILD b/javatests/com/google/gerrit/acceptance/server/account/externalids/BUILD
new file mode 100644
index 0000000..32676fe
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/externalids/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_externalids",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
new file mode 100644
index 0000000..4eac16f
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/account/externalids/OnlineExternalIdCaseSensivityMigratorIT.java
@@ -0,0 +1,293 @@
+// 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.account.externalids;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.server.account.externalids.DuplicateExternalIdKeyException;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdCaseSensitivityMigrator;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigratiorExecutor;
+import com.google.gerrit.server.account.externalids.OnlineExternalIdCaseSensivityMigrator;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Module;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.concurrent.ExecutorService;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class OnlineExternalIdCaseSensivityMigratorIT extends AbstractDaemonTest {
+  private Account.Id accountId = Account.id(66);
+  private boolean isUserNameCaseInsensitive = false;
+
+  @Inject private ExternalIdNotes.Factory externalIdNotesFactory;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
+  @Inject private ExternalIdFactory externalIdFactory;
+  @Inject private OnlineExternalIdCaseSensivityMigrator objectUnderTest;
+
+  @Override
+  public Module createModule() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        install(new FactoryModuleBuilder().build(ExternalIdCaseSensitivityMigrator.Factory.class));
+        bind(ExecutorService.class)
+            .annotatedWith(OnlineExternalIdCaseSensivityMigratiorExecutor.class)
+            .toInstance(MoreExecutors.newDirectExecutorService());
+      }
+    };
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldMigrateExternalId() throws IOException, ConfigInvalidException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isFalse();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldNotThrowExceptionDuringTheMigrationForExternalIdsWithCaseInsensitiveSha1()
+      throws IOException, ConfigInvalidException {
+
+    final boolean caseInsensitiveUserName = true;
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, caseInsensitiveUserName);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, caseInsensitiveUserName);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+
+      objectUnderTest.migrate();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldNotCreateDuplicateExternaIdNotesWhenUpdatingAccount()
+      throws IOException, ConfigInvalidException {
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_GERRIT, "JonDoe", accountId, isUserNameCaseInsensitive);
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      ExternalId extId =
+          externalIdFactory.create(
+              externalIdKeyFactory.create(SCHEME_USERNAME, "JonDoe", true),
+              accountId,
+              "test@email.com",
+              "w1m9Bg85GQ4hijLNxW+6xAfj4r9wyk9rzVQelIHxuQ");
+      extIdNotes.upsert(extId);
+      extIdNotes.commit(md);
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isFalse();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").get().email())
+          .isEqualTo("test@email.com");
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_GERRIT, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isFalse();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").get().email())
+          .isEqualTo("test@email.com");
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void caseInsensitivityShouldWorkAfterMigration()
+      throws IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(
+              getExternalIdWithCaseInsensitive(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent())
+          .isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldThrowExceptionWhenDuplicateKeys() throws IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "jondoe", Account.id(67), isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isTrue();
+
+      assertThrows(DuplicateExternalIdKeyException.class, () -> objectUnderTest.migrate());
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "false")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "true")
+  public void shouldSkipMigrationWhenUserNameCaseInsensitiveIsSetToFalse()
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "JonDoe").isPresent()).isTrue();
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+    }
+  }
+
+  @Test
+  @GerritConfig(name = "auth.userNameCaseInsensitive", value = "true")
+  @GerritConfig(name = "auth.userNameCaseInsensitiveMigrationMode", value = "false")
+  public void shouldSkipMigrationWhenUserNameCaseInsensitiveMigrationModeIsSetToFalse()
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+
+      createExternalId(
+          md, extIdNotes, SCHEME_USERNAME, "JonDoe", accountId, isUserNameCaseInsensitive);
+
+      objectUnderTest.migrate();
+
+      extIdNotes = externalIdNotesFactory.load(allUsersRepo);
+      assertThat(getExactExternalId(extIdNotes, SCHEME_USERNAME, "jondoe").isPresent()).isFalse();
+    }
+  }
+
+  protected Optional<ExternalId> getExternalIdWithCaseInsensitive(
+      ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return extIdNotes.get(externalIdKeyFactory.create(scheme, id, true));
+  }
+
+  protected Optional<ExternalId> getExactExternalId(
+      ExternalIdNotes extIdNotes, String scheme, String id)
+      throws IOException, ConfigInvalidException {
+    return extIdNotes.get(externalIdKeyFactory.create(scheme, id, false));
+  }
+
+  protected void createExternalId(
+      MetaDataUpdate md,
+      ExternalIdNotes extIdNotes,
+      String scheme,
+      String id,
+      Account.Id accountId,
+      boolean isUserNameCaseInsensitive)
+      throws IOException {
+    ExternalId extId =
+        externalIdFactory.create(
+            externalIdKeyFactory.create(scheme, id, isUserNameCaseInsensitive), accountId);
+    extIdNotes.insert(extId);
+    extIdNotes.commit(md);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
index 9bd8e9c..a2765d9 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentContextIT.java
@@ -30,6 +30,8 @@
 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.ChangeInfo;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.common.ContextLineInfo;
 import com.google.gerrit.server.change.FileContentUtil;
@@ -88,6 +90,28 @@
   }
 
   @Test
+  public void commentContextForRootCommitOnParentSideReturnsEmptyContext() throws Exception {
+    // Create a change in a new branch, making the patchset commit a root commit
+    ChangeInfo changeInfo = createChangeInNewBranch("newBranch");
+    String changeId = changeInfo.changeId;
+    String revision = changeInfo.revisions.keySet().iterator().next();
+
+    // Write a comment on the parent side of the commit message. Set parent=1 because if unset, our
+    // handler in PostReview assumes we want to write on the auto-merge commit and fails the
+    // pre-condition.
+    CommentInput comment = CommentsUtil.newComment(COMMIT_MSG, Side.PARENT, 0, "comment", false);
+    comment.parent = 1;
+    CommentsUtil.addComments(gApi, changeId, revision, comment);
+
+    List<CommentInfo> comments =
+        gApi.changes().id(changeId).commentsRequest().withContext(true).getAsList();
+    assertThat(comments).hasSize(1);
+    CommentInfo c = comments.stream().collect(MoreCollectors.onlyElement());
+    assertThat(c.commitId).isEqualTo(ObjectId.zeroId().name());
+    assertThat(c.contextLines).isEmpty();
+  }
+
+  @Test
   public void commentContextForCommitMessageForLineComment() throws Exception {
     PushOneCommit.Result result =
         createChange(testRepo, "master", SUBJECT, FILE_NAME, FILE_CONTENT, "topic");
@@ -401,7 +425,7 @@
   }
 
   @Test
-  public void commentContextReturnsCorrectContentType_Java() throws Exception {
+  public void commentContextReturnsCorrectContentType_java() throws Exception {
     String javaContent =
         "public class Main {\n"
             + " public static void main(String[]args){\n"
@@ -424,7 +448,7 @@
   }
 
   @Test
-  public void commentContextReturnsCorrectContentType_Cpp() throws Exception {
+  public void commentContextReturnsCorrectContentType_cpp() throws Exception {
     String cppContent =
         "#include <iostream>\n"
             + "\n"
@@ -445,6 +469,52 @@
     assertThat(comments.get(0).sourceContentType).isEqualTo("text/x-c++src");
   }
 
+  @Test
+  public void listChangeCommentsWithContextEnabled_twoRangeCommentsWithTheSameContext()
+      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.addCommentOnRange(gApi, r2, "looks good", createCommentRange(2, 5));
+    CommentsUtil.addCommentOnRange(gApi, r2, "are you sure?", 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("looks good"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+
+    assertThat(
+            comments.stream()
+                .filter(c -> c.message.equals("are you sure?"))
+                .collect(MoreCollectors.onlyElement())
+                .contextLines)
+        .containsExactlyElementsIn(
+            createContextLines("2", "line_2", "3", "line_3", "4", "line_4", "5", "line_5"));
+  }
+
   private String createChangeWithContent(String fileName, String fileContent, int line)
       throws Exception {
     PushOneCommit.Result result =
@@ -522,4 +592,13 @@
     }
     return result;
   }
+
+  private ChangeInfo createChangeInNewBranch(String branchName) throws Exception {
+    ChangeInput in = new ChangeInput();
+    in.project = project.get();
+    in.branch = branchName;
+    in.newBranch = true;
+    in.subject = "New changes";
+    return gApi.changes().create(in).get();
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
index b29c031..80cdad8 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -224,12 +224,14 @@
     String ps1 = result.getCommit().name();
 
     CommentInput comment =
-        CommentsUtil.newCommentWithOnlyMandatoryFields(PATCHSET_LEVEL, "comment");
+        CommentsUtil.newCommentWithOnlyMandatoryFields(
+            PATCHSET_LEVEL, "The change looks good, LGTM");
     CommentsUtil.addComments(gApi, changeId, ps1, comment);
 
     String emailBody = Iterables.getOnlyElement(email.getMessages()).body();
     assertThat(emailBody).contains("Patchset");
     assertThat(emailBody).doesNotContain("/PATCHSET_LEVEL");
+    assertThat(emailBody).contains("The change looks good, LGTM");
   }
 
   @Test
@@ -1270,6 +1272,223 @@
   }
 
   @Test
+  public void publishPartialDraftsAllRevisions() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                "File content in PS2\n",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentInfo draftOnePs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    CommentInfo draftTwoPs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 15), "comment 2"));
+
+    CommentInfo draftThreePs2 =
+        addDraft(
+            r1.getChangeId(),
+            r2.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH_ALL_REVISIONS,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draftOnePs1.id, draftThreePs2.id));
+    gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
+
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList().stream()
+                .map(c -> c.id))
+        .containsExactly(draftOnePs1.id, draftThreePs2.id);
+
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).draftsRequest().getAsList().stream().map(c -> c.id))
+        .containsExactly(draftTwoPs1.id);
+  }
+
+  @Test
+  public void publishPartialDrafts_whenDraftHandlingIsKeep_doesNotPublishDrafts() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    CommentInfo draftPs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.KEEP,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draftPs1.id));
+
+    gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
+
+    assertThat(gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList()).isEmpty();
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).draftsRequest().getAsList().stream().map(c -> c.id))
+        .containsExactly(draftPs1.id);
+  }
+
+  @Test
+  public void publishPartialDrafts_whenDraftHandlingIsPublish_isNotAllowedForOtherRevisions()
+      throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    PushOneCommit.Result r2 =
+        pushFactory
+            .create(
+                admin.newIdent(),
+                testRepo,
+                SUBJECT,
+                FILE_NAME,
+                "File content in PS2\n",
+                r1.getChangeId())
+            .to("refs/for/master");
+
+    CommentInfo draftPs1 =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    addDraft(
+        r1.getChangeId(),
+        r2.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(3, 12), "comment 3"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draftPs1.id));
+
+    // Request to publish draft of PS1, while sending review for PS2 with DraftHandling=PUBLISH
+    Exception error =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().review(reviewInput));
+    assertThat(error)
+        .hasMessageThat()
+        .isEqualTo(
+            String.format(
+                "Draft comments for other revisions cannot be published when DraftHandling = PUBLISH."
+                    + " (draft IDs: [%s])",
+                draftPs1.id));
+  }
+
+  @Test
+  public void publishPartialDraftsWithInvalidDraftIdsIsNotAllowed() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    addDraft(
+        r1.getChangeId(),
+        r1.getCommit().getName(),
+        CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH_ALL_REVISIONS,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of("1234"));
+
+    Exception error =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().review(reviewInput));
+    assertThat(error).hasMessageThat().isEqualTo("Non-existing draft IDs: [1234]");
+  }
+
+  @Test
+  public void publishPartialDraftsForAnotherUserIsNotAllowed() throws Exception {
+    pushFactory
+        .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "initial content\n")
+        .to("refs/heads/master");
+
+    PushOneCommit.Result r1 =
+        pushFactory
+            .create(admin.newIdent(), testRepo, SUBJECT, FILE_NAME, "File content in PS1\n")
+            .to("refs/for/master");
+
+    // Add drafts with user scope
+    requestScopeOperations.setApiUser(accountCreator.user1().id());
+    CommentInfo draft =
+        addDraft(
+            r1.getChangeId(),
+            r1.getCommit().getName(),
+            CommentsUtil.newDraft(FILE_NAME, Side.REVISION, createLineRange(4, 10), "comment 1"));
+
+    ReviewInput reviewInput =
+        createReviewInput(
+            DraftHandling.PUBLISH_ALL_REVISIONS,
+            "review message",
+            /* draftIdsToPublish= */ ImmutableList.of(draft.id));
+
+    // Try to publish the drafts using user2 scope
+    requestScopeOperations.setApiUser(accountCreator.user2().id());
+    Exception error =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r1.getChangeId()).current().review(reviewInput));
+    assertThat(error)
+        .hasMessageThat()
+        .isEqualTo(String.format("Non-existing draft IDs: [%s]", draft.id));
+
+    // Request will succeed if done by user
+    requestScopeOperations.setApiUser(accountCreator.user1().id());
+    gApi.changes().id(r1.getChangeId()).current().review(reviewInput);
+    assertThat(
+            gApi.changes().id(r1.getChangeId()).commentsRequest().getAsList().stream()
+                .map(c -> c.id))
+        .containsExactly(draft.id);
+
+    assertThat(gApi.changes().id(r1.getChangeId()).draftsRequest().getAsList()).isEmpty();
+  }
+
+  @Test
   public void commentTags() throws Exception {
     PushOneCommit.Result r = createChange();
 
@@ -1789,4 +2008,13 @@
     to.range = from.range;
     to.inReplyTo = from.inReplyTo;
   }
+
+  private ReviewInput createReviewInput(
+      DraftHandling handling, String message, List<String> draftIdsToPublish) {
+    ReviewInput reviewInput = new ReviewInput();
+    reviewInput.drafts = handling;
+    reviewInput.message = message;
+    reviewInput.draftIdsToPublish = draftIdsToPublish;
+    return reviewInput;
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
index e39f967..9d821b7 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/ConsistencyCheckerIT.java
@@ -257,7 +257,6 @@
             + "Groups: "
             + rev
             + "\n");
-    indexer.index(c.getProject(), c.getId());
     ChangeNotes notes = changeNotesFactory.create(c.getProject(), c.getId());
 
     FixInput fix = new FixInput();
@@ -817,8 +816,6 @@
             + "Subject: "
             + subject
             + "\n");
-    indexer.index(c.getProject(), c.getId());
-
     return ps;
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
index 74dfa04..e778a5c 100644
--- a/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/change/GetRelatedIT.java
@@ -45,9 +45,9 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.EditInfo;
 import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.server.change.GetRelatedChangesUtil;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.restapi.change.ChangesCollection;
-import com.google.gerrit.server.restapi.change.GetRelated;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
@@ -573,7 +573,7 @@
 
     ChangeData cd = getChange(last);
     assertThat(cd.patchSets()).hasSize(n);
-    assertThat(GetRelated.getAllGroups(cd.notes(), psUtil)).hasSize(n);
+    assertThat(GetRelatedChangesUtil.getAllGroups(cd.notes().getPatchSets().values())).hasSize(n);
 
     assertRelated(cd.change().currentPatchSetId());
   }
diff --git a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java b/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
deleted file mode 100644
index b23f9a3..0000000
--- a/javatests/com/google/gerrit/acceptance/server/change/PatchListCacheIT.java
+++ /dev/null
@@ -1,326 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT 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.GitUtil.getChangeId;
-import static com.google.gerrit.acceptance.GitUtil.pushHead;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.cache.Cache;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.acceptance.AbstractDaemonTest;
-import com.google.gerrit.acceptance.NoHttpd;
-import com.google.gerrit.entities.Patch;
-import com.google.gerrit.entities.Patch.ChangeType;
-import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
-import com.google.gerrit.server.patch.IntraLineDiff;
-import com.google.gerrit.server.patch.IntraLineDiffArgs;
-import com.google.gerrit.server.patch.IntraLineDiffKey;
-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.Text;
-import com.google.inject.Inject;
-import com.google.inject.name.Named;
-import java.lang.reflect.Field;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Optional;
-import java.util.Set;
-import org.eclipse.jgit.diff.Edit;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Test;
-
-@NoHttpd
-public class PatchListCacheIT extends AbstractDaemonTest {
-  private static String SUBJECT_1 = "subject 1";
-  private static String SUBJECT_2 = "subject 2";
-  private static String SUBJECT_3 = "subject 3";
-  private static String FILE_A = "a.txt";
-  private static String FILE_B = "b.txt";
-  private static String FILE_C = "c.txt";
-  private static String FILE_D = "d.txt";
-
-  @Inject private PatchListCache patchListCache;
-
-  @Inject
-  @Named("diff")
-  private Cache<PatchListKey, PatchList> abstractPatchListCache;
-
-  @Test
-  public void ensureLegacyBackendIsUsedForFileCacheBackend() throws Exception {
-    Field fileCacheField = patchListCache.getClass().getDeclaredField("fileCache");
-    fileCacheField.setAccessible(true);
-    // Use the reflection to access "localCache" field that is only present in Guava backend.
-    assertThat(
-            Arrays.stream(fileCacheField.get(patchListCache).getClass().getDeclaredFields())
-                .anyMatch(f -> f.getName().equals("localCache")))
-        .isTrue();
-
-    // intraCache (and all other cache backends) should use Caffeine backend.
-    Field intraCacheField = patchListCache.getClass().getDeclaredField("intraCache");
-    intraCacheField.setAccessible(true);
-    assertThat(
-            Arrays.stream(intraCacheField.get(patchListCache).getClass().getDeclaredFields())
-                .noneMatch(f -> f.getName().equals("localCache")))
-        .isTrue();
-  }
-
-  @Test
-  public void listPatchesAgainstBase() throws Exception {
-    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
-    pushHead(testRepo, "refs/heads/master", false);
-
-    // Change 1, 1 (+FILE_A, -FILE_D)
-    RevCommit c =
-        commitBuilder().add(FILE_A, "1").rm(FILE_D).message(SUBJECT_2).insertChangeId().create();
-    String id = getChangeId(testRepo, c).get();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Compare Change 1,1 with Base (+FILE_A, -FILE_D)
-    List<PatchListEntry> entries = getCurrentPatches(id);
-    assertThat(entries).hasSize(3);
-    assertAdded(Patch.COMMIT_MSG, entries.get(0));
-    assertAdded(FILE_A, entries.get(1));
-    assertDeleted(FILE_D, entries.get(2));
-
-    // Change 1,2 (+FILE_A, +FILE_B, -FILE_D)
-    amendBuilder().add(FILE_B, "2").create();
-    pushHead(testRepo, "refs/for/master", false);
-    entries = getCurrentPatches(id);
-
-    // Compare Change 1,2 with Base (+FILE_A, +FILE_B, -FILE_D)
-    assertThat(entries).hasSize(4);
-    assertAdded(Patch.COMMIT_MSG, entries.get(0));
-    assertAdded(FILE_A, entries.get(1));
-    assertAdded(FILE_B, entries.get(2));
-    assertDeleted(FILE_D, entries.get(3));
-  }
-
-  @Test
-  public void listPatchesAgainstBaseWithRebase() throws Exception {
-    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
-    pushHead(testRepo, "refs/heads/master", false);
-
-    // Change 1,1 (+FILE_A, -FILE_D)
-    RevCommit c = commitBuilder().add(FILE_A, "1").rm(FILE_D).message(SUBJECT_2).create();
-    String id = getChangeId(testRepo, c).get();
-    pushHead(testRepo, "refs/for/master", false);
-    List<PatchListEntry> entries = getCurrentPatches(id);
-    assertThat(entries).hasSize(3);
-    assertAdded(Patch.COMMIT_MSG, entries.get(0));
-    assertAdded(FILE_A, entries.get(1));
-    assertDeleted(FILE_D, entries.get(2));
-
-    // Change 2,1 (+FILE_B)
-    testRepo.reset("HEAD~1");
-    commitBuilder().add(FILE_B, "2").message(SUBJECT_3).create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Change 1,2 (+FILE_A, -FILE_D))
-    testRepo.cherryPick(c);
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Compare Change 1,2 with Base (+FILE_A, -FILE_D))
-    entries = getCurrentPatches(id);
-    assertThat(entries).hasSize(3);
-    assertAdded(Patch.COMMIT_MSG, entries.get(0));
-    assertAdded(FILE_A, entries.get(1));
-    assertDeleted(FILE_D, entries.get(2));
-  }
-
-  @Test
-  public void listPatchesAgainstOtherPatchSet() throws Exception {
-    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
-    pushHead(testRepo, "refs/heads/master", false);
-
-    // Change 1,1 (+FILE_A, +FILE_C, -FILE_D)
-    RevCommit a =
-        commitBuilder().add(FILE_A, "1").add(FILE_C, "3").rm(FILE_D).message(SUBJECT_2).create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Change 1,2 (+FILE_A, +FILE_B, -FILE_D)
-    RevCommit b = amendBuilder().add(FILE_B, "2").rm(FILE_C).create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Compare Change 1,1 with Change 1,2 (+FILE_B, -FILE_C)
-    List<PatchListEntry> entries = getPatches(a, b);
-    assertThat(entries).hasSize(3);
-    assertModified(Patch.COMMIT_MSG, entries.get(0));
-    assertAdded(FILE_B, entries.get(1));
-    assertDeleted(FILE_C, entries.get(2));
-
-    // Compare Change 1,2 with Change 1,1 (-FILE_B, +FILE_C)
-    List<PatchListEntry> entriesReverse = getPatches(b, a);
-    assertThat(entriesReverse).hasSize(3);
-    assertModified(Patch.COMMIT_MSG, entriesReverse.get(0));
-    assertDeleted(FILE_B, entriesReverse.get(1));
-    assertAdded(FILE_C, entriesReverse.get(2));
-  }
-
-  @Test
-  public void listPatchesAgainstOtherPatchSetWithRebase() throws Exception {
-    commitBuilder().add(FILE_D, "4").message(SUBJECT_1).create();
-    pushHead(testRepo, "refs/heads/master", false);
-
-    // Change 1,1 (+FILE_A, -FILE_D)
-    RevCommit a = commitBuilder().add(FILE_A, "1").rm(FILE_D).message(SUBJECT_2).create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Change 2,1 (+FILE_B)
-    testRepo.reset("HEAD~1");
-    commitBuilder().add(FILE_B, "2").message(SUBJECT_3).create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Change 1,2 (+FILE_A, +FILE_C, -FILE_D)
-    testRepo.cherryPick(a);
-    RevCommit b = amendBuilder().add(FILE_C, "2").create();
-    pushHead(testRepo, "refs/for/master", false);
-
-    // Compare Change 1,1 with Change 1,2 (+FILE_C)
-    List<PatchListEntry> entries = getPatches(a, b);
-    assertThat(entries).hasSize(2);
-    assertModified(Patch.COMMIT_MSG, entries.get(0));
-    assertAdded(FILE_C, entries.get(1));
-
-    // Compare Change 1,2 with Change 1,1 (-FILE_C)
-    List<PatchListEntry> entriesReverse = getPatches(b, a);
-    assertThat(entriesReverse).hasSize(2);
-    assertModified(Patch.COMMIT_MSG, entriesReverse.get(0));
-    assertDeleted(FILE_C, entriesReverse.get(1));
-  }
-
-  @Test
-  public void harmfulMutationsOfEditsAreNotPossibleForPatchListEntry() throws Exception {
-    RevCommit commit =
-        commitBuilder().add("a.txt", "First line\nSecond line\n").message(SUBJECT_1).create();
-    pushHead(testRepo, "refs/heads/master", false);
-
-    PatchListKey diffKey = PatchListKey.againstDefaultBase(commit.copy(), Whitespace.IGNORE_NONE);
-    PatchList patchList = patchListCache.get(diffKey, project);
-
-    PatchListEntry patchListEntry = getEntryFor(patchList, "a.txt");
-    Edit outputEdit = Iterables.getOnlyElement(patchListEntry.getEdits());
-    Edit originalEdit =
-        new Edit(
-            outputEdit.getBeginA(),
-            outputEdit.getEndA(),
-            outputEdit.getBeginB(),
-            outputEdit.getEndB());
-
-    outputEdit.shift(5);
-
-    assertThat(patchListEntry.getEdits()).containsExactly(originalEdit);
-  }
-
-  @Test
-  public void harmfulMutationsOfEditsAreNotPossibleForIntraLineDiffArgsAndCachedValue() {
-    String a = "First line\nSecond line\n";
-    String b = "1st line\n2nd line\n";
-    Text aText = new Text(a.getBytes(UTF_8));
-    Text bText = new Text(b.getBytes(UTF_8));
-    Edit inputEdit = new Edit(0, 2, 0, 2);
-    List<Edit> inputEdits = new ArrayList<>(ImmutableList.of(inputEdit));
-    Set<Edit> inputEditsDueToRebase = new HashSet<>(ImmutableSet.of(inputEdit));
-
-    IntraLineDiffKey diffKey =
-        IntraLineDiffKey.create(ObjectId.zeroId(), ObjectId.zeroId(), Whitespace.IGNORE_NONE);
-    IntraLineDiffArgs diffArgs =
-        IntraLineDiffArgs.create(
-            aText,
-            bText,
-            inputEdits,
-            inputEditsDueToRebase,
-            project,
-            ObjectId.zeroId(),
-            "file.txt");
-    IntraLineDiff intraLineDiff = patchListCache.getIntraLineDiff(diffKey, diffArgs);
-
-    Edit outputEdit = Iterables.getOnlyElement(intraLineDiff.getEdits());
-
-    outputEdit.shift(5);
-    inputEdit.shift(7);
-    inputEdits.add(new Edit(43, 47, 50, 51));
-    inputEditsDueToRebase.add(new Edit(53, 57, 60, 61));
-
-    Edit originalEdit = new Edit(0, 2, 0, 2);
-    assertThat(diffArgs.edits()).containsExactly(originalEdit);
-    assertThat(diffArgs.editsDueToRebase()).containsExactly(originalEdit);
-    assertThat(intraLineDiff.getEdits()).containsExactly(originalEdit);
-  }
-
-  @Test
-  public void largeObjectTombstoneGetsCached() {
-    PatchListKey key = PatchListKey.againstDefaultBase(ObjectId.zeroId(), Whitespace.IGNORE_ALL);
-    PatchListCacheImpl.LargeObjectTombstone tombstone =
-        new PatchListCacheImpl.LargeObjectTombstone();
-    abstractPatchListCache.put(key, tombstone);
-    assertThat(abstractPatchListCache.getIfPresent(key)).isSameInstanceAs(tombstone);
-  }
-
-  private static void assertAdded(String expectedNewName, PatchListEntry e) {
-    assertName(expectedNewName, e);
-    assertThat(e.getChangeType()).isEqualTo(ChangeType.ADDED);
-  }
-
-  private static void assertModified(String expectedNewName, PatchListEntry e) {
-    assertName(expectedNewName, e);
-    assertThat(e.getChangeType()).isEqualTo(ChangeType.MODIFIED);
-  }
-
-  private static void assertDeleted(String expectedNewName, PatchListEntry e) {
-    assertName(expectedNewName, e);
-    assertThat(e.getChangeType()).isEqualTo(ChangeType.DELETED);
-  }
-
-  private static void assertName(String expectedNewName, PatchListEntry e) {
-    assertThat(e.getNewName()).isEqualTo(expectedNewName);
-    assertThat(e.getOldName()).isNull();
-  }
-
-  private List<PatchListEntry> getCurrentPatches(String changeId) throws Exception {
-    return patchListCache.get(getKey(null, getCurrentRevisionId(changeId)), project).getPatches();
-  }
-
-  private List<PatchListEntry> getPatches(ObjectId revisionIdA, ObjectId revisionIdB)
-      throws Exception {
-    return patchListCache.get(getKey(revisionIdA, revisionIdB), project).getPatches();
-  }
-
-  private PatchListKey getKey(ObjectId revisionIdA, ObjectId revisionIdB) {
-    return PatchListKey.againstCommit(revisionIdA, revisionIdB, Whitespace.IGNORE_NONE);
-  }
-
-  private ObjectId getCurrentRevisionId(String changeId) throws Exception {
-    return ObjectId.fromString(gApi.changes().id(changeId).get().currentRevision);
-  }
-
-  private static PatchListEntry getEntryFor(PatchList patchList, String filePath) {
-    Optional<PatchListEntry> patchListEntry =
-        patchList.getPatches().stream()
-            .filter(entry -> entry.getNewName().equals(filePath))
-            .findAny();
-    return patchListEntry.orElseThrow(
-        () -> new IllegalStateException("No PatchListEntry for " + filePath + " exists"));
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
index d767f48..cb1a679 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/AbstractMailIT.java
@@ -93,8 +93,9 @@
    * @param f1 Comment on file one.
    * @return A string with all inline comments and the original quoted email.
    */
-  static String newPlaintextBody(String changeURL, String changeMessage, String c1, String f1) {
-    return (changeMessage == null ? "" : changeMessage + "\n")
+  static String newPlaintextBody(
+      String changeURL, String patchsetLevelComment, String c1, String f1) {
+    return (patchsetLevelComment == null ? "" : patchsetLevelComment + "\n")
         + "> Foo Bar has posted comments on this change. (  \n"
         + "> "
         + changeURL
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
index f267513..85238f8 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ChangeNotificationsIT.java
@@ -41,12 +41,12 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
@@ -89,6 +89,7 @@
         .forUpdate()
         .add(allow(Permission.FORGE_COMMITTER).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.SUBMIT).ref("refs/*").group(REGISTERED_USERS))
+        .add(allow(Permission.SUBMIT_AS).ref("refs/*").group(REGISTERED_USERS))
         .add(allow(Permission.ABANDON).ref("refs/*").group(REGISTERED_USERS))
         .add(allowLabel(LabelId.CODE_REVIEW).ref("refs/*").group(REGISTERED_USERS).range(-2, +2))
         .update();
@@ -273,7 +274,7 @@
   }
 
   /*
-   * AddReviewerSender tests.
+   * ModifyReviewerSender tests (only for additions).
    */
 
   private void addReviewerToReviewableChange(Adder adder) throws Exception {
@@ -300,6 +301,32 @@
     addReviewerToReviewableChange(batch());
   }
 
+  private void addReviewerToIgnoredChange(Adder adder) throws Exception {
+    StagedChange sc = stageReviewableChange();
+    requestScopeOperations.setApiUser(sc.reviewer.id());
+    gApi.changes().id(sc.changeId).ignore(true);
+    TestAccount addedReviewer = accountCreator.create("added", "added@example.com", "added", null);
+    addReviewer(adder, sc.changeId, sc.owner, addedReviewer.email(), CC_ON_OWN_COMMENTS, null);
+
+    assertThat(sender)
+        .sent("newchange", sc)
+        .to(addedReviewer)
+        .cc(sc.owner)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void addReviewerToIgnoredChangeSingly() throws Exception {
+    addReviewerToIgnoredChange(singly());
+  }
+
+  @Test
+  public void addReviewerToIgnoredChangeBatch() throws Exception {
+    addReviewerToIgnoredChange(batch());
+  }
+
   private void addReviewerToReviewableChangeByOwnerCcingSelf(Adder adder) throws Exception {
     StagedChange sc = stageReviewableChange();
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
@@ -421,7 +448,7 @@
     TestAccount reviewer = accountCreator.create("added", "added@example.com", "added", null);
     addReviewer(singly(), sc.changeId, sc.owner, reviewer.email());
     // TODO(dborowitz): In theory this should match the batch case, but we don't currently pass
-    // enough info into AddReviewersEmail#emailReviewers to distinguish the reviewStarted case.
+    // enough info into ModifyReviewersEmail#emailReviewers to distinguish the reviewStarted case.
     // Complicating the emailReviewers arguments is not the answer; this needs to be rewritten.
     // Tolerate the difference for now.
     assertThat(sender).didNotSend();
@@ -582,7 +609,7 @@
 
   private Adder singly(ReviewerState reviewerState) {
     return (String changeId, String reviewer, @Nullable NotifyHandling notify) -> {
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = reviewer;
       in.state = reviewerState;
       if (notify != null) {
@@ -1623,6 +1650,75 @@
     assertThat(sender).didNotSend();
   }
 
+  @Test
+  public void mergeOnBehalfOfEmailEnabled_impersonatedOwnerNotified() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    // If notification is enabled, onBehalfOfUser is always notified.
+    setEmailStrategy(sc.owner, ENABLED);
+    merge(sc.changeId, other, sc.owner, ALL);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfEmailEnabled_impersonatedReviewerNotified() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    // If notification is enabled, onBehalfOfUser is always notified.
+    setEmailStrategy(sc.reviewer, ENABLED);
+    merge(sc.changeId, other, sc.reviewer, ALL);
+    assertThat(sender)
+        .sent("merged", sc)
+        .to(sc.owner)
+        .cc(sc.reviewer, sc.ccer)
+        .cc(StagedUsers.REVIEWER_BY_EMAIL, StagedUsers.CC_BY_EMAIL)
+        .bcc(sc.starrer)
+        .bcc(ALL_COMMENTS, SUBMITTED_CHANGES)
+        .noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfReviewerNotifyOwner_impersonatedReviewerInCC() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(sc.reviewer, ENABLED);
+    // Even though Submit strategy is OWNER, impersonated reviewer is added to CC.
+    merge(sc.changeId, other, sc.reviewer, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).cc(sc.reviewer).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfOtherNotifyOwner_impersonatedOtherInCC() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    // Unrelated impersonated user is added to CC.
+    merge(sc.changeId, sc.reviewer, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).cc(other).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfEmailDisabled_doesNotNotify() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    setEmailStrategy(other, EmailStrategy.DISABLED);
+    merge(sc.changeId, sc.reviewer, other, OWNER);
+    assertThat(sender).sent("merged", sc).to(sc.owner).noOneElse();
+    assertThat(sender).didNotSend();
+  }
+
+  @Test
+  public void mergeOnBehalfOfNotifyNone() throws Exception {
+    StagedChange sc = stageChangeReadyForMerge();
+    merge(sc.changeId, other, sc.owner, NONE);
+    assertThat(sender).didNotSend();
+  }
+
   private void merge(String changeId, TestAccount by) throws Exception {
     merge(changeId, by, ENABLED);
   }
@@ -1648,6 +1744,16 @@
     gApi.changes().id(changeId).current().submit(in);
   }
 
+  private void merge(
+      String changeId, TestAccount by, TestAccount onBehalfOf, @Nullable NotifyHandling notify)
+      throws Exception {
+    requestScopeOperations.setApiUser(by.id());
+    SubmitInput in = new SubmitInput();
+    in.notify = notify;
+    in.onBehalfOf = onBehalfOf.id().toString();
+    gApi.changes().id(changeId).current().submit(in);
+  }
+
   private StagedChange stageChangeReadyForMerge() throws Exception {
     StagedChange sc = stageReviewableChange();
     requestScopeOperations.setApiUser(sc.reviewer.id());
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
index 49b184b..df66e9f 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/ListMailFilterIT.java
@@ -48,7 +48,7 @@
   @GerritConfig(name = "receiveemail.filter.mode", value = "ALLOW")
   @GerritConfig(
       name = "receiveemail.filter.patterns",
-      values = {".+ser@example\\.com", "a@b\\.com"})
+      values = {".+ser1@example\\.com", "a@b\\.com"})
   public void listFilterAllowDoesNotFilterListedUser() throws Exception {
     ChangeInfo changeInfo = createChangeAndReplyByEmail();
     // Check that the comments from the email have been persisted
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
index 6dd2f32..d6fcccc 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailMetadataIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.UseTimezone;
 import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
 import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.mail.MailProcessingUtil;
@@ -114,8 +115,7 @@
     for (Map.Entry<String, Object> entry : want.entrySet()) {
       if (entry.getValue() instanceof String) {
         assertThat(have)
-            .containsEntry(
-                "X-" + entry.getKey(), new EmailHeader.String((String) entry.getValue()));
+            .containsEntry("X-" + entry.getKey(), new StringEmailHeader((String) entry.getValue()));
       } else if (entry.getValue() instanceof Date) {
         assertThat(have)
             .containsEntry("X-" + entry.getKey(), new EmailHeader.Date((Date) entry.getValue()));
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index 5679c41..d4000b3 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -16,6 +16,9 @@
 
 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.block;
+import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.clearInvocations;
 import static org.mockito.Mockito.mock;
@@ -28,7 +31,9 @@
 import com.google.common.collect.Streams;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
-import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
+import com.google.gerrit.entities.Permission;
 import com.google.gerrit.extensions.annotations.Exports;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
@@ -44,6 +49,7 @@
 import com.google.gerrit.mail.MailMessage;
 import com.google.gerrit.mail.MailProcessingUtil;
 import com.google.gerrit.server.mail.receive.MailProcessor;
+import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.gerrit.testing.TestCommentHelper;
 import com.google.inject.Inject;
@@ -61,6 +67,7 @@
   @Inject private MailProcessor mailProcessor;
   @Inject private AccountOperations accountOperations;
   @Inject private TestCommentHelper testCommentHelper;
+  @Inject private ProjectOperations projectOperations;
 
   private static final CommentValidator mockCommentValidator = mock(CommentValidator.class);
 
@@ -91,7 +98,7 @@
   }
 
   @Test
-  public void parseAndPersistChangeMessage() throws Exception {
+  public void parseAndPersistPatchsetLevelComment() throws Exception {
     String changeId = createChangeWithReview();
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
     String ts =
@@ -108,8 +115,19 @@
 
     Collection<ChangeMessageInfo> messages = gApi.changes().id(changeId).get().messages;
     assertThat(messages).hasSize(3);
-    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1:\n\nTest Message");
+    assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1:\n\n(1 comment)");
     assertThat(Iterables.getLast(messages).tag).isEqualTo("mailMessageId=some id");
+    // Assert comment
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    assertThat(comments).hasSize(3);
+    assertThat(comments.get(0).path).isEqualTo(PATCHSET_LEVEL);
+    assertThat(comments.get(0).message).isEqualTo("Test Message");
+    assertThat(comments.get(0).tag).isEqualTo("mailMessageId=some id");
+    assertThat(comments.get(0).parent).isNull();
+    assertThat(comments.get(0).side).isNull();
+    assertThat(comments.get(0).line).isNull();
+    assertThat(comments.get(0).parent).isNull();
+    assertThat(comments.get(0).inReplyTo).isNull();
   }
 
   @Test
@@ -276,6 +294,133 @@
   }
 
   @Test
+  public void sendNotificationOnProjectNotFound() throws Exception {
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(TimeUtil.now(), ZoneId.of("UTC")));
+
+    String changeUrl = canonicalWebUrl.get() + "c/non-existing-project/+/123";
+
+    // Build Message
+    String txt = newPlaintextBody(changeUrl + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(123, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnProjectNotVisible() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Block read permissions on the project.
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnChangeNotFound() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Delete the change so that it's not found.
+    gApi.changes().id(changeId).delete();
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
+  public void sendNotificationOnChangeNotVisible() throws Exception {
+    String changeId = createChangeWithReview();
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(
+                gApi.changes().id(changeId).get().updated.toInstant(), ZoneId.of("UTC")));
+
+    // Make change private so that it's no visible to user.
+    gApi.changes().id(changeId).setPrivate(true);
+
+    // Build Message
+    String txt = newPlaintextBody(getChangeUrl(changeInfo) + "/1", "Test Message", null, null);
+    MailMessage.Builder b =
+        messageBuilderWithDefaultFields()
+            .from(user.getNameEmail())
+            .textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    sender.clear();
+    mailProcessor.process(b.build());
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body())
+        .contains(
+            "Gerrit Code Review was unable to process your email because the change was not"
+                + " found.");
+    assertThat(message.headers()).containsKey("Subject");
+  }
+
+  @Test
   public void validateChangeMessage_rejected() throws Exception {
     String changeId = createChangeWithReview();
     ChangeInfo changeInfo = gApi.changes().id(changeId).get();
@@ -300,7 +445,7 @@
     assertThat(message.body()).contains("rejected one or more comments");
 
     // ensure the message header contains a valid message id.
-    assertThat(((EmailHeader.String) (message.headers().get("Message-ID"))).getString())
+    assertThat(((StringEmailHeader) (message.headers().get("Message-ID"))).getString())
         .containsMatch("<someid-REJECTION-HTML@" + new URL(canonicalWebUrl.get()).getHost() + ">");
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
index 1c916a3..45a471b 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailSenderIT.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.entities.EmailHeader;
+import com.google.gerrit.entities.EmailHeader.StringEmailHeader;
 import java.net.URI;
 import java.util.Map;
 import org.junit.Test;
@@ -64,7 +65,7 @@
 
   private String headerString(Map<String, EmailHeader> headers, String name) {
     EmailHeader header = headers.get(name);
-    assertThat(header).isInstanceOf(EmailHeader.String.class);
-    return ((EmailHeader.String) header).getString();
+    assertThat(header).isInstanceOf(StringEmailHeader.class);
+    return ((StringEmailHeader) header).getString();
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
new file mode 100644
index 0000000..1b164eb
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/notedb/ExternalIdNotesUpsertPreprocessorIT.java
@@ -0,0 +1,209 @@
+// 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.notedb;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.entities.Account;
+import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdNotes;
+import com.google.gerrit.server.account.externalids.ExternalIdUpsertPreprocessor;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests {@link ExternalIdUpsertPreprocessor}. */
+@TestPlugin(
+    name = "external-id-update-preprocessor",
+    sysModule =
+        "com.google.gerrit.acceptance.server.notedb.ExternalIdNotesUpsertPreprocessorIT$TestModule")
+public class ExternalIdNotesUpsertPreprocessorIT extends LightweightPluginDaemonTest {
+  @Inject private Sequences sequences;
+  @Inject private @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider;
+  @Inject private ExternalIdNotes.Factory extIdNotesFactory;
+  @Inject private ExternalIdFactory extIdFactory;
+
+  public static class TestModule extends AbstractModule {
+    @Override
+    protected void configure() {
+      bind(ExternalIdUpsertPreprocessor.class)
+          .annotatedWith(Exports.named("TestPreprocessor"))
+          .to(TestPreprocessor.class);
+    }
+  }
+
+  private TestPreprocessor testPreprocessor;
+
+  @Before
+  public void setUp() {
+    testPreprocessor = plugin.getSysInjector().getInstance(TestPreprocessor.class);
+    testPreprocessor.reset();
+  }
+
+  @Test
+  public void insertAccount() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = extIdFactory.create("foo", "bar", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId));
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void replaceByKeys() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = extIdFactory.create("foo", "bar1", id);
+    ExternalId extId2 = extIdFactory.create("foo", "bar2", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replaceByKeys(ImmutableSet.of(extId1.key()), ImmutableSet.of(extId2));
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void insert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = extIdFactory.create("foo", "bar", id);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.insert(extId);
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void upsert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = extIdFactory.create("foo", "bar", id);
+
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.upsert(extId);
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId);
+  }
+
+  @Test
+  public void replace() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = extIdFactory.create("foo", "bar1", id);
+    ExternalId extId2 = extIdFactory.create("foo", "bar2", id);
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replace(ImmutableSet.of(extId1), ImmutableSet.of(extId2));
+      extIdNotes.commit(md);
+    }
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void replace_viaAccountsUpdate() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = extIdFactory.create("foo", "bar", id, "email1@foo", "hash");
+    ExternalId extId2 = extIdFactory.create("foo", "bar", id, "email2@foo", "hash");
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    testPreprocessor.reset();
+    accountsUpdateProvider.get().update("test", id, u -> u.updateExternalId(extId2));
+    assertThat(testPreprocessor.upserted).containsExactly(extId2);
+  }
+
+  @Test
+  public void blockUpsert() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId = extIdFactory.create("foo", "bar", id);
+    testPreprocessor.throwException = true;
+    StorageException e =
+        assertThrows(
+            StorageException.class,
+            () -> accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId)));
+    assertThat(e).hasMessageThat().contains("upsert not good");
+    assertThat(testPreprocessor.upserted).isEmpty();
+  }
+
+  @Test
+  public void blockUpsert_replace() throws Exception {
+    Account.Id id = Account.id(sequences.nextAccountId());
+    ExternalId extId1 = extIdFactory.create("foo", "bar", id, "email1@foo", "hash");
+    ExternalId extId2 = extIdFactory.create("foo", "bar", id, "email2@foo", "hash");
+    accountsUpdateProvider.get().insert("test", id, u -> u.addExternalId(extId1));
+
+    assertThat(accounts.get(id).get().externalIds()).containsExactly(extId1);
+
+    testPreprocessor.reset();
+    testPreprocessor.throwException = true;
+    try (Repository allUsersRepo = repoManager.openRepository(allUsers);
+        MetaDataUpdate md = metaDataUpdateFactory.create(allUsers)) {
+      ExternalIdNotes extIdNotes = extIdNotesFactory.load(allUsersRepo);
+      extIdNotes.replace(ImmutableSet.of(extId1), ImmutableSet.of(extId2));
+      StorageException e = assertThrows(StorageException.class, () -> extIdNotes.commit(md));
+      assertThat(e).hasMessageThat().contains("upsert not good");
+    }
+    assertThat(testPreprocessor.upserted).isEmpty();
+    assertThat(accounts.get(id).get().externalIds()).containsExactly(extId1);
+  }
+
+  @Singleton
+  public static class TestPreprocessor implements ExternalIdUpsertPreprocessor {
+    List<ExternalId> upserted = new ArrayList<>();
+
+    boolean throwException = false;
+
+    @Override
+    public void upsert(ExternalId extId) {
+      assertThat(extId.blobId()).isNotNull();
+      if (throwException) {
+        throw new StorageException("upsert not good");
+      }
+      upserted.add(extId);
+    }
+
+    void reset() {
+      upserted.clear();
+      throwException = false;
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
index 9e4907c..46687e3 100644
--- a/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/permissions/ExternalUserPermissionIT.java
@@ -44,7 +44,7 @@
 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.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.permissions.ChangePermission;
 import com.google.gerrit.server.permissions.PermissionBackend;
@@ -70,6 +70,7 @@
   @Inject private ChangeNotes.Factory changeNotesFactory;
   @Inject private ExternalUser.Factory externalUserFactory;
   @Inject private GroupOperations groupOperations;
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
 
   @Before
   public void setUp() {
@@ -295,7 +296,7 @@
   ExternalUser createUserInGroup(String userId, String groupId) {
     return externalUserFactory.create(
         ImmutableSet.of(),
-        ImmutableSet.of(ExternalId.Key.parse("company-auth:" + groupId + "-" + userId)),
+        ImmutableSet.of(externalIdKeyFactory.parse("company-auth:" + groupId + "-" + userId)),
         PropertyMap.EMPTY);
   }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
index 6aa5878..6b34cca 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/CustomLabelIT.java
@@ -43,8 +43,8 @@
 import com.google.gerrit.entities.CachedProjectConfig;
 import com.google.gerrit.entities.LabelFunction;
 import com.google.gerrit.entities.LabelType;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.LabelInfo;
 import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -169,7 +169,7 @@
     try (Registration registration = extensionRegistry.newRegistration().add(testListener)) {
       saveLabelConfig(P.toBuilder().setFunction(ANY_WITH_BLOCK));
       PushOneCommit.Result r = createChange();
-      AddReviewerInput in = new AddReviewerInput();
+      ReviewerInput in = new ReviewerInput();
       in.reviewer = user.email();
       gApi.changes().id(r.getChangeId()).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
index 33276e7..ba86976 100644
--- a/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/project/ProjectWatchIT.java
@@ -16,9 +16,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
-import static com.google.gerrit.server.StarredChangesUtil.IGNORE_LABEL;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.NoHttpd;
 import com.google.gerrit.acceptance.PushOneCommit;
@@ -32,7 +30,6 @@
 import com.google.gerrit.entities.Permission;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
-import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.testing.FakeEmailSender.Message;
 import com.google.inject.Inject;
@@ -470,7 +467,7 @@
 
     // ignore the change
     requestScopeOperations.setApiUser(user.id());
-    gApi.accounts().self().setStars(r.getChangeId(), new StarsInput(ImmutableSet.of(IGNORE_LABEL)));
+    gApi.changes().id(r.getChangeId()).ignore(true);
 
     sender.clear();
 
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
new file mode 100644
index 0000000..6e19c39
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsEvaluatorIT.java
@@ -0,0 +1,251 @@
+// 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.project;
+
+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.value;
+
+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.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult.Status;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.project.SubmitRequirementsEvaluator;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.junit.Before;
+import org.junit.Test;
+
+@NoHttpd
+public class SubmitRequirementsEvaluatorIT extends AbstractDaemonTest {
+  @Inject SubmitRequirementsEvaluator evaluator;
+  @Inject private ProjectOperations projectOperations;
+  @Inject private Provider<InternalChangeQuery> changeQueryProvider;
+
+  private ChangeData changeData;
+  private String changeId;
+
+  @Before
+  public void setUp() throws Exception {
+    PushOneCommit.Result pushResult =
+        createChange(testRepo, "refs/heads/master", "Fix a bug", "file.txt", "content", "topic");
+    changeData = pushResult.getChange();
+    changeId = pushResult.getChangeId();
+  }
+
+  @Test
+  public void invalidExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("invalid_field:invalid_value");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.ERROR);
+    assertThat(result.errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void expressionWithPassingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/master");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void expressionWithFailingPredicate() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create("branch:refs/heads/foo");
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.FAIL);
+    assertThat(result.errorMessage()).isEqualTo(Optional.empty());
+  }
+
+  @Test
+  public void compositeExpression() throws Exception {
+    SubmitRequirementExpression expression =
+        SubmitRequirementExpression.create(
+            String.format(
+                "(project:%s AND branch:refs/heads/foo) OR message:\"Fix a bug\"", project.get()));
+
+    SubmitRequirementExpressionResult result = evaluator.evaluateExpression(expression, changeData);
+
+    assertThat(result.status()).isEqualTo(Status.PASS);
+
+    assertThat(result.passingAtoms())
+        .containsExactly(String.format("project:%s", project.get()), "message:\"Fix a bug\"");
+
+    assertThat(result.failingAtoms()).containsExactly(String.format("branch:refs/heads/foo"));
+  }
+
+  @Test
+  public void submitRequirementIsNotApplicable_whenApplicabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:non-existent-project",
+            /* submittabilityExpr= */ "message:\"Fix bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.NOT_APPLICABLE);
+  }
+
+  @Test
+  public void submitRequirementIsSatisfied_whenSubmittabilityExpressionIsTrue() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "message:\"Fix a bug\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.SATISFIED);
+  }
+
+  @Test
+  public void submitRequirementIsUnsatisfied_whenSubmittabilityExpressionIsFalse()
+      throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.UNSATISFIED);
+    assertThat(result.submittabilityExpressionResult().failingAtoms())
+        .containsExactly("label:\"code-review=+2\"");
+  }
+
+  @Test
+  public void submitRequirementIsOverridden_whenOverrideExpressionIsTrue() throws Exception {
+    addLabel("build-cop-override");
+    voteLabel(changeId, "build-cop-override", 1);
+
+    // Reload change data after applying the vote
+    changeData =
+        changeQueryProvider.get().byLegacyChangeId(changeData.getId()).stream()
+            .collect(MoreCollectors.onlyElement());
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.OVERRIDDEN);
+  }
+
+  @Test
+  public void submitRequirementIsError_whenApplicabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "invalid_field:invalid_value",
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.applicabilityExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenSubmittabilityExpressionHasInvalidSyntax()
+      throws Exception {
+    addLabel("build-cop-override");
+
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "invalid_field:invalid_value",
+            /* overrideExpr= */ "label:\"build-cop-override=+1\"");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.submittabilityExpressionResult().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  @Test
+  public void submitRequirementIsError_whenOverrideExpressionHasInvalidSyntax() throws Exception {
+    SubmitRequirement sr =
+        createSubmitRequirement(
+            /* applicabilityExpr= */ "project:" + project.get(),
+            /* submittabilityExpr= */ "label:\"code-review=+2\"",
+            /* overrideExpr= */ "invalid_field:invalid_value");
+
+    SubmitRequirementResult result = evaluator.evaluateRequirement(sr, changeData);
+    assertThat(result.status()).isEqualTo(SubmitRequirementResult.Status.ERROR);
+    assertThat(result.overrideExpressionResult().get().errorMessage().get())
+        .isEqualTo("Unsupported operator invalid_field:invalid_value");
+  }
+
+  private void voteLabel(String changeId, String labelName, int score) throws RestApiException {
+    gApi.changes().id(changeId).current().review(new ReviewInput().label(labelName, score));
+  }
+
+  private void addLabel(String labelName) throws Exception {
+    configLabel(
+        project,
+        labelName,
+        LabelFunction.NO_OP,
+        value(1, "ok"),
+        value(0, "No score"),
+        value(-1, "Needs work"));
+
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(allowLabel(labelName).ref("refs/heads/master").group(REGISTERED_USERS).range(-1, +1))
+        .update();
+  }
+
+  private SubmitRequirement createSubmitRequirement(
+      @Nullable String applicabilityExpr,
+      String submittabilityExpr,
+      @Nullable String overrideExpr) {
+    return SubmitRequirement.builder()
+        .setName("sr-name")
+        .setDescription(Optional.of("sr-description"))
+        .setApplicabilityExpression(SubmitRequirementExpression.of(applicabilityExpr))
+        .setSubmittabilityExpression(SubmitRequirementExpression.create(submittabilityExpr))
+        .setOverrideExpression(SubmitRequirementExpression.of(overrideExpr))
+        .setAllowOverrideInChildProjects(false)
+        .build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
new file mode 100644
index 0000000..4675bc0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/project/SubmitRequirementsValidationIT.java
@@ -0,0 +1,252 @@
+// 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.acceptance.GitUtil.pushHead;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.util.Locale;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.util.RawParseUtils;
+import org.junit.Test;
+
+/**
+ * Tests validating submit requirements on upload of {@code project.config} to {@code
+ * refs/meta/config}.
+ */
+public class SubmitRequirementsValidationIT extends AbstractDaemonTest {
+  @Test
+  public void validSubmitRequirementIsAccepted_optionalParametersNotSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ "label:\"code-review=+2\""));
+
+    PushResult r = pushRefsMetaConfig();
+    assertOkStatus(r);
+  }
+
+  @Test
+  public void validSubmitRequirementIsAccepted_allParametersSet() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+              /* value= */ "foo bar description");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_APPLICABILITY_EXPRESSION,
+              /* value= */ "branch:refs/heads/master");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"code-review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_EXPRESSION,
+              /* value= */ "label:\"override=+1\"");
+          projectConfig.setBoolean(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_OVERRIDE_IN_CHILD_PROJECTS,
+              /* value= */ false);
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertOkStatus(r);
+  }
+
+  @Test
+  public void conflictingSubmitRequirementsAreRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig -> {
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName,
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"code-review=+2\"");
+          projectConfig.setString(
+              ProjectConfig.SUBMIT_REQUIREMENT,
+              /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
+              /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+              /* value= */ "label:\"code-review=+2\"");
+        });
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirement '%s' conflicts with '%s'.",
+            submitRequirementName.toLowerCase(Locale.US), submitRequirementName));
+  }
+
+  @Test
+  public void conflictingSubmitRequirementIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ "label:\"code-review=+2\""));
+    PushResult r = pushRefsMetaConfig();
+    assertOkStatus(r);
+
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName.toLowerCase(Locale.US),
+                /* name= */ ProjectConfig.KEY_SR_SUBMITTABILITY_EXPRESSION,
+                /* value= */ "label:\"code-review=+2\""));
+    r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirement '%s' conflicts with '%s'.",
+            submitRequirementName.toLowerCase(Locale.US), submitRequirementName));
+  }
+
+  @Test
+  public void submitRequirementWithoutSubmittabilityExpressionIsRejected() throws Exception {
+    fetchRefsMetaConfig();
+
+    String submitRequirementName = "Code-Review";
+    updateProjectConfig(
+        projectConfig ->
+            projectConfig.setString(
+                ProjectConfig.SUBMIT_REQUIREMENT,
+                /* subsection= */ submitRequirementName,
+                /* name= */ ProjectConfig.KEY_SR_DESCRIPTION,
+                /* value= */ "foo bar description"));
+
+    PushResult r = pushRefsMetaConfig();
+    assertErrorStatus(
+        r,
+        "Invalid project configuration",
+        String.format(
+            "project.config: Submit requirement '%s' does not define a submittability expression.",
+            submitRequirementName));
+  }
+
+  private void fetchRefsMetaConfig() throws Exception {
+    fetch(testRepo, RefNames.REFS_CONFIG + ":" + RefNames.REFS_CONFIG);
+    testRepo.reset(RefNames.REFS_CONFIG);
+  }
+
+  private PushResult pushRefsMetaConfig() throws Exception {
+    return pushHead(testRepo, RefNames.REFS_CONFIG);
+  }
+
+  private void updateProjectConfig(Consumer<Config> configUpdater) throws Exception {
+    RevCommit head = getHead(testRepo.getRepository(), RefNames.REFS_CONFIG);
+    Config projectConfig = readProjectConfig(head);
+    configUpdater.accept(projectConfig);
+    RevCommit commit =
+        testRepo.update(
+            RefNames.REFS_CONFIG,
+            testRepo
+                .commit()
+                .parent(head)
+                .message("Update project config")
+                .author(admin.newIdent())
+                .committer(admin.newIdent())
+                .add(ProjectConfig.PROJECT_CONFIG, projectConfig.toText()));
+
+    testRepo.reset(commit);
+  }
+
+  private Config readProjectConfig(RevCommit commit) throws Exception {
+    try (TreeWalk tw =
+        TreeWalk.forPath(
+            testRepo.getRevWalk().getObjectReader(),
+            ProjectConfig.PROJECT_CONFIG,
+            commit.getTree())) {
+      if (tw == null) {
+        throw new IllegalStateException(
+            String.format("%s does not exist", ProjectConfig.PROJECT_CONFIG));
+      }
+    }
+    RevObject blob = testRepo.get(commit.getTree(), ProjectConfig.PROJECT_CONFIG);
+    byte[] data = testRepo.getRepository().open(blob).getCachedBytes(Integer.MAX_VALUE);
+    String content = RawParseUtils.decode(data);
+
+    Config projectConfig = new Config();
+    projectConfig.fromText(content);
+    return projectConfig;
+  }
+
+  public void assertOkStatus(PushResult result) {
+    RemoteRefUpdate refUpdate = result.getRemoteUpdate(RefNames.REFS_CONFIG);
+    assertThat(refUpdate).isNotNull();
+    assertWithMessage(getMessage(result, refUpdate))
+        .that(refUpdate.getStatus())
+        .isEqualTo(Status.OK);
+  }
+
+  public void assertErrorStatus(PushResult result, String... expectedMessages) {
+    RemoteRefUpdate refUpdate = result.getRemoteUpdate(RefNames.REFS_CONFIG);
+    assertThat(refUpdate).isNotNull();
+    assertWithMessage(getMessage(result, refUpdate))
+        .that(refUpdate.getStatus())
+        .isEqualTo(Status.REJECTED_OTHER_REASON);
+    for (String expectedMessage : expectedMessages) {
+      assertThat(result.getMessages()).contains(expectedMessage);
+    }
+  }
+
+  private String getMessage(PushResult result, RemoteRefUpdate refUpdate) {
+    StringBuilder b = new StringBuilder();
+    if (refUpdate.getMessage() != null) {
+      b.append(refUpdate.getMessage());
+      b.append("\n");
+    }
+    b.append(result.getMessages());
+    return b.toString();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
new file mode 100644
index 0000000..537c7d8
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/query/ApprovalQueryIT.java
@@ -0,0 +1,269 @@
+// 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.query;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.testsuite.change.ChangeKindCreator;
+import com.google.gerrit.acceptance.testsuite.change.ChangeOperations;
+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.entities.PatchSetApproval;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.query.approval.ApprovalContext;
+import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
+import com.google.inject.Inject;
+import java.util.Date;
+import org.junit.Test;
+
+public class ApprovalQueryIT extends AbstractDaemonTest {
+  @Inject private ApprovalQueryBuilder queryBuilder;
+  @Inject private ChangeKindCreator changeKindCreator;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
+  @Inject private ChangeKindCache changeKindCache;
+  @Inject private ChangeOperations changeOperations;
+
+  @Test
+  public void magicValuePredicate() throws Exception {
+    assertTrue(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertTrue(queryBuilder.parse("is:mAx").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(1)));
+    assertFalse(queryBuilder.parse("is:MAX").asMatchable().match(contextForCodeReviewLabel(5000)));
+
+    assertTrue(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertTrue(queryBuilder.parse("is:mIn").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(-1)));
+    assertFalse(queryBuilder.parse("is:MIN").asMatchable().match(contextForCodeReviewLabel(5000)));
+
+    assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(-2)));
+    assertTrue(queryBuilder.parse("is:ANY").asMatchable().match(contextForCodeReviewLabel(2)));
+    assertTrue(queryBuilder.parse("is:aNy").asMatchable().match(contextForCodeReviewLabel(2)));
+  }
+
+  @Test
+  public void changeKindPredicate_noCodeChange() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.NO_CODE_CHANGE, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.NO_CODE_CHANGE, testRepo, admin, project);
+    PatchSet.Id ps1 = PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* id= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:no-code-change")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.TRIVIAL_REBASE, testRepo, admin, project);
+    PatchSet.Id ps2 = PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* id= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("changekind:no-code-change")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void changeKindPredicate_trivialRebase() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.TRIVIAL_REBASE, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.TRIVIAL_REBASE, testRepo, admin, project);
+    PatchSet.Id ps1 = PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* id= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:trivial-rebase")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps2 = PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* id= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("changekind:trivial-rebase")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void changeKindPredicate_reworkAndNotRework() throws Exception {
+    String change = changeKindCreator.createChange(ChangeKind.REWORK, testRepo, admin);
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps1 = PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* id= */ 1);
+    assertTrue(
+        queryBuilder
+            .parse("changekind:rework")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps1, admin.id())));
+
+    changeKindCreator.updateChange(change, ChangeKind.REWORK, testRepo, admin, project);
+    PatchSet.Id ps2 = PatchSet.id(Change.id(gApi.changes().id(change).get()._number), /* id= */ 2);
+    assertFalse(
+        queryBuilder
+            .parse("-changekind:rework")
+            .asMatchable()
+            .match(contextForCodeReviewLabel(/* value= */ -2, ps2, admin.id())));
+  }
+
+  @Test
+  public void uploaderInPredicate() throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    PushOneCommit.Result pushResult = createChange();
+    String changeCreatedByAdmin = pushResult.getChangeId();
+    approve(changeCreatedByAdmin);
+    // PS2 uploaded by admin
+    amendChange(changeCreatedByAdmin);
+    // PS3 uploaded by user
+    amendChangeWithUploader(pushResult, project, user).assertOkStatus();
+
+    // can copy approval from patchset 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("uploaderin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* id= */ 1),
+                    admin.id())));
+    // can not copy approval from patchset 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("uploaderin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* id= */ 2),
+                    admin.id())));
+  }
+
+  @Test
+  public void approverInPredicate() throws Exception {
+    String administratorsUUID = gApi.groups().query("name:Administrators").get().get(0).id;
+
+    PushOneCommit.Result pushResult = createChange();
+    amendChange(pushResult.getChangeId());
+    amendChange(pushResult.getChangeId());
+    // can copy approval from patchset 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("approverin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* id= */ 1),
+                    admin.id())));
+    // can not copy approval from patchset 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("approverin:" + administratorsUUID)
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2,
+                    PatchSet.id(pushResult.getChange().getId(), /* id= */ 2),
+                    user.id())));
+  }
+
+  @Test
+  public void userInPredicate_groupNotFound() {
+    QueryParseException thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryBuilder
+                    .parse("uploaderin:foobar")
+                    .asMatchable()
+                    .match(contextForCodeReviewLabel(/* value= */ 2)));
+    assertThat(thrown).hasMessageThat().contains("Group foobar not found");
+  }
+
+  @Test
+  public void hasChangedFilesPredicate() throws Exception {
+    Change.Id changeId =
+        changeOperations.newChange().project(project).file("file").content("content").create();
+    changeOperations.change(changeId).newPatchset().file("file").content("new content").create();
+
+    // can copy approval from patch-set 1 -> 2
+    assertTrue(
+        queryBuilder
+            .parse("has:unchanged-files")
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2, PatchSet.id(changeId, /* id= */ 1), admin.id())));
+    changeOperations.change(changeId).newPatchset().file("file").delete().create();
+
+    // can not copy approval from patch-set 2 -> 3
+    assertFalse(
+        queryBuilder
+            .parse("has:unchanged-files")
+            .asMatchable()
+            .match(
+                contextForCodeReviewLabel(
+                    /* value= */ 2, PatchSet.id(changeId, /* id= */ 2), admin.id())));
+  }
+
+  @Test
+  public void hasChangedFilesPredicate_unsupportedOperator() {
+    QueryParseException thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryBuilder
+                    .parse("has:invalid")
+                    .asMatchable()
+                    .match(contextForCodeReviewLabel(/* value= */ 2)));
+    assertThat(thrown)
+        .hasMessageThat()
+        .contains(
+            "'invalid' is not a supported argument for has. only 'unchanged-files' is supported");
+  }
+
+  private ApprovalContext contextForCodeReviewLabel(int value) throws Exception {
+    PushOneCommit.Result result = createChange();
+    amendChange(result.getChangeId());
+    PatchSet.Id psId = PatchSet.id(result.getChange().getId(), 1);
+    return contextForCodeReviewLabel(value, psId, admin.id());
+  }
+
+  private ApprovalContext contextForCodeReviewLabel(
+      int value, PatchSet.Id psId, Account.Id approver) {
+    ChangeNotes changeNotes = changeNotesFactory.create(project, psId.changeId());
+    PatchSet.Id newPsId = PatchSet.id(psId.changeId(), psId.get() + 1);
+    ChangeKind changeKind =
+        changeKindCache.getChangeKind(
+            changeNotes.getChange(), changeNotes.getPatchSets().get(newPsId));
+    PatchSetApproval approval =
+        PatchSetApproval.builder()
+            .postSubmit(false)
+            .granted(new Date())
+            .key(PatchSetApproval.key(psId, approver, LabelId.create("Code-Review")))
+            .value(value)
+            .build();
+    return ApprovalContext.create(
+        changeNotes, approval, changeNotes.getPatchSets().get(newPsId), changeKind);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/query/BUILD b/javatests/com/google/gerrit/acceptance/server/query/BUILD
new file mode 100644
index 0000000..f7d13a0
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/query/BUILD
@@ -0,0 +1,7 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "server_query",
+    labels = ["server"],
+)
diff --git a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
index 2692584..c9c3bbb 100644
--- a/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/quota/RepositorySizeQuotaIT.java
@@ -76,8 +76,13 @@
 
   @Test
   public void pushWithAvailableTokens() throws Exception {
+    // The push creates a pack that contains 325 bytes of uncompressed data.
+    // The data in the push contains sha and timestamps which are different on each test run.
+    // Due to it, the push's pack size varies after data compression and lead to a flaky tests
+    // if the amount of availableTokens doesn't cover all possible sizes. To avoid flakiness, we
+    // set availableTokens value large enough to cover all possible pack sizes.
     when(quotaBackendWithResource.availableTokens(REPOSITORY_SIZE_GROUP))
-        .thenReturn(singletonAggregation(ok(277L)));
+        .thenReturn(singletonAggregation(ok(512L)));
     when(quotaBackendWithResource.requestTokens(eq(REPOSITORY_SIZE_GROUP), anyLong()))
         .thenReturn(singletonAggregation(ok()));
     when(quotaBackendWithUser.project(project)).thenReturn(quotaBackendWithResource);
diff --git a/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
index 06bf1ae..6486fbe 100644
--- a/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/util/PluginLogFileIT.java
@@ -34,7 +34,7 @@
 
   @Test
   public void testMultiThreadedPluginLogFile() throws Exception {
-    try (AutoCloseable ignored = installPlugin("my-plugin", Module.class)) {
+    try (AutoCloseable ignored = installPlugin("my-plugin", TestModule.class)) {
       ExecutorService service = Executors.newFixedThreadPool(NUMBER_OF_THREADS);
       CountDownLatch latch = new CountDownLatch(NUMBER_OF_THREADS);
       createChange();
diff --git a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
index f866fff..3b38bad 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/AbstractIndexTests.java
@@ -38,7 +38,6 @@
 public abstract class AbstractIndexTests extends AbstractDaemonTest {
   @Inject private ExtensionRegistry extensionRegistry;
 
-  /** @param injector injector */
   public void configureIndex(Injector injector) {}
 
   @Test
diff --git a/javatests/com/google/gerrit/acceptance/ssh/BUILD b/javatests/com/google/gerrit/acceptance/ssh/BUILD
index 5634322..6dec6af 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/BUILD
+++ b/javatests/com/google/gerrit/acceptance/ssh/BUILD
@@ -11,31 +11,14 @@
 acceptance_tests(
     srcs = glob(
         ["*IT.java"],
-        exclude = ["ElasticIndexIT.java"],
     ),
     group = "ssh",
     labels = ["ssh"],
     vm_args = ["-Xmx512m"],
     deps = [
         ":util",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/logging",
         "//lib/commons:compress",
     ],
 )
-
-acceptance_tests(
-    srcs = ["ElasticIndexIT.java"],
-    group = "elastic",
-    labels = [
-        "docker",
-        "elastic",
-        "exclusive",
-        "ssh",
-    ],
-    deps = [
-        ":util",
-        "//java/com/google/gerrit/elasticsearch",
-        "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
-        "//lib/commons:compress",
-    ],
-)
diff --git a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java b/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
deleted file mode 100644
index e72d806..0000000
--- a/javatests/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
+++ /dev/null
@@ -1,38 +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.acceptance.ssh;
-
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.createAllIndexes;
-import static com.google.gerrit.elasticsearch.ElasticTestUtils.getConfig;
-
-import com.google.gerrit.elasticsearch.ElasticVersion;
-import com.google.gerrit.index.IndexType;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-
-/** Tests for every supported {@link IndexType#isElasticsearch()} most recent index version. */
-public class ElasticIndexIT extends AbstractIndexTests {
-
-  @ConfigSuite.Default
-  public static Config elasticsearchV7() {
-    return getConfig(ElasticVersion.V7_16);
-  }
-
-  @Override
-  public void configureIndex(Injector injector) {
-    createAllIndexes(injector);
-  }
-}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
index 9dcfa3f..10c81a1 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/GarbageCollectionIT.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.acceptance.UseSsh;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.common.data.GarbageCollectionResult.GcError;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.git.GarbageCollection;
 import com.google.gerrit.server.git.GarbageCollectionQueue;
@@ -91,8 +92,8 @@
             .run(Arrays.asList(allProjects, project, project2, project3));
     assertThat(result.hasErrors()).isTrue();
     assertThat(result.getErrors()).hasSize(1);
-    GarbageCollectionResult.Error error = result.getErrors().get(0);
-    assertThat(error.getType()).isEqualTo(GarbageCollectionResult.Error.Type.GC_ALREADY_SCHEDULED);
+    GcError error = result.getErrors().get(0);
+    assertThat(error.getType()).isEqualTo(GcError.Type.GC_ALREADY_SCHEDULED);
     assertThat(error.getProjectName()).isEqualTo(project);
   }
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
index 78960bb..cf316c7 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/QueryIT.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.SshSession;
 import com.google.gerrit.acceptance.UseSsh;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.server.data.ChangeAttribute;
@@ -84,7 +84,7 @@
   @Test
   public void allReviewersOptionJSON() throws Exception {
     String changeId = createChange().getChangeId();
-    AddReviewerInput in = new AddReviewerInput();
+    ReviewerInput in = new ReviewerInput();
     in.reviewer = user.email();
     gApi.changes().id(changeId).addReviewer(in);
 
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SetAccountIT.java b/javatests/com/google/gerrit/acceptance/ssh/SetAccountIT.java
new file mode 100644
index 0000000..a82876e
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SetAccountIT.java
@@ -0,0 +1,93 @@
+// 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.ssh;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowCapability;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.junit.Test;
+
+@UseSsh
+@NoHttpd
+public class SetAccountIT extends AbstractDaemonTest {
+  @Inject private ExternalIds externalIds;
+  @Inject private ProjectOperations projectOperations;
+
+  @Test
+  public void setAccount_deleteExternalId_all() throws Exception {
+    TestAccount testAccount = accountCreator.create("user1", "user1@example.com", null, null);
+    adminSshSession.exec("gerrit set-account --delete-external-id ALL user1");
+    adminSshSession.assertSuccess();
+    assertThat(externalIds.byAccount(testAccount.id()).isEmpty()).isTrue();
+  }
+
+  @Test
+  public void setAccount_deleteExternalId_single() throws Exception {
+    TestAccount testAccount = accountCreator.create("user2", "user2@example.com", null, null);
+    List<String> extIdKeys = getExternalIdKeys(testAccount);
+    assertThat(extIdKeys.contains("username:user2")).isTrue();
+    assertThat(extIdKeys.contains("mailto:user2@example.com")).isTrue();
+    adminSshSession.exec("gerrit set-account --delete-external-id username:user2 user2");
+    adminSshSession.assertSuccess();
+    extIdKeys = getExternalIdKeys(testAccount);
+    assertThat(extIdKeys.contains("username:user3")).isFalse();
+    assertThat(extIdKeys.contains("mailto:user3@example.com")).isFalse();
+  }
+
+  @Test
+  public void setAccount_deleteExternalId_multiple() throws Exception {
+    TestAccount testAccount = accountCreator.create("user3", "user3@example.com", null, null);
+    List<String> extIdKeys = getExternalIdKeys(testAccount);
+    assertThat(extIdKeys.contains("username:user3")).isTrue();
+    assertThat(extIdKeys.contains("mailto:user3@example.com")).isTrue();
+    adminSshSession.exec(
+        "gerrit set-account --delete-external-id username:user3 --delete-external-id mailto:user3@example.com user3");
+    adminSshSession.assertSuccess();
+    extIdKeys = getExternalIdKeys(testAccount);
+    assertThat(extIdKeys.contains("username:user3")).isFalse();
+    assertThat(extIdKeys.contains("mailto:user3@example.com")).isFalse();
+  }
+
+  @Test
+  public void setAccount_deleteExternalId_byUser() throws Exception {
+    userSshSession.exec("gerrit set-account --delete-external-id mailto:admin@example.com admin");
+    userSshSession.assertFailure();
+    projectOperations
+        .allProjectsForUpdate()
+        .add(allowCapability(GlobalCapability.MODIFY_ACCOUNT).group(REGISTERED_USERS))
+        .update();
+    userSshSession.exec("gerrit set-account --delete-external-id mailto:admin@example.com admin");
+    userSshSession.assertSuccess();
+    userSshSession.exec("gerrit set-account --delete-external-id username:admin admin");
+    userSshSession.assertFailure();
+  }
+
+  private List<String> getExternalIdKeys(TestAccount account) throws Exception {
+    return externalIds.byAccount(account.id()).stream()
+        .map(e -> e.key().get())
+        .collect(Collectors.toList());
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
new file mode 100644
index 0000000..e21cb26
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCancellationIT.java
@@ -0,0 +1,172 @@
+// 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.ssh;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.UseSsh;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.server.cancellation.RequestCancelledException;
+import com.google.gerrit.server.cancellation.RequestStateProvider;
+import com.google.gerrit.server.project.CreateProjectArgs;
+import com.google.gerrit.server.validators.ProjectCreationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+@UseSsh
+public class SshCancellationIT extends AbstractDaemonTest {
+  @Inject private ExtensionRegistry extensionRegistry;
+
+  @Test
+  public void handleClientDisconnected() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Closed Request");
+    }
+  }
+
+  @Test
+  public void handleClientDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.CLIENT_PROVIDED_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Client Provided Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleServerDeadlineExceeded() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED,
+                /* cancellationMessage= */ null);
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded");
+    }
+  }
+
+  @Test
+  public void handleRequestCancellationWithMessage() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RequestCancelledException(
+                RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+
+  @Test
+  public void handleWrappedRequestCancelledException() throws Exception {
+    ProjectCreationValidationListener projectCreationListener =
+        new ProjectCreationValidationListener() {
+          @Override
+          public void validateNewProject(CreateProjectArgs args) throws ValidationException {
+            throw new RuntimeException(
+                new RequestCancelledException(
+                    RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m"));
+          }
+        };
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(projectCreationListener)) {
+      adminSshSession.exec("gerrit create-project " + name("new"));
+      adminSshSession.assertFailure("Server Deadline Exceeded (deadline = 10m)");
+    }
+  }
+
+  @Test
+  public void abortIfClientProvidedDeadlineExceeded() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline 1ms " + name("new"));
+    adminSshSession.assertFailure("Client Provided Deadline Exceeded (client.timeout=1ms)");
+  }
+
+  @Test
+  public void requestRejectedIfInvalidDeadlineIsProvided_missingTimeUnit() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline 1 " + name("new"));
+    adminSshSession.assertFailure("Invalid deadline. Missing time unit: 1");
+  }
+
+  @Test
+  public void requestRejectedIfInvalidDeadlineIsProvided_invalidTimeUnit() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline 1x " + name("new"));
+    adminSshSession.assertFailure("Invalid deadline. Invalid time unit value: 1x");
+  }
+
+  @Test
+  public void requestRejectedIfInvalidDeadlineIsProvided_invalidValue() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline invalid " + name("new"));
+    adminSshSession.assertFailure("Invalid deadline. Invalid value: invalid");
+  }
+
+  @Test
+  public void requestSucceedsWithinDeadline() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline 10m " + name("new"));
+    adminSshSession.assertSuccess();
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void abortIfServerDeadlineExceeded() throws Exception {
+    adminSshSession.exec("gerrit create-project " + name("new"));
+    adminSshSession.assertFailure("Server Deadline Exceeded (default.timeout=1ms)");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void clientProvidedDeadlineOverridesServerDeadline() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline 2ms " + name("new"));
+    adminSshSession.assertFailure("Client Provided Deadline Exceeded (client.timeout=2ms)");
+  }
+
+  @Test
+  @GerritConfig(name = "deadline.default.timeout", value = "1ms")
+  public void clientCanDisableDeadlineBySettingZeroAsDeadline() throws Exception {
+    adminSshSession.exec("gerrit create-project --deadline 0 " + name("new"));
+    adminSshSession.assertSuccess();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 2b37cfd..7224e19 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -81,7 +81,8 @@
           "set-reviewers",
           "set-topic",
           "stream-events",
-          "test-submit");
+          "test-submit",
+          "migrate-externalids-to-insensitive");
 
   private static final ImmutableList<String> EMPTY = ImmutableList.of();
   private static final ImmutableMap<String, List<String>> MASTER_COMMANDS =
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
new file mode 100644
index 0000000..f6e5fb3
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/DefaultIndexBindingIT.java
@@ -0,0 +1,47 @@
+// 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.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assume.assumeTrue;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
+import javax.inject.Inject;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class DefaultIndexBindingIT extends AbstractDaemonTest {
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "");
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  @Test
+  public void fakeIsBoundByDefault() throws Exception {
+    String gerritIndexTypeEnv = System.getenv("GERRIT_INDEX_TYPE");
+    assumeTrue(
+        Strings.isNullOrEmpty(gerritIndexTypeEnv) || gerritIndexTypeEnv.equalsIgnoreCase("fake"));
+
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEmpty();
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
new file mode 100644
index 0000000..acb2e5a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/FakeIndexBindingIT.java
@@ -0,0 +1,41 @@
+// 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.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.index.testing.AbstractFakeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
+import javax.inject.Inject;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class FakeIndexBindingIT extends AbstractDaemonTest {
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "fake");
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  @Test
+  public void fakeIsBoundWhenConfigured() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("fake");
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(AbstractFakeIndex.FakeChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
new file mode 100644
index 0000000..5dd6f01
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/testsuite/index/LuceneIndexBindingIT.java
@@ -0,0 +1,41 @@
+// 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.testsuite.index;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.index.IndexType;
+import com.google.gerrit.lucene.LuceneChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.testing.SystemPropertiesTestRule;
+import javax.inject.Inject;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+/** Test to check that the expected index backend was bound depending on sys/env properties. */
+public class LuceneIndexBindingIT extends AbstractDaemonTest {
+  @ClassRule
+  public static SystemPropertiesTestRule systemProperties =
+      new SystemPropertiesTestRule(IndexType.SYS_PROP, "lucene");
+
+  @Inject private ChangeIndexCollection changeIndex;
+
+  @Test
+  public void luceneIsBoundWhenConfigured() throws Exception {
+    assertThat(System.getProperty(IndexType.SYS_PROP)).isEqualTo("lucene");
+    assertThat(changeIndex.getSearchIndex()).isInstanceOf(LuceneChangeIndex.class);
+  }
+}
diff --git a/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
index 7a61626..0883033 100644
--- a/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
+++ b/javatests/com/google/gerrit/auth/ldap/LdapRealmTest.java
@@ -30,6 +30,7 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.AccountGroup;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
@@ -44,6 +45,7 @@
 
 public final class LdapRealmTest {
   @Inject private LdapRealm ldapRealm = null;
+  @Inject private ExternalIdFactory externalIdFactory;
 
   @Before
   public void setUpInjector() throws Exception {
@@ -67,7 +69,7 @@
   }
 
   private ExternalId id(String scheme, String id) {
-    return ExternalId.create(scheme, id, Account.id(1000));
+    return externalIdFactory.create(scheme, id, Account.id(1000));
   }
 
   private boolean accountBelongsToRealm(ExternalId... ids) {
diff --git a/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
index 1af78e3..3ec6f28 100644
--- a/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
+++ b/javatests/com/google/gerrit/auth/oauth/OAuthRealmTest.java
@@ -24,6 +24,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -34,6 +35,7 @@
 
 public final class OAuthRealmTest {
   @Inject private OAuthRealm oauthRealm = null;
+  @Inject private ExternalIdFactory externalIdFactory;
 
   @Before
   public void setUpInjector() throws Exception {
@@ -42,7 +44,7 @@
   }
 
   private ExternalId id(String scheme, String id) {
-    return ExternalId.create(scheme, id, Account.id(1000));
+    return externalIdFactory.create(scheme, id, Account.id(1000));
   }
 
   private boolean accountBelongsToRealm(ExternalId... ids) {
diff --git a/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
index 05b0ec0..f83409b 100644
--- a/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
+++ b/javatests/com/google/gerrit/auth/openid/OpenIdRealmTest.java
@@ -25,6 +25,7 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.testing.InMemoryModule;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -35,6 +36,7 @@
 
 public final class OpenIdRealmTest {
   @Inject private OpenIdRealm openidRealm = null;
+  @Inject private ExternalIdFactory extIdFactory;
 
   @Before
   public void setUpInjector() throws Exception {
@@ -43,7 +45,7 @@
   }
 
   private ExternalId id(String scheme, String id) {
-    return ExternalId.create(scheme, id, Account.id(1000));
+    return extIdFactory.create(scheme, id, Account.id(1000));
   }
 
   private boolean accountBelongsToRealm(ExternalId... ids) {
diff --git a/javatests/com/google/gerrit/elasticsearch/BUILD b/javatests/com/google/gerrit/elasticsearch/BUILD
deleted file mode 100644
index 3036811..0000000
--- a/javatests/com/google/gerrit/elasticsearch/BUILD
+++ /dev/null
@@ -1,85 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-load("//tools/bzl:junit.bzl", "junit_tests")
-
-java_library(
-    name = "elasticsearch_test_utils",
-    testonly = True,
-    srcs = [
-        "ElasticContainer.java",
-        "ElasticTestUtils.java",
-    ],
-    visibility = ["//visibility:public"],
-    deps = [
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/index",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib:junit",
-        "//lib/guice",
-        "//lib/httpcomponents:httpcore",
-        "//lib/jackson:jackson-annotations",
-        "//lib/log:api",
-        "//lib/testcontainers",
-        "//lib/testcontainers:docker-java-api",
-        "//lib/testcontainers:docker-java-transport",
-        "//lib/testcontainers:testcontainers-elasticsearch",
-    ],
-)
-
-ELASTICSEARCH_DEPS = [
-    ":elasticsearch_test_utils",
-    "//java/com/google/gerrit/elasticsearch",
-    "//java/com/google/gerrit/testing:gerrit-test-util",
-    "//lib/guice",
-    "//lib:jgit",
-]
-
-HTTP_TEST_DEPS = [
-    "//lib/httpcomponents:httpasyncclient",
-    "//lib/httpcomponents:httpclient",
-]
-
-QUERY_TESTS_DEP = "//javatests/com/google/gerrit/server/query/%s:abstract_query_tests"
-
-TYPES = [
-    "account",
-    "change",
-    "group",
-    "project",
-]
-
-SUFFIX = "sTest.java"
-
-ELASTICSEARCH_TESTS_V7 = {i: "ElasticV7Query" + i.capitalize() + SUFFIX for i in TYPES}
-
-ELASTICSEARCH_TAGS = [
-    "docker",
-    "elastic",
-]
-
-[junit_tests(
-    name = "elasticsearch_query_%ss_test_V7" % name,
-    size = "large",
-    srcs = [src],
-    tags = ELASTICSEARCH_TAGS,
-    deps = ELASTICSEARCH_DEPS + [QUERY_TESTS_DEP % name] + HTTP_TEST_DEPS,
-) for name, src in ELASTICSEARCH_TESTS_V7.items()]
-
-junit_tests(
-    name = "elasticsearch_tests",
-    size = "small",
-    srcs = glob(
-        ["*Test.java"],
-        exclude = ["Elastic*Query*" + SUFFIX],
-    ),
-    tags = ["elastic"],
-    deps = [
-        "//java/com/google/gerrit/elasticsearch",
-        "//java/com/google/gerrit/testing:gerrit-test-util",
-        "//lib:guava",
-        "//lib:jgit",
-        "//lib/guice",
-        "//lib/httpcomponents:httpcore",
-        "//lib/truth",
-    ],
-)
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
deleted file mode 100644
index 7e044c3..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticConfigurationTest.java
+++ /dev/null
@@ -1,129 +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.elasticsearch;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.DEFAULT_USERNAME;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PASSWORD;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_PREFIX;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_SERVER;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.KEY_USERNAME;
-import static com.google.gerrit.elasticsearch.ElasticConfiguration.SECTION_ELASTICSEARCH;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-import static java.util.stream.Collectors.toList;
-
-import com.google.common.collect.ImmutableList;
-import com.google.inject.ProvisionException;
-import java.util.Arrays;
-import org.apache.http.HttpHost;
-import org.eclipse.jgit.lib.Config;
-import org.junit.Test;
-
-public class ElasticConfigurationTest {
-  @Test
-  public void singleServerNoOtherConfig() throws Exception {
-    Config cfg = newConfig();
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic:1234");
-    assertThat(esCfg.username).isNull();
-    assertThat(esCfg.password).isNull();
-    assertThat(esCfg.prefix).isEmpty();
-  }
-
-  @Test
-  public void serverWithoutPortSpecified() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic:9200");
-  }
-
-  @Test
-  public void prefix() throws Exception {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PREFIX, "myprefix");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.prefix).isEqualTo("myprefix");
-  }
-
-  @Test
-  public void withAuthentication() throws Exception {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_USERNAME, "myself");
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.username).isEqualTo("myself");
-    assertThat(esCfg.password).isEqualTo("s3kr3t");
-  }
-
-  @Test
-  public void withAuthenticationPasswordOnlyUsesDefaultUsername() throws Exception {
-    Config cfg = newConfig();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_PASSWORD, "s3kr3t");
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertThat(esCfg.username).isEqualTo(DEFAULT_USERNAME);
-    assertThat(esCfg.password).isEqualTo("s3kr3t");
-  }
-
-  @Test
-  public void multipleServers() throws Exception {
-    Config cfg = new Config();
-    cfg.setStringList(
-        SECTION_ELASTICSEARCH,
-        null,
-        KEY_SERVER,
-        ImmutableList.of("http://elastic1:1234", "http://elastic2:1234"));
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic1:1234", "http://elastic2:1234");
-  }
-
-  @Test
-  public void noServers() throws Exception {
-    assertProvisionException(new Config());
-  }
-
-  @Test
-  public void singleServerInvalid() throws Exception {
-    Config cfg = new Config();
-    cfg.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "foo");
-    assertProvisionException(cfg);
-  }
-
-  @Test
-  public void multipleServersIncludingInvalid() throws Exception {
-    Config cfg = new Config();
-    cfg.setStringList(
-        SECTION_ELASTICSEARCH, null, KEY_SERVER, ImmutableList.of("http://elastic1:1234", "foo"));
-    ElasticConfiguration esCfg = new ElasticConfiguration(cfg);
-    assertHosts(esCfg, "http://elastic1:1234");
-  }
-
-  private static Config newConfig() {
-    Config config = new Config();
-    config.setString(SECTION_ELASTICSEARCH, null, KEY_SERVER, "http://elastic:1234");
-    return config;
-  }
-
-  private void assertHosts(ElasticConfiguration cfg, Object... hostURIs) throws Exception {
-    assertThat(Arrays.asList(cfg.getHosts()).stream().map(HttpHost::toURI).collect(toList()))
-        .containsExactly(hostURIs);
-  }
-
-  private void assertProvisionException(Config cfg) {
-    ProvisionException thrown =
-        assertThrows(ProvisionException.class, () -> new ElasticConfiguration(cfg));
-    assertThat(thrown).hasMessageThat().contains("No valid Elasticsearch servers configured");
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java b/javatests/com/google/gerrit/elasticsearch/ElasticContainer.java
deleted file mode 100644
index b4fb153..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticContainer.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.elasticsearch;
-
-import org.apache.http.HttpHost;
-import org.junit.AssumptionViolatedException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.testcontainers.elasticsearch.ElasticsearchContainer;
-import org.testcontainers.utility.DockerImageName;
-
-/* Helper class for running ES integration tests in docker container */
-public class ElasticContainer extends ElasticsearchContainer {
-  private static final int ELASTICSEARCH_DEFAULT_PORT = 9200;
-
-  public static ElasticContainer createAndStart(ElasticVersion version) {
-    // Assumption violation is not natively supported by Testcontainers.
-    // See https://github.com/testcontainers/testcontainers-java/issues/343
-    try {
-      ElasticContainer container = new ElasticContainer(version);
-      container.start();
-      return container;
-    } catch (Throwable t) {
-      throw new AssumptionViolatedException("Unable to start container", t);
-    }
-  }
-
-  private static String getImageName(ElasticVersion version) {
-    switch (version) {
-      case V7_16:
-        return "gerritforge/elasticsearch:7.16.2";
-    }
-    throw new IllegalStateException("No tests for version: " + version.name());
-  }
-
-  private ElasticContainer(ElasticVersion version) {
-    super(
-        DockerImageName.parse(getImageName(version))
-            .asCompatibleSubstituteFor("docker.elastic.co/elasticsearch/elasticsearch"));
-  }
-
-  @Override
-  protected Logger logger() {
-    return LoggerFactory.getLogger("org.testcontainers");
-  }
-
-  public HttpHost getHttpHost() {
-    return new HttpHost(getContainerIpAddress(), getMappedPort(ELASTICSEARCH_DEFAULT_PORT));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java b/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
deleted file mode 100644
index dcc6880..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticTestUtils.java
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.elasticsearch;
-
-import com.google.gerrit.index.IndexDefinition;
-import com.google.inject.Injector;
-import com.google.inject.Key;
-import com.google.inject.TypeLiteral;
-import java.util.Collection;
-import java.util.UUID;
-import org.eclipse.jgit.lib.Config;
-
-public final class ElasticTestUtils {
-  public static void configure(Config config, ElasticContainer container, String prefix) {
-    String hostname = container.getHttpHost().getHostName();
-    int port = container.getHttpHost().getPort();
-    config.setString("index", null, "type", "elasticsearch");
-    config.setString("elasticsearch", null, "server", "http://" + hostname + ":" + port);
-    config.setString("elasticsearch", null, "prefix", prefix);
-    config.setInt("index", null, "maxLimit", 10000);
-  }
-
-  public static void createAllIndexes(Injector injector) {
-    Collection<IndexDefinition<?, ?, ?>> indexDefs =
-        injector.getInstance(Key.get(new TypeLiteral<Collection<IndexDefinition<?, ?, ?>>>() {}));
-    for (IndexDefinition<?, ?, ?> indexDef : indexDefs) {
-      indexDef.getIndexCollection().getSearchIndex().deleteAll();
-    }
-  }
-
-  public static Config getConfig(ElasticVersion version) {
-    ElasticContainer container = ElasticContainer.createAndStart(version);
-    String indicesPrefix = UUID.randomUUID().toString();
-    Config cfg = new Config();
-    configure(cfg, container, indicesPrefix);
-    return cfg;
-  }
-
-  private ElasticTestUtils() {
-    // hide default constructor
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
deleted file mode 100644
index 39517d5..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryAccountsTest.java
+++ /dev/null
@@ -1,64 +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.elasticsearch;
-
-import com.google.gerrit.server.query.account.AbstractQueryAccountsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticV7QueryAccountsTest extends AbstractQueryAccountsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
deleted file mode 100644
index 5d64d0a..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryChangesTest.java
+++ /dev/null
@@ -1,95 +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.elasticsearch;
-
-import static java.util.concurrent.TimeUnit.MINUTES;
-
-import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.GerritTestName;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.apache.http.client.methods.HttpPost;
-import org.apache.http.client.protocol.HttpClientContext;
-import org.apache.http.impl.nio.client.CloseableHttpAsyncClient;
-import org.apache.http.impl.nio.client.HttpAsyncClients;
-import org.eclipse.jgit.lib.Config;
-import org.junit.After;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-import org.junit.Rule;
-
-public class ElasticV7QueryChangesTest extends AbstractQueryChangesTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-  private static CloseableHttpAsyncClient client;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-      client = HttpAsyncClients.createDefault();
-      client.start();
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Rule public final GerritTestName testName = new GerritTestName();
-
-  @After
-  public void closeIndex() throws Exception {
-    // Close the index after each test to prevent exceeding Elasticsearch's
-    // shard limit (see Issue 10120).
-    client
-        .execute(
-            new HttpPost(
-                String.format(
-                    "http://%s:%d/%s*/_close",
-                    container.getHttpHost().getHostName(),
-                    container.getHttpHost().getPort(),
-                    testName.getSanitizedMethodName())),
-            HttpClientContext.create(),
-            null)
-        .get(5, MINUTES);
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
deleted file mode 100644
index 645f889..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryGroupsTest.java
+++ /dev/null
@@ -1,64 +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.elasticsearch;
-
-import com.google.gerrit.server.query.group.AbstractQueryGroupsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticV7QueryGroupsTest extends AbstractQueryGroupsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
deleted file mode 100644
index 8d7f5f8..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticV7QueryProjectsTest.java
+++ /dev/null
@@ -1,64 +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.elasticsearch;
-
-import com.google.gerrit.server.query.project.AbstractQueryProjectsTest;
-import com.google.gerrit.testing.ConfigSuite;
-import com.google.gerrit.testing.InMemoryModule;
-import com.google.gerrit.testing.IndexConfig;
-import com.google.inject.Guice;
-import com.google.inject.Injector;
-import org.eclipse.jgit.lib.Config;
-import org.junit.AfterClass;
-import org.junit.BeforeClass;
-
-public class ElasticV7QueryProjectsTest extends AbstractQueryProjectsTest {
-  @ConfigSuite.Default
-  public static Config defaultConfig() {
-    return IndexConfig.createForElasticsearch();
-  }
-
-  private static ElasticContainer container;
-
-  @BeforeClass
-  public static void startIndexService() {
-    if (container == null) {
-      // Only start Elasticsearch once
-      container = ElasticContainer.createAndStart(ElasticVersion.V7_16);
-    }
-  }
-
-  @AfterClass
-  public static void stopElasticsearchServer() {
-    if (container != null) {
-      container.stop();
-    }
-  }
-
-  @Override
-  protected void initAfterLifecycleStart() throws Exception {
-    super.initAfterLifecycleStart();
-    ElasticTestUtils.createAllIndexes(injector);
-  }
-
-  @Override
-  protected Injector createInjector() {
-    Config elasticsearchConfig = new Config(config);
-    InMemoryModule.setDefaults(elasticsearchConfig);
-    String indicesPrefix = testName.getSanitizedMethodName();
-    ElasticTestUtils.configure(elasticsearchConfig, container, indicesPrefix);
-    return Guice.createInjector(new InMemoryModule(elasticsearchConfig));
-  }
-}
diff --git a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java b/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
deleted file mode 100644
index bfb332e..0000000
--- a/javatests/com/google/gerrit/elasticsearch/ElasticVersionTest.java
+++ /dev/null
@@ -1,39 +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.elasticsearch;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.testing.GerritJUnit.assertThrows;
-
-import org.junit.Test;
-
-public class ElasticVersionTest {
-  @Test
-  public void supportedVersion() throws Exception {
-    assertThat(ElasticVersion.forVersion("7.16.2")).isEqualTo(ElasticVersion.V7_16);
-  }
-
-  @Test
-  public void unsupportedVersion() throws Exception {
-    ElasticVersion.UnsupportedVersion thrown =
-        assertThrows(
-            ElasticVersion.UnsupportedVersion.class, () -> ElasticVersion.forVersion("4.0.0"));
-    assertThat(thrown)
-        .hasMessageThat()
-        .contains(
-            "Unsupported version: [4.0.0]. Supported versions: "
-                + ElasticVersion.supportedVersions());
-  }
-}
diff --git a/javatests/com/google/gerrit/entities/PatchSetTest.java b/javatests/com/google/gerrit/entities/PatchSetTest.java
index 61e1b4c..7e04fe8 100644
--- a/javatests/com/google/gerrit/entities/PatchSetTest.java
+++ b/javatests/com/google/gerrit/entities/PatchSetTest.java
@@ -96,6 +96,10 @@
   public void parseId() {
     assertThat(PatchSet.Id.parse("1,2")).isEqualTo(PatchSet.id(Change.id(1), 2));
     assertThat(PatchSet.Id.parse("01,02")).isEqualTo(PatchSet.id(Change.id(1), 2));
+    Change.Id cId = Change.id(321);
+    assertThat(
+            PatchSet.Id.fromEditRef(RefNames.refsEdit(Account.id(123), cId, PatchSet.id(cId, 6))))
+        .isEqualTo(PatchSet.id(cId, 6));
     assertInvalidId(null);
     assertInvalidId("");
     assertInvalidId("1");
diff --git a/javatests/com/google/gerrit/entities/converter/BUILD b/javatests/com/google/gerrit/entities/converter/BUILD
index 6ca9871..6c4d1e4 100644
--- a/javatests/com/google/gerrit/entities/converter/BUILD
+++ b/javatests/com/google/gerrit/entities/converter/BUILD
@@ -6,6 +6,7 @@
     deps = [
         "//java/com/google/gerrit/entities",
         "//java/com/google/gerrit/proto/testing",
+        "//java/com/google/gerrit/server",
         "//lib:guava",
         "//lib:jgit",
         "//lib:protobuf",
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
index b185558..ec6c372 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeMessageProtoConverterTest.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.proto.Entities;
 import com.google.gerrit.proto.testing.SerializedClassSubject;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import java.lang.reflect.Type;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -36,14 +37,14 @@
   @Test
   public void allValuesConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
-            PatchSet.id(Change.id(34), 13));
-    changeMessage.setMessage("This is a change message.");
-    changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(Account.id(10003));
+            PatchSet.id(Change.id(34), 13),
+            "This is a change message.",
+            Account.id(10003),
+            "An arbitrary tag.");
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -69,7 +70,7 @@
   @Test
   public void mainValuesConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
@@ -97,7 +98,7 @@
   @Test
   public void realAuthorIsNotAutomaticallySetToAuthorWhenConvertedToProto() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"), Account.id(63), null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
@@ -118,7 +119,8 @@
     // writtenOn may not be null according to the column definition but it's optional for the
     // protobuf definition. -> assume as optional and hence test null
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     Entities.ChangeMessage proto = changeMessageProtoConverter.toProto(changeMessage);
 
@@ -135,14 +137,14 @@
   @Test
   public void allValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
-            PatchSet.id(Change.id(34), 13));
-    changeMessage.setMessage("This is a change message.");
-    changeMessage.setTag("An arbitrary tag.");
-    changeMessage.setRealAuthor(Account.id(10003));
+            PatchSet.id(Change.id(34), 13),
+            "This is a change message.",
+            Account.id(10003),
+            "An arbitrary tag.");
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
@@ -150,9 +152,30 @@
   }
 
   @Test
+  public void messageTemplateConvertedToProtoAndParsedBack() {
+    ChangeMessage changeMessage =
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"),
+            Account.id(63),
+            new Timestamp(9876543),
+            PatchSet.id(Change.id(34), 13),
+            String.format(
+                "This is a change message by %s and includes %s ",
+                AccountTemplateUtil.getAccountTemplate(Account.id(10001)),
+                AccountTemplateUtil.getAccountTemplate(Account.id(10002))),
+            Account.id(10003),
+            "An arbitrary tag.");
+
+    ChangeMessage convertedChangeMessage =
+        changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
+
+    assertThat(convertedChangeMessage).isEqualTo(changeMessage);
+  }
+
+  @Test
   public void mainValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(Change.id(543), "change-message-21"),
             Account.id(63),
             new Timestamp(9876543),
@@ -166,7 +189,8 @@
   @Test
   public void mandatoryValuesConvertedToProtoAndBackAgain() {
     ChangeMessage changeMessage =
-        new ChangeMessage(ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
+        ChangeMessage.create(
+            ChangeMessage.key(Change.id(543), "change-message-21"), null, null, null);
 
     ChangeMessage convertedChangeMessage =
         changeMessageProtoConverter.fromProto(changeMessageProtoConverter.toProto(changeMessage));
diff --git a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
index ae8e06d..8c5e449 100644
--- a/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/ChangeProtoConverterTest.java
@@ -60,7 +60,6 @@
         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))
@@ -109,7 +108,6 @@
                     .setBranch("refs/heads/branch-74"))
             // Default values which can't be unset.
             .setCurrentPatchSetId(0)
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -147,7 +145,6 @@
                     .setBranch("refs/heads/branch-74"))
             .setCurrentPatchSetId(0)
             // Default values which can't be unset.
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -185,7 +182,6 @@
             .setCurrentPatchSetId(23)
             .setSubject("subject ABC")
             // Default values which can't be unset.
-            .setRowVersion(0)
             .setStatus(Change.STATUS_NEW)
             .setIsPrivate(false)
             .setWorkInProgress(false)
@@ -251,7 +247,6 @@
     assertThat(change.getSubject()).isNull();
     assertThat(change.currentPatchSetId()).isNull();
     // Default values for unset protobuf fields which can't be unset in the entity object.
-    assertThat(change.getRowVersion()).isEqualTo(0);
     assertThat(change.isNew()).isTrue();
     assertThat(change.isPrivate()).isFalse();
     assertThat(change.isWorkInProgress()).isFalse();
@@ -284,7 +279,6 @@
             ImmutableMap.<String, Type>builder()
                 .put("changeId", Change.Id.class)
                 .put("changeKey", Change.Key.class)
-                .put("rowVersion", int.class)
                 .put("createdOn", Timestamp.class)
                 .put("lastUpdatedOn", Timestamp.class)
                 .put("owner", Account.Id.class)
@@ -309,7 +303,6 @@
   private static void assertEqualChange(Change change, Change expectedChange) {
     assertThat(change.getChangeId()).isEqualTo(expectedChange.getChangeId());
     assertThat(change.getKey()).isEqualTo(expectedChange.getKey());
-    assertThat(change.getRowVersion()).isEqualTo(expectedChange.getRowVersion());
     assertThat(change.getCreatedOn()).isEqualTo(expectedChange.getCreatedOn());
     assertThat(change.getLastUpdatedOn()).isEqualTo(expectedChange.getLastUpdatedOn());
     assertThat(change.getOwner()).isEqualTo(expectedChange.getOwner());
diff --git a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
index bf39ff8..d332f8a 100644
--- a/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
+++ b/javatests/com/google/gerrit/entities/converter/PatchSetApprovalProtoConverterTest.java
@@ -49,6 +49,7 @@
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
+            .copied(true)
             .build();
 
     Entities.PatchSetApproval proto = protoConverter.toProto(patchSetApproval);
@@ -68,6 +69,7 @@
             .setTag("tag-21")
             .setRealAccountId(Entities.Account_Id.newBuilder().setId(612))
             .setPostSubmit(true)
+            .setCopied(true)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -99,6 +101,7 @@
             .setGranted(987654L)
             // This value can't be unset when our entity class is given.
             .setPostSubmit(false)
+            .setCopied(false)
             .build();
     assertThat(proto).isEqualTo(expectedProto);
   }
@@ -115,6 +118,7 @@
             .tag("tag-21")
             .realAccountId(Account.id(612))
             .postSubmit(true)
+            .copied(true)
             .build();
 
     PatchSetApproval convertedPatchSetApproval =
@@ -162,6 +166,7 @@
     assertThat(patchSetApproval.value()).isEqualTo(0);
     assertThat(patchSetApproval.granted()).isEqualTo(new Timestamp(0));
     assertThat(patchSetApproval.postSubmit()).isEqualTo(false);
+    assertThat(patchSetApproval.copied()).isEqualTo(false);
   }
 
   /** See {@link SerializedClassSubject} for background and what to do if this test fails. */
@@ -176,6 +181,7 @@
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("copied", boolean.class)
                 .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
diff --git a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
index 5e8c7b6..543428a 100644
--- a/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
+++ b/javatests/com/google/gerrit/extensions/client/ListOptionTest.java
@@ -19,8 +19,10 @@
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAR;
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.BAZ;
 import static com.google.gerrit.extensions.client.ListOptionTest.MyOption.FOO;
+import static org.junit.Assert.fail;
 
 import com.google.common.math.IntMath;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import java.util.EnumSet;
 import org.junit.Test;
 
@@ -43,6 +45,17 @@
   }
 
   @Test
+  public void fromHexString() {
+    try {
+      // TODO(hanwen): move GerritJUnit.assertThrows to a place that doesn't depend on everything.
+      ListOption.fromHexString(MyOption.class, "xyz");
+      fail("must throw");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).contains("32-bit integer");
+    }
+  }
+
+  @Test
   public void fromBits() {
     assertThat(IntMath.pow(2, BAZ.getValue())).isEqualTo(131072);
     assertThat(ListOption.fromBits(MyOption.class, 0)).isEmpty();
diff --git a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
index 4352fe8..024e35e 100644
--- a/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
+++ b/javatests/com/google/gerrit/extensions/common/ChangeInfoDifferTest.java
@@ -58,6 +58,17 @@
   }
 
   @Test
+  public void getDiff_returnsOldAndNewChangeInfos() {
+    ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
+    ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
+
+    ChangeInfoDifference diff = ChangeInfoDiffer.getDifference(oldChangeInfo, newChangeInfo);
+
+    assertThat(diff.oldChangeInfo()).isEqualTo(oldChangeInfo);
+    assertThat(diff.newChangeInfo()).isEqualTo(newChangeInfo);
+  }
+
+  @Test
   public void getDiff_givenUnchangedTopic_returnsNullTopics() {
     ChangeInfo oldChangeInfo = createChangeInfoWithTopic("topic");
     ChangeInfo newChangeInfo = createChangeInfoWithTopic(oldChangeInfo.topic);
diff --git a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
index f9f1fa85..f8945b5 100644
--- a/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
+++ b/javatests/com/google/gerrit/extensions/conditions/BooleanConditionTest.java
@@ -14,10 +14,6 @@
 
 package com.google.gerrit.extensions.conditions;
 
-import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.not;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
-import static com.google.gerrit.extensions.conditions.BooleanCondition.valueOf;
 import static org.junit.Assert.assertEquals;
 
 import org.junit.Test;
@@ -49,78 +45,84 @@
 
   @Test
   public void reduceAnd_CutOffNonTrivialWhenPossible() throws Exception {
-    BooleanCondition nonReduced = and(false, NO_TRIVIAL_EVALUATION);
-    BooleanCondition reduced = valueOf(false);
+    BooleanCondition nonReduced = BooleanCondition.and(false, NO_TRIVIAL_EVALUATION);
+    BooleanCondition reduced = BooleanCondition.valueOf(false);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceAnd_CutOffNonTrivialWhenPossibleSwapped() throws Exception {
-    BooleanCondition nonReduced = and(NO_TRIVIAL_EVALUATION, valueOf(false));
-    BooleanCondition reduced = valueOf(false);
+    BooleanCondition nonReduced =
+        BooleanCondition.and(NO_TRIVIAL_EVALUATION, BooleanCondition.valueOf(false));
+    BooleanCondition reduced = BooleanCondition.valueOf(false);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceAnd_KeepNonTrivialWhenNoCutOffPossible() throws Exception {
-    BooleanCondition nonReduced = and(true, NO_TRIVIAL_EVALUATION);
-    BooleanCondition reduced = and(true, NO_TRIVIAL_EVALUATION);
+    BooleanCondition nonReduced = BooleanCondition.and(true, NO_TRIVIAL_EVALUATION);
+    BooleanCondition reduced = BooleanCondition.and(true, NO_TRIVIAL_EVALUATION);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceAnd_KeepNonTrivialWhenNoCutOffPossibleSwapped() throws Exception {
-    BooleanCondition nonReduced = and(NO_TRIVIAL_EVALUATION, valueOf(true));
-    BooleanCondition reduced = and(NO_TRIVIAL_EVALUATION, valueOf(true));
+    BooleanCondition nonReduced =
+        BooleanCondition.and(NO_TRIVIAL_EVALUATION, BooleanCondition.valueOf(true));
+    BooleanCondition reduced =
+        BooleanCondition.and(NO_TRIVIAL_EVALUATION, BooleanCondition.valueOf(true));
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceOr_CutOffNonTrivialWhenPossible() throws Exception {
-    BooleanCondition nonReduced = or(true, NO_TRIVIAL_EVALUATION);
-    BooleanCondition reduced = valueOf(true);
+    BooleanCondition nonReduced = BooleanCondition.or(true, NO_TRIVIAL_EVALUATION);
+    BooleanCondition reduced = BooleanCondition.valueOf(true);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceOr_CutOffNonTrivialWhenPossibleSwapped() throws Exception {
-    BooleanCondition nonReduced = or(NO_TRIVIAL_EVALUATION, valueOf(true));
-    BooleanCondition reduced = valueOf(true);
+    BooleanCondition nonReduced =
+        BooleanCondition.or(NO_TRIVIAL_EVALUATION, BooleanCondition.valueOf(true));
+    BooleanCondition reduced = BooleanCondition.valueOf(true);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceOr_KeepNonTrivialWhenNoCutOffPossible() throws Exception {
-    BooleanCondition nonReduced = or(false, NO_TRIVIAL_EVALUATION);
-    BooleanCondition reduced = or(false, NO_TRIVIAL_EVALUATION);
+    BooleanCondition nonReduced = BooleanCondition.or(false, NO_TRIVIAL_EVALUATION);
+    BooleanCondition reduced = BooleanCondition.or(false, NO_TRIVIAL_EVALUATION);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceOr_KeepNonTrivialWhenNoCutOffPossibleSwapped() throws Exception {
-    BooleanCondition nonReduced = or(NO_TRIVIAL_EVALUATION, valueOf(false));
-    BooleanCondition reduced = or(NO_TRIVIAL_EVALUATION, valueOf(false));
+    BooleanCondition nonReduced =
+        BooleanCondition.or(NO_TRIVIAL_EVALUATION, BooleanCondition.valueOf(false));
+    BooleanCondition reduced =
+        BooleanCondition.or(NO_TRIVIAL_EVALUATION, BooleanCondition.valueOf(false));
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceNot_ReduceIrrelevant() throws Exception {
-    BooleanCondition nonReduced = not(valueOf(true));
-    BooleanCondition reduced = valueOf(false);
+    BooleanCondition nonReduced = BooleanCondition.not(BooleanCondition.valueOf(true));
+    BooleanCondition reduced = BooleanCondition.valueOf(false);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceNot_ReduceIrrelevant2() throws Exception {
-    BooleanCondition nonReduced = not(valueOf(false));
-    BooleanCondition reduced = valueOf(true);
+    BooleanCondition nonReduced = BooleanCondition.not(BooleanCondition.valueOf(false));
+    BooleanCondition reduced = BooleanCondition.valueOf(true);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
   @Test
   public void reduceNot_KeepNonTrivialWhenNoCutOffPossible() throws Exception {
-    BooleanCondition nonReduced = not(NO_TRIVIAL_EVALUATION);
-    BooleanCondition reduced = not(NO_TRIVIAL_EVALUATION);
+    BooleanCondition nonReduced = BooleanCondition.not(NO_TRIVIAL_EVALUATION);
+    BooleanCondition reduced = BooleanCondition.not(NO_TRIVIAL_EVALUATION);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
@@ -132,8 +134,10 @@
     //     /  \    \
     //   NTE NTE  TRUE
     BooleanCondition nonReduced =
-        and(or(NO_TRIVIAL_EVALUATION, NO_TRIVIAL_EVALUATION), not(valueOf(true)));
-    BooleanCondition reduced = valueOf(false);
+        BooleanCondition.and(
+            BooleanCondition.or(NO_TRIVIAL_EVALUATION, NO_TRIVIAL_EVALUATION),
+            BooleanCondition.not(BooleanCondition.valueOf(true)));
+    BooleanCondition reduced = BooleanCondition.valueOf(false);
     assertEquals(nonReduced.reduce(), reduced);
   }
 
@@ -145,8 +149,13 @@
     //     /  \   / \
     //   NTE NTE  T  F
     BooleanCondition nonReduced =
-        and(or(NO_TRIVIAL_EVALUATION, NO_TRIVIAL_EVALUATION), or(valueOf(true), valueOf(false)));
-    BooleanCondition reduced = and(or(NO_TRIVIAL_EVALUATION, NO_TRIVIAL_EVALUATION), valueOf(true));
+        BooleanCondition.and(
+            BooleanCondition.or(NO_TRIVIAL_EVALUATION, NO_TRIVIAL_EVALUATION),
+            BooleanCondition.or(BooleanCondition.valueOf(true), BooleanCondition.valueOf(false)));
+    BooleanCondition reduced =
+        BooleanCondition.and(
+            BooleanCondition.or(NO_TRIVIAL_EVALUATION, NO_TRIVIAL_EVALUATION),
+            BooleanCondition.valueOf(true));
     assertEquals(nonReduced.reduce(), reduced);
   }
 }
diff --git a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
index 45b3419..05e9808 100644
--- a/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
+++ b/javatests/com/google/gerrit/gpg/GerritPublicKeyCheckerTest.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.gpg;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.gpg.GerritPublicKeyChecker.toExtIdKey;
 import static com.google.gerrit.gpg.PublicKeyStore.keyToString;
 import static com.google.gerrit.gpg.testing.TestKeys.validKeyWithSecondUserId;
 import static com.google.gerrit.gpg.testing.TestTrustKeys.keyA;
@@ -39,6 +38,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testing.InMemoryModule;
@@ -76,6 +76,10 @@
 
   @Inject private ThreadLocalRequestContext requestContext;
 
+  @Inject private AuthRequest.Factory authRequestFactory;
+
+  @Inject private ExternalIdFactory externalIdFactory;
+
   private LifecycleManager lifecycle;
   private Account.Id userId;
   private IdentifiedUser user;
@@ -101,7 +105,7 @@
     lifecycle.start();
 
     schemaCreator.create();
-    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    userId = accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId();
     // Note: does not match any key in TestKeys.
     accountsUpdateProvider
         .get()
@@ -121,7 +125,7 @@
   }
 
   private IdentifiedUser addUser(String name) throws Exception {
-    AuthRequest req = AuthRequest.forUser(name);
+    AuthRequest req = authRequestFactory.createForUser(name);
     Account.Id id = accountManager.authenticate(req).getAccountId();
     return userFactory.create(id);
   }
@@ -202,16 +206,18 @@
     reloadUser();
 
     TestKey key = validKeyWithSecondUserId();
-    PublicKeyChecker checker = checkerFactory.create(user, store).disableTrust();
+    GerritPublicKeyChecker checker =
+        (GerritPublicKeyChecker) checkerFactory.create(user, store).disableTrust();
     assertProblems(
         checker.check(key.getPublicKey()),
         Status.BAD,
         "No identities found for user; check http://test/settings#Identities");
 
-    checker = checkerFactory.create().setStore(store).disableTrust();
+    checker = (GerritPublicKeyChecker) checkerFactory.create().setStore(store).disableTrust();
     assertProblems(
         checker.check(key.getPublicKey()), Status.BAD, "Key is not associated with any users");
-    insertExtId(ExternalId.create(toExtIdKey(key.getPublicKey()), user.getAccountId()));
+    insertExtId(
+        externalIdFactory.create(checker.toExtIdKey(key.getPublicKey()), user.getAccountId()));
     assertProblems(checker.check(key.getPublicKey()), Status.BAD, "No identities found for user");
   }
 
@@ -362,13 +368,15 @@
   private void add(PGPPublicKeyRing kr, IdentifiedUser user) throws Exception {
     Account.Id id = user.getAccountId();
     List<ExternalId> newExtIds = new ArrayList<>(2);
-    newExtIds.add(ExternalId.create(toExtIdKey(kr.getPublicKey()), id));
+    GerritPublicKeyChecker checker =
+        (GerritPublicKeyChecker) checkerFactory.create(user, store).disableTrust();
+    newExtIds.add(externalIdFactory.create(checker.toExtIdKey(kr.getPublicKey()), id));
 
     String userId = Iterators.getOnlyElement(kr.getPublicKey().getUserIDs(), null);
     if (userId != null) {
       String email = PushCertificateIdent.parse(userId).getEmailAddress();
       assertThat(email).contains("@");
-      newExtIds.add(ExternalId.createEmail(id, email));
+      newExtIds.add(externalIdFactory.createEmail(id, email));
     }
 
     store.add(kr);
@@ -401,7 +409,7 @@
   }
 
   private void addExternalId(String scheme, String id, String email) throws Exception {
-    insertExtId(ExternalId.createWithEmail(scheme, id, user.getAccountId(), email));
+    insertExtId(externalIdFactory.createWithEmail(scheme, id, user.getAccountId(), email));
   }
 
   private void insertExtId(ExternalId extId) throws Exception {
diff --git a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
index 2f0fafa..25334fd 100644
--- a/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
+++ b/javatests/com/google/gerrit/httpd/ProjectBasicAuthFilterTest.java
@@ -32,8 +32,12 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.AuthResult;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
+import com.google.gerrit.server.account.externalids.PasswordVerifier;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.util.http.testutil.FakeHttpServletRequest;
 import com.google.gerrit.util.http.testutil.FakeHttpServletResponse;
@@ -62,12 +66,6 @@
   private static final String AUTH_PASSWORD = "jd123";
   private static final String GERRIT_COOKIE_KEY = "GerritAccount";
   private static final String AUTH_COOKIE_VALUE = "gerritcookie";
-  private static final ExternalId AUTH_USER_PASSWORD_EXTERNAL_ID =
-      ExternalId.createWithPassword(
-          ExternalId.Key.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
-          AUTH_ACCOUNT_ID,
-          null,
-          AUTH_PASSWORD);
 
   @Mock private DynamicItem<WebSession> webSessionItem;
 
@@ -93,14 +91,23 @@
   private FakeHttpServletRequest req;
   private HttpServletResponse res;
   private AuthResult authSuccessful;
+  private ExternalIdFactory extIdFactory;
+  private ExternalIdKeyFactory extIdKeyFactory;
+  private PasswordVerifier pwdVerifier;
+  private AuthRequest.Factory authRequestFactory;
 
   @Before
   public void setUp() throws Exception {
     req = new FakeHttpServletRequest("gerrit.example.com", 80, "", "");
     res = new FakeHttpServletResponse();
 
+    extIdKeyFactory = new ExternalIdKeyFactory(new ExternalIdKeyFactory.ConfigImpl(authConfig));
+    extIdFactory = new ExternalIdFactory(extIdKeyFactory, authConfig);
+    authRequestFactory = new AuthRequest.Factory(extIdKeyFactory);
+    pwdVerifier = new PasswordVerifier(extIdKeyFactory, authConfig);
+
     authSuccessful =
-        new AuthResult(AUTH_ACCOUNT_ID, ExternalId.Key.create("username", AUTH_USER), false);
+        new AuthResult(AUTH_ACCOUNT_ID, extIdKeyFactory.create("username", AUTH_USER), false);
     doReturn(Optional.of(accountState)).when(accountCache).getByUsername(AUTH_USER);
     doReturn(Optional.of(accountState)).when(accountCache).get(AUTH_ACCOUNT_ID);
     doReturn(account).when(accountState).account();
@@ -121,7 +128,13 @@
     res.setStatus(HttpServletResponse.SC_OK);
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
 
@@ -136,7 +149,13 @@
     res.setStatus(HttpServletResponse.SC_OK);
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
 
@@ -155,7 +174,13 @@
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
 
@@ -175,7 +200,13 @@
     res.setStatus(HttpServletResponse.SC_OK);
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
 
@@ -216,7 +247,13 @@
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
 
@@ -235,7 +272,13 @@
     doReturn(GitBasicAuthPolicy.LDAP).when(authConfig).getGitBasicAuthPolicy();
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
 
@@ -255,7 +298,13 @@
     res.setStatus(HttpServletResponse.SC_OK);
 
     ProjectBasicAuthFilter basicAuthFilter =
-        new ProjectBasicAuthFilter(webSessionItem, accountCache, accountManager, authConfig);
+        new ProjectBasicAuthFilter(
+            webSessionItem,
+            accountCache,
+            accountManager,
+            authConfig,
+            authRequestFactory,
+            pwdVerifier);
 
     basicAuthFilter.doFilter(req, res, chain);
   }
@@ -278,9 +327,13 @@
   }
 
   private void initMockedUsernamePasswordExternalId() {
-    doReturn(ImmutableSet.builder().add(AUTH_USER_PASSWORD_EXTERNAL_ID).build())
-        .when(accountState)
-        .externalIds();
+    ExternalId extId =
+        extIdFactory.createWithPassword(
+            extIdKeyFactory.create(ExternalId.SCHEME_USERNAME, AUTH_USER),
+            AUTH_ACCOUNT_ID,
+            null,
+            AUTH_PASSWORD);
+    doReturn(ImmutableSet.builder().add(extId).build()).when(accountState).externalIds();
   }
 
   private void requestBasicAuth(FakeHttpServletRequest fakeReq) {
diff --git a/javatests/com/google/gerrit/index/query/AndPredicateTest.java b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
index 16828dd..01fa99b 100644
--- a/javatests/com/google/gerrit/index/query/AndPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/AndPredicateTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.index.query;
 
-import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.and;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
@@ -23,6 +22,7 @@
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
+import com.google.common.collect.ImmutableList;
 import java.util.List;
 import org.junit.Test;
 
@@ -44,13 +44,13 @@
     final Predicate<String> n = and(a, b);
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
-    assertChildren("clear", n, of(a, b));
+    assertChildren("clear", n, ImmutableList.of(a, b));
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
-    assertChildren("remove(0)", n, of(a, b));
+    assertChildren("remove(0)", n, ImmutableList.of(a, b));
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
-    assertChildren("iterator().remove()", n, of(a, b));
+    assertChildren("iterator().remove()", n, ImmutableList.of(a, b));
   }
 
   private static void assertChildren(
@@ -98,8 +98,8 @@
     final TestPredicate a = f("author", "alice");
     final TestPredicate b = f("author", "bob");
     final TestPredicate c = f("author", "charlie");
-    final List<TestPredicate> s2 = of(a, b);
-    final List<TestPredicate> s3 = of(a, b, c);
+    final List<TestPredicate> s2 = ImmutableList.of(a, b);
+    final List<TestPredicate> s3 = ImmutableList.of(a, b, c);
     final Predicate<String> n2 = and(a, b);
 
     assertNotSame(n2, n2.copy(s2));
diff --git a/javatests/com/google/gerrit/index/query/OrPredicateTest.java b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
index 1cbcb75..4d6c6e1 100644
--- a/javatests/com/google/gerrit/index/query/OrPredicateTest.java
+++ b/javatests/com/google/gerrit/index/query/OrPredicateTest.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.index.query;
 
-import static com.google.common.collect.ImmutableList.of;
 import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.testing.GerritJUnit.assertThrows;
 import static org.junit.Assert.assertEquals;
@@ -23,6 +22,7 @@
 import static org.junit.Assert.assertSame;
 import static org.junit.Assert.assertTrue;
 
+import com.google.common.collect.ImmutableList;
 import java.util.List;
 import org.junit.Test;
 
@@ -44,13 +44,13 @@
     final Predicate<String> n = or(a, b);
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().clear());
-    assertChildren("clear", n, of(a, b));
+    assertChildren("clear", n, ImmutableList.of(a, b));
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().remove(0));
-    assertChildren("remove(0)", n, of(a, b));
+    assertChildren("remove(0)", n, ImmutableList.of(a, b));
 
     assertThrows(UnsupportedOperationException.class, () -> n.getChildren().iterator().remove());
-    assertChildren("iterator().remove()", n, of(a, b));
+    assertChildren("iterator().remove()", n, ImmutableList.of(a, b));
   }
 
   private static void assertChildren(
@@ -98,8 +98,8 @@
     final TestPredicate a = f("author", "alice");
     final TestPredicate b = f("author", "bob");
     final TestPredicate c = f("author", "charlie");
-    final List<TestPredicate> s2 = of(a, b);
-    final List<TestPredicate> s3 = of(a, b, c);
+    final List<TestPredicate> s2 = ImmutableList.of(a, b);
+    final List<TestPredicate> s3 = ImmutableList.of(a, b, c);
     final Predicate<String> n2 = or(a, b);
 
     assertNotSame(n2, n2.copy(s2));
diff --git a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
index 892cabe..acf9a50 100644
--- a/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
+++ b/javatests/com/google/gerrit/integration/git/GitProtocolV2IT.java
@@ -114,7 +114,7 @@
       setUpUserAuthentication(admin.username());
 
       // Create non-admin user
-      TestAccount user = accountCreator.user();
+      TestAccount user = accountCreator.user1();
       setUpUserAuthentication(user.username());
 
       // Prepare data for new change on master branch
diff --git a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
index c33ef8e..ad8a486 100644
--- a/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
+++ b/javatests/com/google/gerrit/integration/git/NoAccessSameAsNotFoundIT.java
@@ -64,7 +64,7 @@
   private void setup(ServerContext ctx) throws Exception {
     ctx.getInjector().injectMembers(this);
 
-    TestAccount user = accountCreator.user();
+    TestAccount user = accountCreator.user1();
     gApi.accounts().id(user.username()).setHttpPassword(PASSWORD);
 
     String canonical = config.getString("gerrit", null, "canonicalweburl");
diff --git a/javatests/com/google/gerrit/mail/AbstractParserTest.java b/javatests/com/google/gerrit/mail/AbstractParserTest.java
index b22b8ad..a2432a2 100644
--- a/javatests/com/google/gerrit/mail/AbstractParserTest.java
+++ b/javatests/com/google/gerrit/mail/AbstractParserTest.java
@@ -31,11 +31,11 @@
   protected static final String CHANGE_URL =
       "https://gerrit-review.googlesource.com/c/project/+/123";
 
-  protected static void assertChangeMessage(String message, MailComment comment) {
+  protected static void assertPatchsetComment(String message, MailComment comment) {
     assertThat(comment.fileName).isNull();
     assertThat(comment.message).isEqualTo(message);
     assertThat(comment.inReplyTo).isNull();
-    assertThat(comment.type).isEqualTo(MailComment.CommentType.CHANGE_MESSAGE);
+    assertThat(comment.type).isEqualTo(MailComment.CommentType.PATCHSET_LEVEL);
   }
 
   protected static void assertInlineComment(
diff --git a/javatests/com/google/gerrit/mail/HtmlParserTest.java b/javatests/com/google/gerrit/mail/HtmlParserTest.java
index d661278..bb60fd8 100644
--- a/javatests/com/google/gerrit/mail/HtmlParserTest.java
+++ b/javatests/com/google/gerrit/mail/HtmlParserTest.java
@@ -35,7 +35,7 @@
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
   }
 
   @Test
@@ -56,7 +56,7 @@
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, "");
 
     assertThat(parsedComments).hasSize(1);
-    assertChangeMessage(
+    assertPatchsetComment(
         "Did you consider this: http://gerritcodereview.com", parsedComments.get(0));
   }
 
@@ -77,7 +77,7 @@
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
     assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
   }
@@ -100,7 +100,7 @@
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertInlineComment(
         "How about [1]? This would help IMHO.\n\n[1] http://gerritcodereview.com",
         parsedComments.get(1),
@@ -125,7 +125,7 @@
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
     assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(4));
   }
@@ -168,7 +168,7 @@
     List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage(txtMessage, parsedComments.get(0));
+    assertPatchsetComment(txtMessage, parsedComments.get(0));
     assertFileComment(txtMessage, parsedComments.get(1), comments.get(1).key.filename);
     assertInlineComment(txtMessage, parsedComments.get(2), comments.get(4));
   }
@@ -176,7 +176,6 @@
   /**
    * Create an html message body with the specified comments.
    *
-   * @param changeMessage
    * @param c1 Comment in reply to first comment.
    * @param c2 Comment in reply to second comment.
    * @param c3 Comment in reply to third comment.
diff --git a/javatests/com/google/gerrit/mail/TextParserTest.java b/javatests/com/google/gerrit/mail/TextParserTest.java
index f1d6179..d3e7447 100644
--- a/javatests/com/google/gerrit/mail/TextParserTest.java
+++ b/javatests/com/google/gerrit/mail/TextParserTest.java
@@ -43,7 +43,7 @@
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(1);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
   }
 
   @Test
@@ -64,7 +64,7 @@
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertInlineComment("I have a comment on this.", parsedComments.get(1), comments.get(1));
     assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
   }
@@ -87,7 +87,7 @@
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
     assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
   }
@@ -138,7 +138,7 @@
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(3);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertFileComment("This is a nice file", parsedComments.get(1), comments.get(1).key.filename);
     assertInlineComment("Also have a comment here.", parsedComments.get(2), comments.get(3));
   }
@@ -161,7 +161,7 @@
     List<MailComment> parsedComments = TextParser.parse(b.build(), comments, CHANGE_URL);
 
     assertThat(parsedComments).hasSize(2);
-    assertChangeMessage("Looks good to me", parsedComments.get(0));
+    assertPatchsetComment("Looks good to me", parsedComments.get(0));
     assertInlineComment("Comment in reply to file comment", parsedComments.get(1), comments.get(0));
   }
 
@@ -174,14 +174,13 @@
     List<MailComment> parsedComments = TextParser.parse(b.build(), defaultComments(), CHANGE_URL);
 
     assertThat(parsedComments).hasSize(1);
-    assertChangeMessage(
+    assertPatchsetComment(
         "Nice change\n\nMy other comment on the same entity", parsedComments.get(0));
   }
 
   /**
    * Create a plaintext message body with the specified comments.
    *
-   * @param changeMessage
    * @param c1 Comment in reply to first inline comment.
    * @param c2 Comment in reply to second inline comment.
    * @param c3 Comment in reply to third inline comment.
diff --git a/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java b/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
index e34b578..fd47567 100644
--- a/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
+++ b/javatests/com/google/gerrit/pgm/init/api/AllProjectsConfigTest.java
@@ -17,6 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.config.AllProjectsConfigProvider;
+import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.securestore.testing.InMemorySecureStore;
 import java.io.File;
@@ -76,8 +78,11 @@
     InitFlags flags = new InitFlags(sitePaths, secureStore, ImmutableList.of(), false);
     Section.Factory sections =
         (name, subsection) -> new Section(flags, sitePaths, secureStore, ui, name, subsection);
+    AllProjectsConfigProvider configProvider = new FileBasedAllProjectsConfigProvider(sitePaths);
+
     allProjectsConfig =
-        new AllProjectsConfig(new AllProjectsNameOnInitProvider(sections), sitePaths, flags);
+        new AllProjectsConfig(
+            new AllProjectsNameOnInitProvider(sections), configProvider, sitePaths, flags);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/BUILD b/javatests/com/google/gerrit/server/BUILD
index 7ab7ae9..837e69d 100644
--- a/javatests/com/google/gerrit/server/BUILD
+++ b/javatests/com/google/gerrit/server/BUILD
@@ -45,7 +45,9 @@
         "//java/com/google/gerrit/git",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/jgit",
+        "//java/com/google/gerrit/json",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/mail",
         "//java/com/google/gerrit/metrics",
@@ -55,6 +57,7 @@
         "//java/com/google/gerrit/server/account/externalids/testing",
         "//java/com/google/gerrit/server/cache/serialize",
         "//java/com/google/gerrit/server/cache/testing",
+        "//java/com/google/gerrit/server/cancellation",
         "//java/com/google/gerrit/server/fixes/testing",
         "//java/com/google/gerrit/server/git/receive:ref_cache",
         "//java/com/google/gerrit/server/ioutil",
diff --git a/javatests/com/google/gerrit/server/RequestInfoTest.java b/javatests/com/google/gerrit/server/RequestInfoTest.java
new file mode 100644
index 0000000..fafe856
--- /dev/null
+++ b/javatests/com/google/gerrit/server/RequestInfoTest.java
@@ -0,0 +1,57 @@
+// 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;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class RequestInfoTest {
+  @Test
+  public void redactRequestUri() throws Exception {
+    // test with valid request URIs
+    assertThat(redact("/")).isEqualTo("/");
+    assertThat(redact("/changes")).isEqualTo("/changes");
+    assertThat(redact("/changes/")).isEqualTo("/changes/");
+    assertThat(redact("/changes/123")).isEqualTo("/changes/*");
+    assertThat(redact("/changes/123/detail")).isEqualTo("/changes/*/detail");
+    assertThat(redact("/changes/123/detail/")).isEqualTo("/changes/*/detail/");
+    assertThat(redact("/accounts/self/capabilities")).isEqualTo("/accounts/*/capabilities");
+    assertThat(redact("/foo/123/bar/567")).isEqualTo("/foo/*/bar/*");
+    assertThat(redact("/foo/123/bar/567/baz")).isEqualTo("/foo/*/bar/*/baz");
+    assertThat(redact("/foo/123/bar/567/baz/")).isEqualTo("/foo/*/bar/*/baz/");
+    assertThat(redact("/foo/123/bar/567/baz/890")).isEqualTo("/foo/*/bar/*/baz/*");
+    assertThat(redact("changes")).isEqualTo("changes");
+    assertThat(redact("changes/")).isEqualTo("changes/");
+    assertThat(redact("changes/123")).isEqualTo("changes/*");
+    assertThat(redact("changes/123/detail")).isEqualTo("changes/*/detail");
+    assertThat(redact("changes/123/detail/")).isEqualTo("changes/*/detail/");
+    assertThat(redact("foo/123/bar/567")).isEqualTo("foo/*/bar/*");
+    assertThat(redact("foo/123/bar/567/baz")).isEqualTo("foo/*/bar/*/baz");
+    assertThat(redact("foo/123/bar/567/baz/")).isEqualTo("foo/*/bar/*/baz/");
+    assertThat(redact("foo/123/bar/567/baz/890")).isEqualTo("foo/*/bar/*/baz/*");
+
+    // test with invalid request URIs
+    assertThat(redact("")).isEqualTo("");
+    assertThat(redact("//")).isEqualTo("//");
+    assertThat(redact("///")).isEqualTo("///");
+    assertThat(redact("/changes//detail")).isEqualTo("/changes//detail");
+    assertThat(redact("//123/detail")).isEqualTo("//*/detail");
+  }
+
+  public static String redact(String uri) {
+    return RequestInfo.redactRequestUri(uri);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/account/AccountResolverTest.java b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
index 5f14d28..5e49aaf 100644
--- a/javatests/com/google/gerrit/server/account/AccountResolverTest.java
+++ b/javatests/com/google/gerrit/server/account/AccountResolverTest.java
@@ -352,7 +352,7 @@
   }
 
   private static AccountResolver newAccountResolver() {
-    return new AccountResolver(null, null, null, null, null, null, null, "Anonymous Name");
+    return new AccountResolver(null, null, null, null, null, null, null, null, "Anonymous Name");
   }
 
   private AccountState newAccount(int id) {
diff --git a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
index 45dacd9..eb2133e 100644
--- a/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/AllExternalIdsTest.java
@@ -25,12 +25,34 @@
 import com.google.gerrit.server.account.externalids.AllExternalIds.Serializer;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto;
 import com.google.gerrit.server.cache.proto.Cache.AllExternalIdsProto.ExternalIdProto;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.inject.TypeLiteral;
+import java.lang.reflect.Type;
 import java.util.Arrays;
 import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
 import org.junit.Test;
+import org.mockito.Mock;
 
 public class AllExternalIdsTest {
+  private ExternalIdFactory externalIdFactory;
+
+  @Mock AuthConfig authConfig;
+
+  @Before
+  public void setUp() throws Exception {
+    externalIdFactory =
+        new ExternalIdFactory(
+            new ExternalIdKeyFactory(
+                new ExternalIdKeyFactory.Config() {
+                  @Override
+                  public boolean isUserNameCaseInsensitive() {
+                    return false;
+                  }
+                }),
+            authConfig);
+  }
+
   @Test
   public void serializeEmptyExternalIds() throws Exception {
     assertRoundTrip(allExternalIds(), AllExternalIdsProto.getDefaultInstance());
@@ -42,10 +64,10 @@
     Account.Id accountId2 = Account.id(1002);
     assertRoundTrip(
         allExternalIds(
-            ExternalId.create("scheme1", "id1", accountId1),
-            ExternalId.create("scheme2", "id2", accountId1),
-            ExternalId.create("scheme2", "id3", accountId2),
-            ExternalId.create("scheme3", "id4", accountId2)),
+            externalIdFactory.create("scheme1", "id1", accountId1),
+            externalIdFactory.create("scheme2", "id2", accountId1),
+            externalIdFactory.create("scheme2", "id3", accountId2),
+            externalIdFactory.create("scheme3", "id4", accountId2)),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder().setKey("scheme1:id1").setAccountId(1001).build())
@@ -61,7 +83,7 @@
   @Test
   public void serializeExternalIdWithEmail() throws Exception {
     assertRoundTrip(
-        allExternalIds(ExternalId.createEmail(Account.id(1001), "foo@example.com")),
+        allExternalIds(externalIdFactory.createEmail(Account.id(1001), "foo@example.com")),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder()
@@ -75,7 +97,7 @@
   public void serializeExternalIdWithPassword() throws Exception {
     assertRoundTrip(
         allExternalIds(
-            ExternalId.create("scheme", "id", Account.id(1001), null, "hashed password")),
+            externalIdFactory.create("scheme", "id", Account.id(1001), null, "hashed password")),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
                 ExternalIdProto.newBuilder()
@@ -89,8 +111,8 @@
   public void serializeExternalIdWithBlobId() throws Exception {
     assertRoundTrip(
         allExternalIds(
-            ExternalId.create(
-                ExternalId.create("scheme", "id", Account.id(1001)),
+            externalIdFactory.create(
+                externalIdFactory.create("scheme", "id", Account.id(1001)),
                 ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"))),
         AllExternalIdsProto.newBuilder()
             .addExternalId(
@@ -109,26 +131,30 @@
     assertThatSerializedClass(AllExternalIds.class)
         .hasAutoValueMethods(
             ImmutableMap.of(
+                "byKey",
+                new TypeLiteral<ImmutableMap<ExternalId.Key, ExternalId>>() {}.getType(),
                 "byAccount",
-                    new TypeLiteral<ImmutableSetMultimap<Account.Id, ExternalId>>() {}.getType(),
+                new TypeLiteral<ImmutableSetMultimap<Account.Id, ExternalId>>() {}.getType(),
                 "byEmail",
-                    new TypeLiteral<ImmutableSetMultimap<String, ExternalId>>() {}.getType()));
+                new TypeLiteral<ImmutableSetMultimap<String, ExternalId>>() {}.getType()));
   }
 
   @Test
   public void externalIdMethods() {
     assertThatSerializedClass(ExternalId.class)
         .hasAutoValueMethods(
-            ImmutableMap.of(
-                "key", ExternalId.Key.class,
-                "accountId", Account.Id.class,
-                "email", String.class,
-                "password", String.class,
-                "blobId", ObjectId.class));
+            ImmutableMap.<String, Type>builder()
+                .put("key", ExternalId.Key.class)
+                .put("accountId", Account.Id.class)
+                .put("isCaseInsensitive", boolean.class)
+                .put("email", String.class)
+                .put("password", String.class)
+                .put("blobId", ObjectId.class)
+                .build());
   }
 
   private static AllExternalIds allExternalIds(ExternalId... externalIds) {
-    return AllExternalIds.create(Arrays.asList(externalIds));
+    return AllExternalIds.create(Arrays.stream(externalIds));
   }
 
   private static void assertRoundTrip(
diff --git a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
index 2f64ed0..1b5e951 100644
--- a/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
+++ b/javatests/com/google/gerrit/server/account/externalids/ExternalIDCacheLoaderTest.java
@@ -17,7 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
@@ -28,6 +28,7 @@
 import com.google.gerrit.server.account.externalids.testing.ExternalIdTestUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -44,6 +45,7 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.mockito.Mock;
 import org.mockito.Mockito;
 import org.mockito.junit.MockitoJUnitRunner;
 
@@ -57,11 +59,26 @@
   private ExternalIdReader externalIdReader;
   private ExternalIdReader externalIdReaderSpy;
 
+  private ExternalIdFactory externalIdFactory;
+  @Mock private AuthConfig authConfig;
+
   @Before
   public void setUp() throws Exception {
+    externalIdFactory =
+        new ExternalIdFactory(
+            new ExternalIdKeyFactory(
+                new ExternalIdKeyFactory.Config() {
+                  @Override
+                  public boolean isUserNameCaseInsensitive() {
+                    return false;
+                  }
+                }),
+            authConfig);
     externalIdCache = CacheBuilder.newBuilder().build();
     repoManager.createRepository(ALL_USERS).close();
-    externalIdReader = new ExternalIdReader(repoManager, ALL_USERS, new DisabledMetricMaker());
+    externalIdReader =
+        new ExternalIdReader(
+            repoManager, ALL_USERS, new DisabledMetricMaker(), externalIdFactory, authConfig);
     externalIdReaderSpy = Mockito.spy(externalIdReader);
     loader = createLoader(true);
   }
@@ -80,7 +97,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -92,7 +109,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -142,7 +159,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -151,12 +168,13 @@
     ObjectId head =
         modifyExternalId(
             externalId(1, 1),
-            ExternalId.create("fooschema", "bar1", Account.id(1), "foo@bar.com", "password"));
+            externalIdFactory.create(
+                "fooschema", "bar1", Account.id(1), "foo@bar.com", "password"));
     assertThat(allFromGit(head).byAccount().size()).isEqualTo(1);
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -173,7 +191,7 @@
     externalIdCache.put(firstState, allFromGit(firstState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -186,7 +204,7 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   @Test
@@ -200,7 +218,7 @@
     externalIdCache.put(oldState, allFromGit(oldState));
 
     assertThat(loader.load(head)).isEqualTo(allFromGit(head));
-    verifyZeroInteractions(externalIdReaderSpy);
+    verifyNoInteractions(externalIdReaderSpy);
   }
 
   private ExternalIdCacheLoader createLoader(boolean allowPartial) {
@@ -212,11 +230,12 @@
         externalIdReaderSpy,
         Providers.of(externalIdCache),
         new DisabledMetricMaker(),
-        cfg);
+        cfg,
+        externalIdFactory);
   }
 
   private AllExternalIds allFromGit(ObjectId revision) throws Exception {
-    return AllExternalIds.create(externalIdReader.all(revision));
+    return AllExternalIds.create(externalIdReader.all(revision).stream());
   }
 
   private ObjectId inserExternalIds(int numberOfIdsToInsert) throws Exception {
@@ -256,13 +275,14 @@
   }
 
   private ExternalId externalId(int key, int accountId) {
-    return ExternalId.create("fooschema", "bar" + key, Account.id(accountId));
+    return externalIdFactory.create("fooschema", "bar" + key, Account.id(accountId));
   }
 
   private ObjectId performExternalIdUpdate(Consumer<ExternalIdNotes> update) throws Exception {
     try (Repository repo = repoManager.openRepository(ALL_USERS)) {
       PersonIdent updater = new PersonIdent("Foo bar", "foo@bar.com");
-      ExternalIdNotes extIdNotes = ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo);
+      ExternalIdNotes extIdNotes =
+          ExternalIdNotes.loadNoCacheUpdate(ALL_USERS, repo, externalIdFactory, false);
       update.accept(extIdNotes);
       try (MetaDataUpdate metaDataUpdate =
           new MetaDataUpdate(GitReferenceUpdated.DISABLED, null, repo)) {
diff --git a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
index d8c6fe2..2264612 100644
--- a/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
+++ b/javatests/com/google/gerrit/server/cache/PersistentCacheFactoryIT.java
@@ -29,7 +29,7 @@
   @Inject PersistentCacheFactory persistentCacheFactory;
 
   @ModuleImpl(name = CacheModule.PERSISTENT_MODULE)
-  public static class Module extends AbstractModule {
+  public static class TestModule extends AbstractModule {
 
     @Override
     protected void configure() {
@@ -39,7 +39,7 @@
 
   @Override
   public com.google.inject.Module createModule() {
-    return new Module();
+    return new TestModule();
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
index 3ade4d0..14af43b 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheTest.java
@@ -26,6 +26,7 @@
 import com.google.common.cache.CacheBuilder;
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.server.cache.h2.H2CacheImpl.SqlStore;
@@ -34,6 +35,10 @@
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.inject.TypeLiteral;
 import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import javax.annotation.Nullable;
@@ -105,6 +110,62 @@
   }
 
   @Test
+  public void getAll_WithLoadingCache_LoaderNotImplementingLoadAll() throws ExecutionException {
+    Cache<String, ValueHolder<String>> mem =
+        CacheBuilder.newBuilder()
+            .build(
+                new CacheLoader<String, ValueHolder<String>>() {
+                  @Override
+                  public ValueHolder<String> load(String s) throws Exception {
+                    return new ValueHolder<>(s + "_loaded", Instant.now());
+                  }
+                });
+
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
+
+    assertThat(impl.getAll(Arrays.asList("S1", "S2")))
+        .containsExactlyEntriesIn(ImmutableMap.of("S1", "S1_loaded", "S2", "S2_loaded"));
+
+    // Make sure the values were cached
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S1")).isEqualTo("S1_loaded");
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S2")).isEqualTo("S2_loaded");
+  }
+
+  @Test
+  public void getAll_WithLoadingCache_LoaderImplementingLoadAll() throws ExecutionException {
+    Cache<String, ValueHolder<String>> mem =
+        CacheBuilder.newBuilder()
+            .build(
+                new CacheLoader<String, ValueHolder<String>>() {
+                  @Override
+                  public ValueHolder<String> load(String s) throws Exception {
+                    return new ValueHolder<>(s + "_loaded", Instant.now());
+                  }
+
+                  @Override
+                  public Map<String, ValueHolder<String>> loadAll(Iterable<? extends String> keys)
+                      throws Exception {
+                    Map<String, ValueHolder<String>> result = new HashMap<>();
+                    for (String k : keys) {
+                      result.put(k, load(k));
+                    }
+                    return result;
+                  }
+                });
+
+    H2CacheImpl<String, String> impl =
+        newH2CacheImpl(newStore(nextDbId(), DEFAULT_VERSION, null, null), mem);
+
+    assertThat(impl.getAll(Arrays.asList("S1", "S2")))
+        .containsExactlyEntriesIn(ImmutableMap.of("S1", "S1_loaded", "S2", "S2_loaded"));
+
+    // Make sure the values were cached
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S1")).isEqualTo("S1_loaded");
+    assertWithMessage("in-memory value").that(impl.getIfPresent("S2")).isEqualTo("S2_loaded");
+  }
+
+  @Test
   public void stringSerializer() {
     String input = "foo";
     byte[] serialized = StringCacheSerializer.INSTANCE.serialize(input);
diff --git a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
index 8fe7662..8f5b215 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/CommentContextSerializerTest.java
@@ -1,12 +1,12 @@
 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.CommentContextCacheImpl.CommentContextSerializer;
 import com.google.gerrit.server.comment.CommentContextKey;
 import org.junit.Test;
 
@@ -16,8 +16,8 @@
     CommentContext commentContext =
         CommentContext.create(ImmutableMap.of(1, "line_1", 2, "line_2"), "text/x-java");
 
-    byte[] serialized = INSTANCE.serialize(commentContext);
-    CommentContext deserialized = INSTANCE.deserialize(serialized);
+    byte[] serialized = CommentContextSerializer.INSTANCE.serialize(commentContext);
+    CommentContext deserialized = CommentContextSerializer.INSTANCE.deserialize(serialized);
 
     assertThat(commentContext).isEqualTo(deserialized);
   }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
index ecab07d..bb924ca 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffCacheKeySerializerTest.java
@@ -39,8 +39,9 @@
             .newCommit(COMMIT_ID_2)
             .newFilePath("some_file.txt")
             .renameScore(65)
-            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS)
             .whitespace(Whitespace.IGNORE_ALL)
+            .useTimeout(true)
             .build();
 
     byte[] serialized = FileDiffCacheKey.Serializer.INSTANCE.serialize(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
index 17fd959..c5e8574 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/FileDiffOutputSerializerTest.java
@@ -48,6 +48,7 @@
             .sizeDelta(10)
             .headerLines(ImmutableList.of("header line 1", "header line 2"))
             .edits(edits)
+            .negative(Optional.of(true))
             .build();
 
     byte[] serialized = FileDiffOutput.Serializer.INSTANCE.serialize(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
index 12d8d00..d7fdfe6 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffKeySerializerTest.java
@@ -38,8 +38,9 @@
             .newTree(TREE_ID_2)
             .newFilePath("some_file.txt")
             .renameScore(65)
-            .diffAlgorithm(DiffAlgorithm.HISTOGRAM)
+            .diffAlgorithm(DiffAlgorithm.HISTOGRAM_WITH_FALLBACK_MYERS)
             .whitespace(Whitespace.IGNORE_ALL)
+            .useTimeout(true)
             .build();
 
     byte[] serialized = GitFileDiffCacheKey.Serializer.INSTANCE.serialize(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
index 93441a4..e73c774 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/GitFileDiffSerializerTest.java
@@ -51,6 +51,7 @@
             .patchType(Optional.of(PatchType.UNIFIED))
             .oldMode(Optional.of(FileMode.REGULAR_FILE))
             .newMode(Optional.of(FileMode.REGULAR_FILE))
+            .negative(Optional.of(true))
             .build();
 
     byte[] serialized = Serializer.INSTANCE.serialize(gitFileDiff);
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 ad460cd..614dcf0 100644
--- a/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/LabelTypeSerializerTest.java
@@ -35,6 +35,7 @@
           .setIgnoreSelfApproval(!LabelType.DEF_IGNORE_SELF_APPROVAL)
           .setRefPatterns(ImmutableList.of("refs/heads/*", "refs/tags/*"))
           .setDefaultValue((short) 1)
+          .setCopyCondition("is:ANY")
           .setCopyAnyScore(!LabelType.DEF_COPY_ANY_SCORE)
           .setCopyMaxScore(!LabelType.DEF_COPY_MAX_SCORE)
           .setCopyMinScore(!LabelType.DEF_COPY_MIN_SCORE)
@@ -57,7 +58,8 @@
 
   @Test
   public void roundTripWithMinimalValues() {
-    LabelType autoValue = ALL_VALUES_SET.toBuilder().setRefPatterns(null).build();
+    LabelType autoValue =
+        ALL_VALUES_SET.toBuilder().setRefPatterns(null).setCopyCondition(null).build();
     assertThat(deserialize(serialize(autoValue))).isEqualTo(autoValue);
   }
 }
diff --git a/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
new file mode 100644
index 0000000..a1dee1a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cache/serialize/entities/SubmitRequirementSerializerTest.java
@@ -0,0 +1,41 @@
+// 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.serialize.entities;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer.deserialize;
+import static com.google.gerrit.server.cache.serialize.entities.SubmitRequirementSerializer.serialize;
+
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import java.util.Optional;
+import org.junit.Test;
+
+public class SubmitRequirementSerializerTest {
+  private static final SubmitRequirement submitReq =
+      SubmitRequirement.builder()
+          .setName("code-review")
+          .setDescription(Optional.of("require code review +2"))
+          .setApplicabilityExpression(SubmitRequirementExpression.of("branch(refs/heads/master)"))
+          .setSubmittabilityExpression(SubmitRequirementExpression.create("label(code-review, 2+)"))
+          .setOverrideExpression(Optional.empty())
+          .setAllowOverrideInChildProjects(true)
+          .build();
+
+  @Test
+  public void roundTrip() {
+    assertThat(deserialize(serialize(submitReq))).isEqualTo(submitReq);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java b/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
new file mode 100644
index 0000000..2d3e94a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/cancellation/RequestStateContextTest.java
@@ -0,0 +1,268 @@
+// 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.cancellation;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.cancellation.RequestStateContext.NonCancellableOperationContext;
+import org.junit.Test;
+
+public class RequestStateContextTest {
+  @Test
+  public void openContext() {
+    assertNoRequestStateProviders();
+
+    RequestStateProvider requestStateProvider1 = new TestRequestStateProvider();
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+      RequestStateProvider requestStateProvider2 = new TestRequestStateProvider();
+      requestStateContext.addRequestStateProvider(requestStateProvider2);
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+    }
+
+    assertNoRequestStateProviders();
+  }
+
+  @Test
+  public void openNestedContexts() {
+    assertNoRequestStateProviders();
+
+    RequestStateProvider requestStateProvider1 = new TestRequestStateProvider();
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+      RequestStateProvider requestStateProvider2 = new TestRequestStateProvider();
+      requestStateContext.addRequestStateProvider(requestStateProvider2);
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+
+      RequestStateProvider requestStateProvider3 = new TestRequestStateProvider();
+      try (RequestStateContext requestStateContext2 =
+          RequestStateContext.open().addRequestStateProvider(requestStateProvider3)) {
+        RequestStateProvider requestStateProvider4 = new TestRequestStateProvider();
+        requestStateContext2.addRequestStateProvider(requestStateProvider4);
+        assertRequestStateProviders(
+            ImmutableSet.of(
+                requestStateProvider1,
+                requestStateProvider2,
+                requestStateProvider3,
+                requestStateProvider4));
+      }
+
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+    }
+
+    assertNoRequestStateProviders();
+  }
+
+  @Test
+  public void openNestedContextsWithSameRequestStateProviders() {
+    assertNoRequestStateProviders();
+
+    RequestStateProvider requestStateProvider1 = new TestRequestStateProvider();
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+      RequestStateProvider requestStateProvider2 = new TestRequestStateProvider();
+      requestStateContext.addRequestStateProvider(requestStateProvider2);
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+
+      try (RequestStateContext requestStateContext2 =
+          RequestStateContext.open().addRequestStateProvider(requestStateProvider1)) {
+        requestStateContext2.addRequestStateProvider(requestStateProvider2);
+
+        assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+      }
+
+      assertRequestStateProviders(ImmutableSet.of(requestStateProvider1, requestStateProvider2));
+    }
+
+    assertNoRequestStateProviders();
+  }
+
+  @Test
+  public void abortIfCancelled_noRequestStateProvider() {
+    assertNoRequestStateProviders();
+
+    // Calling abortIfCancelled() shouldn't throw an exception.
+    RequestStateContext.abortIfCancelled();
+  }
+
+  @Test
+  public void abortIfCancelled_requestNotCancelled() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {}
+                })) {
+      // Calling abortIfCancelled() shouldn't throw an exception.
+      RequestStateContext.abortIfCancelled();
+    }
+  }
+
+  @Test
+  public void abortIfCancelled_requestCancelled() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {
+                    onCancelled.onCancel(
+                        RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* message= */ null);
+                  }
+                })) {
+      RequestCancelledException requestCancelledException =
+          assertThrows(
+              RequestCancelledException.class, () -> RequestStateContext.abortIfCancelled());
+      assertThat(requestCancelledException)
+          .hasMessageThat()
+          .isEqualTo("Request cancelled: CLIENT_CLOSED_REQUEST");
+      assertThat(requestCancelledException.getCancellationReason())
+          .isEqualTo(RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST);
+      assertThat(requestCancelledException.getCancellationMessage()).isEmpty();
+    }
+  }
+
+  @Test
+  public void abortIfCancelled_requestCancelled_withMessage() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {
+                    onCancelled.onCancel(
+                        RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED, "deadline = 10m");
+                  }
+                })) {
+      RequestCancelledException requestCancelledException =
+          assertThrows(
+              RequestCancelledException.class, () -> RequestStateContext.abortIfCancelled());
+      assertThat(requestCancelledException)
+          .hasMessageThat()
+          .isEqualTo("Request cancelled: SERVER_DEADLINE_EXCEEDED (deadline = 10m)");
+      assertThat(requestCancelledException.getCancellationReason())
+          .isEqualTo(RequestStateProvider.Reason.SERVER_DEADLINE_EXCEEDED);
+      assertThat(requestCancelledException.getCancellationMessage()).hasValue("deadline = 10m");
+    }
+  }
+
+  @Test
+  public void nonCancellableOperation_requestNotCanclled() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {}
+                })) {
+      // Calling abortIfCancelled() shouldn't throw an exception.
+      RequestStateContext.abortIfCancelled();
+      try (NonCancellableOperationContext nonCancellableOperationContext =
+          RequestStateContext.startNonCancellableOperation()) {
+        // Calling abortIfCancelled() shouldn't throw an exception.
+        RequestStateContext.abortIfCancelled();
+      }
+      // Calling abortIfCancelled() shouldn't throw an exception.
+      RequestStateContext.abortIfCancelled();
+    }
+  }
+
+  @Test
+  public void nonCancellableOperationNotAborted() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {
+                    onCancelled.onCancel(
+                        RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* message= */ null);
+                  }
+                })) {
+      assertThrows(RequestCancelledException.class, () -> RequestStateContext.abortIfCancelled());
+      boolean cancelledOnClose = false;
+      try (NonCancellableOperationContext nonCancellableOperationContext =
+          RequestStateContext.startNonCancellableOperation()) {
+        // Calling abortIfCancelled() shouldn't throw an exception since we are within a
+        // non-cancellable operation.
+        RequestStateContext.abortIfCancelled();
+      } catch (RequestCancelledException e) {
+        // The request is expected to get aborted on close of the non-cancellable operation.
+        cancelledOnClose = true;
+      }
+      assertThat(cancelledOnClose).isTrue();
+    }
+  }
+
+  @Test
+  public void nestedNonCancellableOperationNotAborted() {
+    try (RequestStateContext requestStateContext =
+        RequestStateContext.open()
+            .addRequestStateProvider(
+                new RequestStateProvider() {
+                  @Override
+                  public void checkIfCancelled(OnCancelled onCancelled) {
+                    onCancelled.onCancel(
+                        RequestStateProvider.Reason.CLIENT_CLOSED_REQUEST, /* message= */ null);
+                  }
+                })) {
+      assertThrows(RequestCancelledException.class, () -> RequestStateContext.abortIfCancelled());
+      boolean cancelledOnClose = false;
+      try (NonCancellableOperationContext nonCancellableOperationContext =
+          RequestStateContext.startNonCancellableOperation()) {
+        // Calling abortIfCancelled() shouldn't throw an exception since we are within a
+        // non-cancellable operation.
+        RequestStateContext.abortIfCancelled();
+
+        try (NonCancellableOperationContext nestedNonCancellableOperationContext =
+            RequestStateContext.startNonCancellableOperation()) {
+          // Calling abortIfCancelled() shouldn't throw an exception since we are within a
+          // non-cacellable operation.
+          RequestStateContext.abortIfCancelled();
+
+          // Close of the nestedNonCancellableOperationContext shouldn't throw an exception since
+          // the outer nonCancellableOperationContext is still open.
+        }
+
+        // Calling abortIfCancelled() shouldn't throw an exception since we are within a
+        // non-cancellable operation.
+        RequestStateContext.abortIfCancelled();
+      } catch (RequestCancelledException e) {
+        // The request is expected to get aborted on close of the non-cancellable operation.
+        cancelledOnClose = true;
+      }
+      assertThat(cancelledOnClose).isTrue();
+    }
+  }
+
+  private void assertNoRequestStateProviders() {
+    assertRequestStateProviders(ImmutableSet.of());
+  }
+
+  private void assertRequestStateProviders(
+      ImmutableSet<RequestStateProvider> expectedRequestStateProviders) {
+    assertThat(RequestStateContext.getRequestStateProviders())
+        .containsExactlyElementsIn(expectedRequestStateProviders);
+  }
+
+  private static class TestRequestStateProvider implements RequestStateProvider {
+    @Override
+    public void checkIfCancelled(OnCancelled onCancelled) {}
+  }
+}
diff --git a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
index 20813f6..01537e0 100644
--- a/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
+++ b/javatests/com/google/gerrit/server/change/ChangeKindCacheImplTest.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.proto.testing.SerializedClassSubject;
 import com.google.gerrit.server.cache.proto.Cache.ChangeKindKeyProto;
 import com.google.gerrit.server.cache.serialize.CacheSerializer;
-import com.google.gerrit.server.change.ChangeKindCacheImpl.Key;
 import org.eclipse.jgit.lib.ObjectId;
 import org.junit.Test;
 
@@ -31,7 +30,7 @@
   @Test
   public void keySerializer() throws Exception {
     ChangeKindCacheImpl.Key key =
-        Key.create(
+        ChangeKindCacheImpl.Key.create(
             ObjectId.zeroId(),
             ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
             "aStrategy");
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadTest.java b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
index dc46e48..08485a4 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadTest.java
@@ -19,7 +19,6 @@
 
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Comment;
-import com.google.gerrit.entities.Comment.Key;
 import com.google.gerrit.entities.HumanComment;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -59,7 +58,7 @@
 
   private static HumanComment createComment(String commentUuid) {
     return new HumanComment(
-        new Key(commentUuid, "myFile", 1),
+        new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
         new Timestamp(1234),
         (short) 1,
diff --git a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
index 56566d3..0c61906 100644
--- a/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
+++ b/javatests/com/google/gerrit/server/change/CommentThreadsTest.java
@@ -19,7 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.entities.Account;
-import com.google.gerrit.entities.Comment.Key;
+import com.google.gerrit.entities.Comment;
 import com.google.gerrit.entities.HumanComment;
 import java.sql.Timestamp;
 import org.junit.Test;
@@ -260,7 +260,7 @@
 
   private static HumanComment createComment(String commentUuid) {
     return new HumanComment(
-        new Key(commentUuid, "myFile", 1),
+        new Comment.Key(commentUuid, "myFile", 1),
         Account.id(100),
         new Timestamp(1234),
         (short) 1,
diff --git a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java b/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
deleted file mode 100644
index b69a894..0000000
--- a/javatests/com/google/gerrit/server/change/IncludedInResolverTest.java
+++ /dev/null
@@ -1,175 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.entities.RefNames.REFS_TAGS;
-
-import com.google.common.truth.Correspondence;
-import com.google.gerrit.truth.NullAwareCorrespondence;
-import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.junit.Before;
-import org.junit.Test;
-
-public class IncludedInResolverTest {
-  // Branch names
-  private static final String BRANCH_MASTER = "master";
-  private static final String BRANCH_1_0 = "rel-1.0";
-  private static final String BRANCH_1_3 = "rel-1.3";
-  private static final String BRANCH_2_0 = "rel-2.0";
-  private static final String BRANCH_2_5 = "rel-2.5";
-
-  // Tag names
-  private static final String TAG_1_0 = "1.0";
-  private static final String TAG_1_0_1 = "1.0.1";
-  private static final String TAG_1_3 = "1.3";
-  private static final String TAG_2_0_1 = "2.0.1";
-  private static final String TAG_2_0 = "2.0";
-  private static final String TAG_2_5 = "2.5";
-  private static final String TAG_2_5_ANNOTATED = "2.5-annotated";
-  private static final String TAG_2_5_ANNOTATED_TWICE = "2.5-annotated_twice";
-
-  // Commits
-  private RevCommit commit_initial;
-  private RevCommit commit_v1_3;
-  private RevCommit commit_v2_5;
-
-  private TestRepository<?> tr;
-
-  @Before
-  public void setUp() throws Exception {
-    tr = new TestRepository<>(new InMemoryRepository(new DfsRepositoryDescription("repo")));
-
-    /*- The following graph will be created.
-
-     o   tag 2.5, 2.5_annotated, 2.5_annotated_twice
-     |\
-     | o tag 2.0.1
-     | o tag 2.0
-     o | tag 1.3
-     |/
-     o   c3
-
-     | o tag 1.0.1
-     |/
-     o   tag 1.0
-     o   c2
-     o   c1
-
-    */
-
-    // Version 1.0
-    commit_initial = tr.branch(BRANCH_MASTER).commit().message("c1").create();
-    tr.branch(BRANCH_MASTER).commit().message("c2").create();
-    RevCommit commit_v1_0 = tr.branch(BRANCH_MASTER).commit().message("version 1.0").create();
-    tag(TAG_1_0, commit_v1_0);
-    RevCommit c3 = tr.branch(BRANCH_MASTER).commit().message("c3").create();
-
-    // Version 1.01
-    tr.branch(BRANCH_1_0).update(commit_v1_0);
-    RevCommit commit_v1_0_1 = tr.branch(BRANCH_1_0).commit().message("version 1.0.1").create();
-    tag(TAG_1_0_1, commit_v1_0_1);
-
-    // Version 1.3
-    tr.branch(BRANCH_1_3).update(c3);
-    commit_v1_3 = tr.branch(BRANCH_1_3).commit().message("version 1.3").create();
-    tag(TAG_1_3, commit_v1_3);
-
-    // Version 2.0
-    tr.branch(BRANCH_2_0).update(c3);
-    RevCommit commit_v2_0 = tr.branch(BRANCH_2_0).commit().message("version 2.0").create();
-    tag(TAG_2_0, commit_v2_0);
-    RevCommit commit_v2_0_1 = tr.branch(BRANCH_2_0).commit().message("version 2.0.1").create();
-    tag(TAG_2_0_1, commit_v2_0_1);
-
-    // Version 2.5
-    tr.branch(BRANCH_2_5).update(commit_v1_3);
-    tr.branch(BRANCH_2_5).commit().parent(commit_v2_0_1).create(); // Merge v2.0.1
-    commit_v2_5 = tr.branch(BRANCH_2_5).commit().message("version 2.5").create();
-    tr.update(REFS_TAGS + TAG_2_5, commit_v2_5);
-    RevTag tag_2_5_annotated = tag(TAG_2_5_ANNOTATED, commit_v2_5);
-    tag(TAG_2_5_ANNOTATED_TWICE, tag_2_5_annotated);
-  }
-
-  @Test
-  public void resolveLatestCommit() throws Exception {
-    // Check tip commit
-    IncludedInResolver.Result detail = resolve(commit_v2_5);
-
-    // Check that only tags and branches which refer the tip are returned
-    assertThat(detail.tags())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
-    assertThat(detail.branches())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(BRANCH_2_5);
-  }
-
-  @Test
-  public void resolveFirstCommit() throws Exception {
-    // Check first commit
-    IncludedInResolver.Result detail = resolve(commit_initial);
-
-    // Check whether all tags and branches are returned
-    assertThat(detail.tags())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(
-            TAG_1_0,
-            TAG_1_0_1,
-            TAG_1_3,
-            TAG_2_0,
-            TAG_2_0_1,
-            TAG_2_5,
-            TAG_2_5_ANNOTATED,
-            TAG_2_5_ANNOTATED_TWICE);
-    assertThat(detail.branches())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(BRANCH_MASTER, BRANCH_1_0, BRANCH_1_3, BRANCH_2_0, BRANCH_2_5);
-  }
-
-  @Test
-  public void resolveBetwixtCommit() throws Exception {
-    // Check a commit somewhere in the middle
-    IncludedInResolver.Result detail = resolve(commit_v1_3);
-
-    // Check whether all succeeding tags and branches are returned
-    assertThat(detail.tags())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(TAG_1_3, TAG_2_5, TAG_2_5_ANNOTATED, TAG_2_5_ANNOTATED_TWICE);
-    assertThat(detail.branches())
-        .comparingElementsUsing(hasShortName())
-        .containsExactly(BRANCH_1_3, BRANCH_2_5);
-  }
-
-  private IncludedInResolver.Result resolve(RevCommit commit) throws Exception {
-    return IncludedInResolver.resolve(tr.getRepository(), tr.getRevWalk(), commit);
-  }
-
-  private RevTag tag(String name, RevObject dest) throws Exception {
-    return tr.update(REFS_TAGS + name, tr.tag(name, dest));
-  }
-
-  private static Correspondence<Ref, String> hasShortName() {
-    return NullAwareCorrespondence.transforming(
-        ref -> Repository.shortenRefName(ref.getName()), "has short name");
-  }
-}
diff --git a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
index 82991b6..8f341aa 100644
--- a/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
+++ b/javatests/com/google/gerrit/server/change/LabelNormalizerTest.java
@@ -71,6 +71,7 @@
   @Inject private ProjectConfig.Factory projectConfigFactory;
   @Inject private GerritApi gApi;
   @Inject private ProjectOperations projectOperations;
+  @Inject private AuthRequest.Factory authRequestFactory;
 
   private LifecycleManager lifecycle;
   private Account.Id userId;
@@ -87,7 +88,7 @@
     lifecycle.start();
 
     schemaCreator.create();
-    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    userId = accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId();
     user = userFactory.create(userId);
 
     requestContext.setContext(() -> user);
diff --git a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
index 7832bec..6b8177e 100644
--- a/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/GitRepositoryManagerTest.java
@@ -36,6 +36,12 @@
   }
 
   private static class TestGitRepositoryManager implements GitRepositoryManager {
+
+    @Override
+    public Status getRepositoryStatus(NameKey name) {
+      throw new UnsupportedOperationException("Not implemented");
+    }
+
     @Override
     public Repository openRepository(NameKey name) {
       throw new UnsupportedOperationException("Not implemented");
diff --git a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
index febb142..12130ea 100644
--- a/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
+++ b/javatests/com/google/gerrit/server/git/LocalDiskRepositoryManagerTest.java
@@ -20,6 +20,7 @@
 
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager.Status;
 import com.google.gerrit.server.ioutil.HostPlatform;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -67,6 +68,7 @@
     try (Repository repo = repoManager.openRepository(projectA)) {
       assertThat(repo).isNotNull();
     }
+    assertThat(repoManager.getRepositoryStatus(projectA)).isEqualTo(Status.ACTIVE);
     assertThat(repoManager.list()).containsExactly(projectA);
   }
 
@@ -199,7 +201,7 @@
   public void testProjectRecreation() throws Exception {
     repoManager.createRepository(Project.nameKey("a"));
     assertThrows(
-        IllegalStateException.class, () -> repoManager.createRepository(Project.nameKey("a")));
+        RepositoryExistsException.class, () -> repoManager.createRepository(Project.nameKey("a")));
   }
 
   @Test
@@ -207,7 +209,8 @@
     repoManager.createRepository(Project.nameKey("a"));
     LocalDiskRepositoryManager newRepoManager = new LocalDiskRepositoryManager(site, cfg);
     assertThrows(
-        IllegalStateException.class, () -> newRepoManager.createRepository(Project.nameKey("a")));
+        RepositoryExistsException.class,
+        () -> newRepoManager.createRepository(Project.nameKey("a")));
   }
 
   @Test
@@ -221,6 +224,19 @@
   }
 
   @Test
+  public void testGetRepositoryStatusNameCaseMismatch() throws Exception {
+    assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
+    repoManager.createRepository(Project.nameKey("a"));
+    assertThat(repoManager.getRepositoryStatus(Project.nameKey("A"))).isEqualTo(Status.ACTIVE);
+  }
+
+  @Test
+  public void testGetRepositoryStatusNonExistent() throws Exception {
+    assertThat(repoManager.getRepositoryStatus(Project.nameKey("non-existent")))
+        .isEqualTo(Status.NON_EXISTENT);
+  }
+
+  @Test
   public void testNameCaseMismatch() throws Exception {
     assume().that(HostPlatform.isWin32() || HostPlatform.isMac()).isTrue();
     repoManager.createRepository(Project.nameKey("a"));
@@ -272,6 +288,12 @@
   }
 
   @Test
+  public void testGetRepoStatusInvalidName() throws Exception {
+    assertThat(repoManager.getRepositoryStatus(Project.nameKey("project%?|<>A")))
+        .isEqualTo(Status.NON_EXISTENT);
+  }
+
+  @Test
   public void list() throws Exception {
     Project.NameKey projectA = Project.nameKey("projectA");
     createRepository(repoManager.getBasePath(projectA), projectA.get());
diff --git a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
index 7da4785..6ad899e 100644
--- a/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
+++ b/javatests/com/google/gerrit/server/group/db/AuditLogReaderTest.java
@@ -242,17 +242,17 @@
             .setNameKey(AccountGroup.nameKey(groupName))
             .setId(AccountGroup.id(next))
             .build();
-    InternalGroupUpdate groupUpdate =
+    GroupDelta groupDelta =
         authorIdent.equals(serverIdent)
-            ? InternalGroupUpdate.builder().setDescription("Groups").build()
-            : InternalGroupUpdate.builder()
+            ? GroupDelta.builder().setDescription("Groups").build()
+            : GroupDelta.builder()
                 .setDescription("Groups")
                 .setMemberModification(members -> ImmutableSet.of(authorId))
                 .build();
 
     GroupConfig groupConfig =
         GroupConfig.createForNewGroup(allUsersName, allUsersRepo, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
+    groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
 
     groupConfig.commit(createMetaDataUpdate(authorIdent));
     return groupConfig
@@ -260,45 +260,42 @@
         .orElseThrow(() -> new IllegalStateException("create group failed"));
   }
 
-  private void updateGroup(AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate)
-      throws Exception {
+  private void updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta) throws Exception {
     GroupConfig groupConfig = GroupConfig.loadForGroup(allUsersName, allUsersRepo, uuid);
-    groupConfig.setGroupUpdate(groupUpdate, getAuditLogFormatter());
+    groupConfig.setGroupDelta(groupDelta, getAuditLogFormatter());
     groupConfig.commit(createMetaDataUpdate(userIdent));
   }
 
   private void addMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
-            .setMemberModification(memberIds -> Sets.union(memberIds, ids))
-            .build();
-    updateGroup(groupUuid, update);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setMemberModification(memberIds -> Sets.union(memberIds, ids)).build();
+    updateGroup(groupUuid, groupDelta);
   }
 
   private void removeMembers(AccountGroup.UUID groupUuid, Set<Account.Id> ids) throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(memberIds -> Sets.difference(memberIds, ids))
             .build();
-    updateGroup(groupUuid, update);
+    updateGroup(groupUuid, groupDelta);
   }
 
   private void addSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
       throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(memberIds -> Sets.union(memberIds, uuids))
             .build();
-    updateGroup(groupUuid, update);
+    updateGroup(groupUuid, groupDelta);
   }
 
   private void removeSubgroups(AccountGroup.UUID groupUuid, Set<AccountGroup.UUID> uuids)
       throws Exception {
-    InternalGroupUpdate update =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(memberIds -> Sets.difference(memberIds, uuids))
             .build();
-    updateGroup(groupUuid, update);
+    updateGroup(groupUuid, groupDelta);
   }
 
   private static AccountGroupMemberAudit createExpMemberAudit(
diff --git a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
index 4b5d6b2..1bfa95c 100644
--- a/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
+++ b/javatests/com/google/gerrit/server/group/db/GroupConfigTest.java
@@ -98,8 +98,8 @@
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setNameKey(groupName).build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(anotherName).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setName(anotherName).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().nameKey().isEqualTo(anotherName);
@@ -161,9 +161,8 @@
     String description = "This is a test group.";
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription(description).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription(description).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().description().isEqualTo(description);
@@ -172,8 +171,8 @@
   @Test
   public void emptyDescriptionForNewGroupIsIgnored() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setDescription("").build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("").build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().description().isNull();
@@ -198,9 +197,8 @@
     AccountGroup.UUID ownerGroupUuid = AccountGroup.uuid("anotherOwnerUuid");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(ownerGroupUuid).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(ownerGroupUuid).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().ownerGroupUuid().isEqualTo(ownerGroupUuid);
@@ -209,10 +207,9 @@
   @Test
   public void ownerGroupUuidOfNewGroupMustNotBeEmpty() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       IOException thrown =
@@ -239,8 +236,8 @@
   @Test
   public void specifiedVisibleToAllIsRespectedForNewGroup() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setVisibleToAll(true).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(true).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().visibleToAll().isTrue();
@@ -268,8 +265,8 @@
     Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setUpdatedOn(createdOn).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().createdOn().isEqualTo(createdOn);
@@ -281,11 +278,11 @@
     Account.Id member2 = Account.id(2);
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(member1, member2))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().members().containsExactly(member1, member2);
@@ -297,11 +294,11 @@
     AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroup2");
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroup1, subgroup2))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupCreation.getGroupUUID());
     assertThatGroup(group).value().subgroups().containsExactly(subgroup1, subgroup2);
@@ -508,8 +505,8 @@
     createArbitraryGroup(groupUuid);
     AccountGroup.NameKey newName = AccountGroup.nameKey("New name");
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(newName).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setName(newName).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().nameKey().isEqualTo(newName);
@@ -520,9 +517,8 @@
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("")).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    GroupDelta groupDelta = GroupDelta.builder().setName(AccountGroup.nameKey("")).build();
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       IOException thrown =
@@ -539,8 +535,8 @@
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
     groupConfig.setAllowSaveEmptyName();
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(emptyName).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    GroupDelta groupDelta = GroupDelta.builder().setName(emptyName).build();
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
@@ -552,9 +548,8 @@
     createArbitraryGroup(groupUuid);
     String newDescription = "New description";
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription(newDescription).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription(newDescription).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().description().isEqualTo(newDescription);
@@ -564,8 +559,8 @@
   public void descriptionCanBeRemoved() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setDescription("").build();
-    Optional<InternalGroup> group = updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("").build();
+    Optional<InternalGroup> group = updateGroup(groupUuid, groupDelta);
 
     assertThatGroup(group).value().description().isNull();
   }
@@ -575,9 +570,8 @@
     createArbitraryGroup(groupUuid);
     AccountGroup.UUID newOwnerGroupUuid = AccountGroup.uuid("New owner");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(newOwnerGroupUuid).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().ownerGroupUuid().isEqualTo(newOwnerGroupUuid);
@@ -588,9 +582,8 @@
     createArbitraryGroup(groupUuid);
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    GroupDelta groupDelta = GroupDelta.builder().setOwnerGroupUUID(AccountGroup.uuid("")).build();
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
       IOException thrown =
@@ -605,9 +598,8 @@
     createArbitraryGroup(groupUuid);
     boolean oldVisibleAll = loadGroup(groupUuid).map(InternalGroup::isVisibleToAll).orElse(false);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setVisibleToAll(!oldVisibleAll).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(!oldVisibleAll).build();
+    updateGroup(groupUuid, groupDelta);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().visibleToAll().isEqualTo(!oldVisibleAll);
@@ -619,16 +611,15 @@
     Timestamp updatedOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 12).atTime(10, 21, 49));
 
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate initialGroupUpdate =
-        InternalGroupUpdate.builder().setUpdatedOn(createdOn).build();
-    createGroup(groupCreation, initialGroupUpdate);
+    GroupDelta initialGroupDelta = GroupDelta.builder().setUpdatedOn(createdOn).build();
+    createGroup(groupCreation, initialGroupDelta);
 
-    InternalGroupUpdate laterGroupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta laterGroupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
-    Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupUpdate);
+    Optional<InternalGroup> group = updateGroup(groupCreation.getGroupUUID(), laterGroupDelta);
 
     assertThatGroup(group).value().createdOn().isEqualTo(createdOn);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupUuid);
@@ -641,17 +632,15 @@
     Account.Id member1 = Account.id(1);
     Account.Id member2 = Account.id(2);
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
-            .setMemberModification(members -> ImmutableSet.of(member1))
-            .build();
-    updateGroup(groupUuid, groupUpdate1);
+    GroupDelta groupDelta1 =
+        GroupDelta.builder().setMemberModification(members -> ImmutableSet.of(member1)).build();
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setMemberModification(members -> Sets.union(members, ImmutableSet.of(member2)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().members().containsExactly(member1, member2);
@@ -663,17 +652,17 @@
     Account.Id member1 = Account.id(1);
     Account.Id member2 = Account.id(2);
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(member1, member2))
             .build();
-    updateGroup(groupUuid, groupUpdate1);
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setMemberModification(members -> Sets.difference(members, ImmutableSet.of(member1)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().members().containsExactly(member2);
@@ -685,17 +674,17 @@
     AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
     AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> ImmutableSet.of(subgroup1))
             .build();
-    updateGroup(groupUuid, groupUpdate1);
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> Sets.union(subgroups, ImmutableSet.of(subgroup2)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().subgroups().containsExactly(subgroup1, subgroup2);
@@ -707,18 +696,18 @@
     AccountGroup.UUID subgroup1 = AccountGroup.uuid("subgroups1");
     AccountGroup.UUID subgroup2 = AccountGroup.uuid("subgroups2");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setSubgroupModification(members -> ImmutableSet.of(subgroup1, subgroup2))
             .build();
-    updateGroup(groupUuid, groupUpdate1);
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setSubgroupModification(
                 members -> Sets.difference(members, ImmutableSet.of(subgroup1)))
             .build();
-    updateGroup(groupUuid, groupUpdate2);
+    updateGroup(groupUuid, groupDelta2);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
     assertThatGroup(group).value().subgroups().containsExactly(subgroup2);
@@ -745,8 +734,8 @@
   @Test
   public void loadedNewGroupWithAllPropertiesDoesNotChangeOnReload() throws Exception {
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setDescription("A test group")
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
@@ -756,7 +745,7 @@
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
-    Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupUpdate);
+    Optional<InternalGroup> createdGroup = createGroup(groupCreation, groupDelta);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
 
     assertThat(createdGroup).isEqualTo(reloadedGroup);
@@ -766,8 +755,8 @@
   public void loadedGroupAfterUpdatesForAllPropertiesDoesNotChangeOnReload() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setDescription("A test group")
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
@@ -777,7 +766,7 @@
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
 
-    Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupUpdate);
+    Optional<InternalGroup> updatedGroup = updateGroup(groupUuid, groupDelta);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupUuid);
 
     assertThat(updatedGroup).isEqualTo(reloadedGroup);
@@ -788,8 +777,8 @@
       throws Exception {
     // Create a group with all properties set.
     InternalGroupCreation groupCreation = getPrefilledGroupCreationBuilder().build();
-    InternalGroupUpdate initialGroupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta initialGroupDelta =
+        GroupDelta.builder()
             .setDescription("A test group")
             .setOwnerGroupUUID(AccountGroup.uuid("another owner"))
             .setVisibleToAll(true)
@@ -798,13 +787,13 @@
             .setMemberModification(members -> ImmutableSet.of(Account.id(1), Account.id(2)))
             .setSubgroupModification(subgroups -> ImmutableSet.of(AccountGroup.uuid("subgroup")))
             .build();
-    createGroup(groupCreation, initialGroupUpdate);
+    createGroup(groupCreation, initialGroupDelta);
 
     // Only update one of the properties.
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
 
-    Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupUpdate);
+    Optional<InternalGroup> updatedGroup = updateGroup(groupCreation.getGroupUUID(), groupDelta);
     Optional<InternalGroup> reloadedGroup = loadGroup(groupCreation.getGroupUUID());
 
     assertThat(updatedGroup).isEqualTo(reloadedGroup);
@@ -818,14 +807,13 @@
     commit(groupConfig);
 
     AccountGroup.NameKey name = AccountGroup.nameKey("Robots");
-    InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(name).build();
-    groupConfig.setGroupUpdate(groupUpdate1, auditLogFormatter);
+    GroupDelta groupDelta1 = GroupDelta.builder().setName(name).build();
+    groupConfig.setGroupDelta(groupDelta1, auditLogFormatter);
     commit(groupConfig);
 
     String description = "Test group for robots";
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setDescription(description).build();
-    groupConfig.setGroupUpdate(groupUpdate2, auditLogFormatter);
+    GroupDelta groupDelta2 = GroupDelta.builder().setDescription(description).build();
+    groupConfig.setGroupDelta(groupDelta2, auditLogFormatter);
     commit(groupConfig);
 
     Optional<InternalGroup> group = loadGroup(groupUuid);
@@ -853,9 +841,9 @@
 
     RevCommit commitAfterCreation = getLatestCommitForGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
     assertThat(commitAfterUpdate).isNotEqualTo(commitAfterCreation);
@@ -866,10 +854,10 @@
   public void newCommitIsNotCreatedForEmptyUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().build();
+    GroupDelta groupDelta = GroupDelta.builder().build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -880,10 +868,10 @@
     createArbitraryGroup(groupUuid);
 
     Timestamp updatedOn = toTimestamp(LocalDate.of(3017, Month.DECEMBER, 12).atTime(10, 21, 49));
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setUpdatedOn(updatedOn).build();
+    GroupDelta groupDelta = GroupDelta.builder().setUpdatedOn(updatedOn).build();
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -893,11 +881,11 @@
   public void newCommitIsNotCreatedForRedundantNameUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setName(groupName).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setName(groupName).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -907,12 +895,11 @@
   public void newCommitIsNotCreatedForRedundantDescriptionUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("A test group").build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("A test group").build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -922,11 +909,11 @@
   public void newCommitIsNotCreatedForRedundantVisibleToAllUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate = InternalGroupUpdate.builder().setVisibleToAll(true).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setVisibleToAll(true).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -936,12 +923,12 @@
   public void newCommitIsNotCreatedForRedundantOwnerGroupUuidUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setOwnerGroupUUID(AccountGroup.uuid("Another owner")).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -951,14 +938,14 @@
   public void newCommitIsNotCreatedForRedundantMemberUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> Sets.union(members, ImmutableSet.of(Account.id(10))))
             .build();
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -968,15 +955,15 @@
   public void newCommitIsNotCreatedForRedundantSubgroupsUpdate() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> Sets.union(subgroups, ImmutableSet.of(AccountGroup.uuid("subgroup"))))
             .build();
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit commitBeforeUpdate = getLatestCommitForGroup(groupUuid);
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
     RevCommit commitAfterUpdate = getLatestCommitForGroup(groupUuid);
 
     assertThat(commitAfterUpdate).isEqualTo(commitBeforeUpdate);
@@ -987,11 +974,11 @@
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit commitBeforeSecondCommit = getLatestCommitForGroup(groupUuid);
@@ -1005,11 +992,10 @@
   public void newCommitIsNotCreatedWhenCommittingGroupUpdateTwice() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription("A test group").build();
+    GroupDelta groupDelta = GroupDelta.builder().setDescription("A test group").build();
 
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit commitBeforeSecondCommit = getLatestCommitForGroup(groupUuid);
@@ -1047,11 +1033,11 @@
             .setNameKey(groupName)
             .setId(groupId)
             .build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setUpdatedOn(new Timestamp(createdOnAsSecondsSinceEpoch * 1000))
             .build();
-    createGroup(groupCreation, groupUpdate);
+    createGroup(groupCreation, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getCommitTime()).isEqualTo(createdOnAsSecondsSinceEpoch);
@@ -1069,13 +1055,13 @@
             .setNameKey(groupName)
             .setId(groupId)
             .build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
@@ -1102,13 +1088,13 @@
             .setNameKey(groupName)
             .setId(groupId)
             .build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(createdOn)
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
@@ -1129,9 +1115,9 @@
     long testStartAsSecondsSinceEpoch = TimeUtil.nowTs().getTime() / 1000;
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
-    updateGroup(groupUuid, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getCommitTime()).isAtLeast((int) testStartAsSecondsSinceEpoch);
@@ -1143,12 +1129,12 @@
     long updatedOnAsSecondsSinceEpoch = 9082093;
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(new Timestamp(updatedOnAsSecondsSinceEpoch * 1000))
             .build();
-    updateGroup(groupUuid, groupUpdate);
+    updateGroup(groupUuid, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getCommitTime()).isEqualTo(updatedOnAsSecondsSinceEpoch);
@@ -1161,13 +1147,13 @@
     Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent committerIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", committerTimestamp, timeZone);
@@ -1189,13 +1175,13 @@
     Timestamp updatedOn = toTimestamp(LocalDate.of(2016, Month.MARCH, 11).atTime(23, 49, 11));
 
     createArbitraryGroup(groupUuid);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Another name"))
             .setUpdatedOn(updatedOn)
             .build();
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, groupUuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
 
     PersonIdent authorIdent =
         new PersonIdent("Jane", "Jane@gerritcodereview.com", authorTimestamp, timeZone);
@@ -1225,14 +1211,13 @@
     createArbitraryGroup(groupUuid);
 
     AccountGroup.NameKey firstName = AccountGroup.nameKey("Bots");
-    InternalGroupUpdate groupUpdate1 = InternalGroupUpdate.builder().setName(firstName).build();
-    updateGroup(groupUuid, groupUpdate1);
+    GroupDelta groupDelta1 = GroupDelta.builder().setName(firstName).build();
+    updateGroup(groupUuid, groupDelta1);
 
     RevCommit commitAfterUpdate1 = getLatestCommitForGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Robots")).build();
-    updateGroup(groupUuid, groupUpdate2);
+    GroupDelta groupDelta2 = GroupDelta.builder().setName(AccountGroup.nameKey("Robots")).build();
+    updateGroup(groupUuid, groupDelta2);
 
     GroupConfig groupConfig =
         GroupConfig.loadForGroupSnapshot(
@@ -1257,9 +1242,9 @@
       throws Exception {
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Another name")).build();
-    createGroup(groupCreation, groupUpdate);
+    GroupDelta groupDelta =
+        GroupDelta.builder().setName(AccountGroup.nameKey("Another name")).build();
+    createGroup(groupCreation, groupDelta);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage()).isEqualTo("Create group");
@@ -1276,13 +1261,13 @@
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
 
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1301,13 +1286,13 @@
 
     InternalGroupCreation groupCreation =
         getPrefilledGroupCreationBuilder().setGroupUUID(groupUuid).build();
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
@@ -1326,11 +1311,11 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(accounts, ImmutableSet.of(), "GerritServer1");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
-    updateGroup(groupUuid, groupUpdate, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1348,17 +1333,17 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(accounts, ImmutableSet.of(), "server-id");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account13.id(), account7.id()))
             .build();
-    updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta1, auditLogFormatter);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .build();
-    updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta2, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage()).isEqualTo("Update group\n\nRemove: John <13@server-id>");
@@ -1375,12 +1360,12 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(ImmutableSet.of(), groups, "serverId");
 
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1398,18 +1383,18 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(ImmutableSet.of(), groups, "serverId");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setSubgroupModification(
                 subgroups -> ImmutableSet.of(group1.getGroupUUID(), group2.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta1, auditLogFormatter);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta2, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1420,13 +1405,11 @@
   public void commitMessageOfGroupRenameContainsFooters() throws Exception {
     createArbitraryGroup(groupUuid);
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("Old name")).build();
-    updateGroup(groupUuid, groupUpdate1);
+    GroupDelta groupDelta1 = GroupDelta.builder().setName(AccountGroup.nameKey("Old name")).build();
+    updateGroup(groupUuid, groupDelta1);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder().setName(AccountGroup.nameKey("New name")).build();
-    updateGroup(groupUuid, groupUpdate2);
+    GroupDelta groupDelta2 = GroupDelta.builder().setName(AccountGroup.nameKey("New name")).build();
+    updateGroup(groupUuid, groupDelta2);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1447,21 +1430,21 @@
     AuditLogFormatter auditLogFormatter =
         AuditLogFormatter.createBackedBy(accounts, groups, "serverId");
 
-    InternalGroupUpdate groupUpdate1 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta1 =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("Old name"))
             .setMemberModification(members -> ImmutableSet.of(account7.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group2.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate1, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta1, auditLogFormatter);
 
-    InternalGroupUpdate groupUpdate2 =
-        InternalGroupUpdate.builder()
+    GroupDelta groupDelta2 =
+        GroupDelta.builder()
             .setName(AccountGroup.nameKey("New name"))
             .setMemberModification(members -> ImmutableSet.of(account13.id()))
             .setSubgroupModification(subgroups -> ImmutableSet.of(group1.getGroupUUID()))
             .build();
-    updateGroup(groupUuid, groupUpdate2, auditLogFormatter);
+    updateGroup(groupUuid, groupDelta2, auditLogFormatter);
 
     RevCommit revCommit = getLatestCommitForGroup(groupUuid);
     assertThat(revCommit.getFullMessage())
@@ -1527,23 +1510,23 @@
   }
 
   private Optional<InternalGroup> createGroup(
-      InternalGroupCreation groupCreation, InternalGroupUpdate groupUpdate) throws Exception {
+      InternalGroupCreation groupCreation, GroupDelta groupDelta) throws Exception {
     GroupConfig groupConfig = GroupConfig.createForNewGroup(projectName, repository, groupCreation);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
 
-  private Optional<InternalGroup> updateGroup(
-      AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate) throws Exception {
-    return updateGroup(uuid, groupUpdate, auditLogFormatter);
+  private Optional<InternalGroup> updateGroup(AccountGroup.UUID uuid, GroupDelta groupDelta)
+      throws Exception {
+    return updateGroup(uuid, groupDelta, auditLogFormatter);
   }
 
   private Optional<InternalGroup> updateGroup(
-      AccountGroup.UUID uuid, InternalGroupUpdate groupUpdate, AuditLogFormatter auditLogFormatter)
+      AccountGroup.UUID uuid, GroupDelta groupDelta, AuditLogFormatter auditLogFormatter)
       throws Exception {
     GroupConfig groupConfig = GroupConfig.loadForGroup(projectName, repository, uuid);
-    groupConfig.setGroupUpdate(groupUpdate, auditLogFormatter);
+    groupConfig.setGroupDelta(groupDelta, auditLogFormatter);
     commit(groupConfig);
     return groupConfig.getLoadedGroup();
   }
diff --git a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
index 92a5fbe..d16efc3 100644
--- a/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -56,25 +56,33 @@
             .build();
     ExternalId extId1 =
         ExternalId.create(
-            ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
+            ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com", false),
             id,
             "foo.bar@example.com",
             null,
             ObjectId.fromString("1b9a0cf038ea38a0ab08617c39aa8e28413a27ca"));
     ExternalId extId2 =
         ExternalId.create(
-            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo"),
+            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo", false),
             id,
             null,
             "secret",
             ObjectId.fromString("5b3a73dc9a668a5b89b5f049225261e3e3291d1a"));
+    ExternalId extId3 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "Bar", true),
+            id,
+            null,
+            "secret",
+            ObjectId.fromString("483ea804e84282e15ddcdd1d15a797eb4796a760"));
     List<String> values =
         toStrings(
             AccountField.EXTERNAL_ID_STATE.get(
-                AccountState.forAccount(account, ImmutableSet.of(extId1, extId2))));
+                AccountState.forAccount(account, ImmutableSet.of(extId1, extId2, extId3))));
     String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
     String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
-    assertThat(values).containsExactly(expectedValue1, expectedValue2);
+    String expectedValue3 = extId3.key().sha1().name() + ":" + extId3.blobId().name();
+    assertThat(values).containsExactly(expectedValue1, expectedValue2, expectedValue3);
   }
 
   private List<String> toStrings(Iterable<byte[]> values) {
diff --git a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
index a7b25b8..6ad2060 100644
--- a/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
+++ b/javatests/com/google/gerrit/server/index/change/ChangeFieldTest.java
@@ -25,15 +25,19 @@
 import com.google.gerrit.entities.Account;
 import com.google.gerrit.entities.Change;
 import com.google.gerrit.entities.LegacySubmitRequirement;
+import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.index.testing.FakeStoredValue;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.TestTimeUtil;
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.ObjectId;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
@@ -129,6 +133,20 @@
     assertStoredRecordRoundTrip(r);
   }
 
+  @Test
+  public void tolerateNullValuesForInsertion() {
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    assertThat(ChangeField.ADDED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+  }
+
+  @Test
+  public void tolerateNullValuesForDeletion() {
+    Project.NameKey project = Project.nameKey("project");
+    ChangeData cd = ChangeData.createForTest(project, Change.id(1), 1, ObjectId.zeroId());
+    assertThat(ChangeField.DELETED.setIfPossible(cd, new FakeStoredValue(null))).isTrue();
+  }
+
   private static SubmitRecord record(SubmitRecord.Status status, SubmitRecord.Label... labels) {
     SubmitRecord r = new SubmitRecord();
     r.status = status;
diff --git a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 521af2f..66a98e8 100644
--- a/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/javatests/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -55,6 +55,7 @@
             null,
             new Config(),
             null,
+            null,
             null));
   }
 
diff --git a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
index ed4325d..fefa066 100644
--- a/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
+++ b/javatests/com/google/gerrit/server/logging/PerformanceLogContextTest.java
@@ -34,6 +34,7 @@
 import com.google.inject.Injector;
 import java.util.ArrayList;
 import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
 import org.eclipse.jgit.lib.Config;
 import org.junit.After;
 import org.junit.Before;
@@ -110,9 +111,10 @@
 
   @Test
   public void
-      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
+      traceTimersInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers()
+          throws Exception {
     // Remove test performance logger so that there are no registered performance loggers.
-    performanceLoggerRegistrationHandle.remove();
+    removeAllPerformanceLoggers();
 
     assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
     assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
@@ -277,9 +279,10 @@
 
   @Test
   public void
-      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers() {
-    // Remove test performance logger so that there are no registered performance loggers.
-    performanceLoggerRegistrationHandle.remove();
+      timerMetricssInsidePerformanceLogContextDoNotCreatePerformanceLogIfNoPerformanceLoggers()
+          throws Exception {
+    // Remove all performance loggers so that there are no registered performance loggers.
+    removeAllPerformanceLoggers();
 
     assertThat(LoggingContext.getInstance().isPerformanceLogging()).isFalse();
     assertThat(LoggingContext.getInstance().getPerformanceLogRecords()).isEmpty();
@@ -369,6 +372,12 @@
     }
   }
 
+  private void removeAllPerformanceLoggers() throws Exception {
+    java.lang.reflect.Field itemsField = DynamicSet.class.getDeclaredField("items");
+    itemsField.setAccessible(true);
+    ((CopyOnWriteArrayList<?>) itemsField.get(performanceLoggers)).clear();
+  }
+
   @AutoValue
   abstract static class PerformanceLogEntry {
     static PerformanceLogEntry create(String operation, Metadata metadata) {
diff --git a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index f10a281..5980071 100644
--- a/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/javatests/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -18,7 +18,7 @@
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.verifyNoInteractions;
 import static org.mockito.Mockito.when;
 
 import com.google.gerrit.entities.Account;
@@ -125,7 +125,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -229,7 +229,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -239,7 +239,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
@@ -304,7 +304,7 @@
     assertThat(r).isNotNull();
     assertThat(r.name()).isEqualTo(ident.getName());
     assertThat(r.email()).isEqualTo(ident.getEmailAddress());
-    verifyZeroInteractions(accountCache);
+    verifyNoInteractions(accountCache);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index a5cb456..ba514fd 100644
--- a/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -44,7 +44,7 @@
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.AnonymousCowardNameProvider;
 import com.google.gerrit.server.config.CanonicalWebUrl;
-import com.google.gerrit.server.config.DefaultUrlFormatter;
+import com.google.gerrit.server.config.DefaultUrlFormatter.DefaultUrlFormatterModule;
 import com.google.gerrit.server.config.EnablePeerIPInReflogRecord;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.GerritServerId;
@@ -52,7 +52,9 @@
 import com.google.gerrit.server.git.GitModule;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.NullProjectCache;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.testing.AssertableExecutorService;
 import com.google.gerrit.testing.ConfigSuite;
@@ -63,7 +65,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.TimeZone;
 import java.util.concurrent.ExecutorService;
@@ -106,7 +107,7 @@
 
   @Inject protected AbstractChangeNotes.Args args;
 
-  @Inject @GerritServerId private String serverId;
+  @Inject @GerritServerId protected String serverId;
 
   protected Injector injector;
   private String systemTimeZone;
@@ -139,12 +140,12 @@
               public void configure() {
                 install(new GitModule());
 
-                install(new DefaultUrlFormatter.Module());
+                install(new DefaultUrlFormatterModule());
                 install(NoteDbModule.forTest());
                 bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
                 bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
                 bind(GitRepositoryManager.class).toInstance(repoManager);
-                bind(ProjectCache.class).toProvider(Providers.of(null));
+                bind(ProjectCache.class).to(NullProjectCache.class);
                 bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
                 bind(String.class)
                     .annotatedWith(AnonymousCowardName.class)
@@ -167,6 +168,11 @@
                     .annotatedWith(FanOutExecutor.class)
                     .toInstance(assertableFanOutExecutor);
                 bind(ServiceUserClassifier.class).to(ServiceUserClassifier.NoOp.class);
+                bind(InternalChangeQuery.class)
+                    .toProvider(
+                        () -> {
+                          throw new UnsupportedOperationException();
+                        });
               }
             });
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 2573a5e..256544c 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -153,6 +153,43 @@
   }
 
   @Test
+  public void parseCopiedApproval() throws Exception {
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Copied-Label: Label1=+1 Account <1@gerrit>,Other Account <2@Gerrit>\n"
+            + "Copied-Label: Label2=+1 Account <1@gerrit>\n"
+            + "Copied-Label: Label3=+1 Account <1@gerrit>,Other Account <2@Gerrit> :\"tag\"\n"
+            + "Copied-Label: Label4=+1 Account <1@Gerrit> :\"tag with characters %^#@^( *::!\"\n"
+            + "Subject: This is a test change\n");
+    assertParseSucceeds(
+        "Update change\n"
+            + "\n"
+            + "Branch: refs/heads/master\n"
+            + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
+            + "Patch-set: 1\n"
+            + "Label: -Label1\n"
+            + "Label: -Label4 Account <1@gerrit>\n"
+            + "Subject: This is a test change\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1=X\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 = 1\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: X+Y\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1 Other Account <2@gerrit>\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: -Label!1\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: -Label!1 Other Account <2@gerrit>\n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: -Label1\n");
+    assertParseFails(
+        "Update change\n\nPatch-set: 1\nCopied-Label: Label1 Other Account <2@gerrit>,Other "
+            + "Account <2@gerrit>,Other Account <2@gerrit> \n");
+    assertParseFails("Update change\n\nPatch-set: 1\nCopied-Label: Label1 non-user\n");
+  }
+
+  @Test
   public void parseSubmitRecords() throws Exception {
     assertParseSucceeds(
         "Update change\n"
@@ -514,7 +551,9 @@
             "Update patch set 1\n"
                 + "\n"
                 + "Patch-set: 1\n"
-                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000"
+                + " \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added"
+                + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
     changeNotesParser.parseAll();
@@ -533,7 +572,9 @@
                 + "\n"
                 + "Patch-set: 1\n"
                 + "Subject: Change subject\n"
-                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000"
+                + " \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added"
+                + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
     changeNotesParser.parseAll();
@@ -563,7 +604,9 @@
             "Update patch set 1\n"
                 + "\n"
                 + "Patch-set: 1\n"
-                + "Attention: {\"person_ident\":\"Gerrit User 1000000 \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Administrator using the hovercard menu\"}",
+                + "Attention: {\"person_ident\":\"Gerrit User 1000000"
+                + " \\u003c1000000@adce0b11-8f2e-4ab6-ac69-e675f183d871\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added"
+                + " by Administrator using the hovercard menu\"}",
             false);
     ChangeNotesParser changeNotesParser = newParser(commit);
     changeNotesParser.parseAll();
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
index 5f375ad..61002f9 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesStateTest.java
@@ -36,6 +36,10 @@
 import com.google.gerrit.entities.PatchSet;
 import com.google.gerrit.entities.PatchSetApproval;
 import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
 import com.google.gerrit.entities.converter.ChangeMessageProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetApprovalProtoConverter;
 import com.google.gerrit.entities.converter.PatchSetProtoConverter;
@@ -52,6 +56,9 @@
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerByEmailSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerSetEntryProto;
 import com.google.gerrit.server.cache.proto.Cache.ChangeNotesStateProto.ReviewerStatusUpdateProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementExpressionResultProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementProto;
+import com.google.gerrit.server.cache.proto.Cache.SubmitRequirementResultProto;
 import com.google.gerrit.server.cache.serialize.ObjectIdConverter;
 import com.google.gerrit.server.notedb.ChangeNotesState.ChangeColumns;
 import com.google.gerrit.server.notedb.ChangeNotesState.Serializer;
@@ -368,6 +375,7 @@
                 PatchSetApproval.key(
                     PatchSet.id(ID, 1), Account.id(2001), LabelId.create(LabelId.CODE_REVIEW)))
             .value(1)
+            .tag("tag")
             .granted(new Timestamp(1212L))
             .build();
     Entities.PatchSetApproval psa1 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a1);
@@ -379,11 +387,13 @@
                 PatchSetApproval.key(
                     PatchSet.id(ID, 1), Account.id(2002), LabelId.create(LabelId.VERIFIED)))
             .value(-1)
+            .tag("tag")
+            .copied(true)
             .granted(new Timestamp(3434L))
             .build();
     Entities.PatchSetApproval psa2 = PatchSetApprovalProtoConverter.INSTANCE.toProto(a2);
     ByteString a2Bytes = Protos.toByteString(psa2);
-    assertThat(a2Bytes.size()).isEqualTo(49);
+    assertThat(a2Bytes.size()).isEqualTo(56);
     assertThat(a2Bytes).isNotEqualTo(a1Bytes);
 
     assertRoundTrip(
@@ -671,6 +681,71 @@
   }
 
   @Test
+  public void serializeSubmitRequirementsResult() throws Exception {
+    assertRoundTrip(
+        newBuilder()
+            .submitRequirementsResult(
+                ImmutableList.of(
+                    SubmitRequirementResult.builder()
+                        .legacy(Optional.of(true))
+                        .patchSetCommitId(
+                            ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd"))
+                        .submitRequirement(
+                            SubmitRequirement.builder()
+                                .setName("Code-Review")
+                                .setApplicabilityExpression(
+                                    SubmitRequirementExpression.of("project:foo"))
+                                .setSubmittabilityExpression(
+                                    SubmitRequirementExpression.create("label:code-review=+2"))
+                                .setAllowOverrideInChildProjects(false)
+                                .build())
+                        .applicabilityExpressionResult(
+                            Optional.of(
+                                SubmitRequirementExpressionResult.create(
+                                    SubmitRequirementExpression.create("project:foo"),
+                                    SubmitRequirementExpressionResult.Status.PASS,
+                                    ImmutableList.of("project:foo"),
+                                    ImmutableList.of())))
+                        .submittabilityExpressionResult(
+                            SubmitRequirementExpressionResult.create(
+                                SubmitRequirementExpression.create("label:code-review=+2"),
+                                SubmitRequirementExpressionResult.Status.FAIL,
+                                ImmutableList.of(),
+                                ImmutableList.of("label:code-review=+2")))
+                        .build()))
+            .build(),
+        newProtoBuilder()
+            .addSubmitRequirementResult(
+                SubmitRequirementResultProto.newBuilder()
+                    .setLegacy(true)
+                    .setCommit(
+                        ObjectIdConverter.create()
+                            .toByteString(
+                                ObjectId.fromString("26e50c7d315a33a13e5cc00902781fa876bc36cd")))
+                    .setSubmitRequirement(
+                        SubmitRequirementProto.newBuilder()
+                            .setName("Code-Review")
+                            .setApplicabilityExpression("project:foo")
+                            .setSubmittabilityExpression("label:code-review=+2")
+                            .setAllowOverrideInChildProjects(false)
+                            .build())
+                    .setApplicabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("project:foo")
+                            .setStatus("PASS")
+                            .addPassingAtoms("project:foo")
+                            .build())
+                    .setSubmittabilityExpressionResult(
+                        SubmitRequirementExpressionResultProto.newBuilder()
+                            .setExpression("label:code-review=+2")
+                            .setStatus("FAIL")
+                            .addFailingAtoms("label:code-review=+2")
+                            .build())
+                    .build())
+            .build());
+  }
+
+  @Test
   public void serializeAssigneeUpdates() throws Exception {
     assertRoundTrip(
         newBuilder()
@@ -721,7 +796,7 @@
   @Test
   public void serializeChangeMessages() throws Exception {
     ChangeMessage m1 =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(ID, "uuid1"),
             Account.id(1000),
             new Timestamp(1212L),
@@ -731,7 +806,7 @@
     assertThat(m1Bytes.size()).isEqualTo(35);
 
     ChangeMessage m2 =
-        new ChangeMessage(
+        ChangeMessage.create(
             ChangeMessage.key(ID, "uuid2"),
             Account.id(2000),
             new Timestamp(3434L),
@@ -842,6 +917,9 @@
                 .put(
                     "publishedComments",
                     new TypeLiteral<ImmutableListMultimap<ObjectId, HumanComment>>() {}.getType())
+                .put(
+                    "submitRequirementsResult",
+                    new TypeLiteral<ImmutableList<SubmitRequirementResult>>() {}.getType())
                 .put("updateCount", int.class)
                 .put("mergedOn", Timestamp.class)
                 .build());
@@ -905,6 +983,7 @@
                 .put("tag", new TypeLiteral<Optional<String>>() {}.getType())
                 .put("realAccountId", Account.Id.class)
                 .put("postSubmit", boolean.class)
+                .put("copied", boolean.class)
                 .put("toBuilder", PatchSetApproval.Builder.class)
                 .build());
   }
@@ -962,6 +1041,8 @@
     assertThatSerializedClass(SubmitRecord.class)
         .hasFields(
             ImmutableMap.of(
+                "ruleName",
+                new TypeLiteral<String>() {}.getType(),
                 "status",
                 SubmitRecord.Status.class,
                 "labels",
@@ -992,7 +1073,7 @@
             .setChangeId(ID.get())
             .setMergedOnMillis(234567L)
             .setHasMergedOn(true)
-            .setColumns(colsProto.toBuilder())
+            .setColumns(colsProto)
             .build());
   }
 
@@ -1055,7 +1136,7 @@
             .setChangeId(ID.get())
             .setServerId(DEFAULT_SERVER_ID)
             .setHasServerId(true)
-            .setColumns(colsProto.toBuilder())
+            .setColumns(colsProto)
             .build());
   }
 
diff --git a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
index de49cdf..c524c94 100644
--- a/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/javatests/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -55,8 +55,8 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
+import com.google.gerrit.server.util.AccountTemplateUtil;
 import com.google.gerrit.server.util.time.TimeUtil;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gerrit.testing.TestChanges;
@@ -80,8 +80,6 @@
 
   @Inject private ChangeNoteJson changeNoteJson;
 
-  @Inject private @GerritServerId String serverId;
-
   @Test
   public void tagChangeMessage() throws Exception {
     String tag = "jenkins";
@@ -516,6 +514,184 @@
   }
 
   @Test
+  public void copiedApprovals() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    c.currentPatchSetId(),
+                    changeOwner.getAccountId(),
+                    LabelId.create(LabelId.CODE_REVIEW)))
+            .value(1)
+            .copied(true)
+            .granted(TimeUtil.nowTs())
+            .tag("tag")
+            .realAccountId(otherUserId)
+            .build());
+    update.putApprovalFor(otherUserId, LabelId.CODE_REVIEW, (short) -1);
+    update.commit();
+
+    // Only the non copied approval is reachable by getApprovals.
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval approval =
+        Iterables.getOnlyElement(notes.getApprovals().get(c.currentPatchSetId()));
+    assertThat(approval.accountId()).isEqualTo(otherUser.getAccountId());
+    assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(approval.value()).isEqualTo((short) -1);
+    assertThat(approval.copied()).isFalse();
+
+    // Get approvals with copied gets all of the approvals (including copied).
+    ImmutableList<PatchSetApproval> approvals =
+        notes.getApprovalsWithCopied().get(c.currentPatchSetId()).stream()
+            .sorted(comparing(a -> a.accountId().get()))
+            .collect(toImmutableList());
+    assertThat(approvals).hasSize(2);
+
+    PatchSetApproval copied = approvals.get(0);
+    assertThat(copied.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(copied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(copied.value()).isEqualTo((short) 1);
+    assertThat(copied.tag()).hasValue("tag");
+    assertThat(copied.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(copied.realAccountId()).isEqualTo(otherUserId);
+    assertThat(copied.copied()).isTrue();
+
+    PatchSetApproval nonCopied = approvals.get(1);
+    assertThat(nonCopied.accountId()).isEqualTo(otherUserId);
+    assertThat(nonCopied.realAccountId()).isEqualTo(otherUserId);
+    assertThat(nonCopied.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(nonCopied.value()).isEqualTo((short) -1);
+  }
+
+  @Test
+  public void copiedApprovalsCanBeRemoved() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    c.currentPatchSetId(),
+                    changeOwner.getAccountId(),
+                    LabelId.create(LabelId.CODE_REVIEW)))
+            .value(1)
+            .copied(true)
+            .granted(TimeUtil.nowTs())
+            .build());
+    update.commit();
+
+    update.removeApproval(LabelId.CODE_REVIEW);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval approval =
+        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+    assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
+    // The vote got removed since the latest patch-set only has one vote and it's "0". The copied
+    // approval will never have a "0" vote, but it can be overridden by a "0" vote of a
+    // non-copied approval.
+    assertThat(approval.value()).isEqualTo((short) 0);
+    assertThat(approval.copied()).isFalse();
+  }
+
+  @Test
+  public void copiedApprovalsWithStrangeTags() throws Exception {
+    String strangeTag = "!@#$%^\0&*):\" \n: \r\"#$@,. :";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    c.currentPatchSetId(),
+                    changeOwner.getAccountId(),
+                    LabelId.create(LabelId.CODE_REVIEW)))
+            .value(1)
+            .copied(true)
+            .granted(TimeUtil.nowTs())
+            .tag(strangeTag)
+            .build());
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    PatchSetApproval approval =
+        Iterables.getOnlyElement(notes.getApprovalsWithCopied().get(c.currentPatchSetId()));
+    assertThat(approval.accountId()).isEqualTo(changeOwner.getAccountId());
+    assertThat(approval.label()).isEqualTo(LabelId.CODE_REVIEW);
+    assertThat(approval.value()).isEqualTo((short) 1);
+    assertThat(approval.tag()).hasValue(NoteDbUtil.sanitizeFooter(strangeTag));
+    assertThat(approval.copied()).isTrue();
+  }
+
+  @Test
+  public void copiedApprovalsPostSubmit() throws Exception {
+    Change c = newChange();
+    SubmissionId submissionId = new SubmissionId(c);
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    c.currentPatchSetId(),
+                    changeOwner.getAccountId(),
+                    LabelId.create(LabelId.CODE_REVIEW)))
+            .value(1)
+            .copied(true)
+            .granted(TimeUtil.nowTs())
+            .build());
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    c.currentPatchSetId(),
+                    changeOwner.getAccountId(),
+                    LabelId.create(LabelId.VERIFIED)))
+            .value(1)
+            .copied(true)
+            .granted(TimeUtil.nowTs())
+            .build());
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.merge(
+        submissionId,
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel(LabelId.VERIFIED, "OK", changeOwner.getAccountId()),
+                submitLabel(LabelId.CODE_REVIEW, "NEED", null))));
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putCopiedApproval(
+        PatchSetApproval.builder()
+            .key(
+                PatchSetApproval.key(
+                    c.currentPatchSetId(),
+                    changeOwner.getAccountId(),
+                    LabelId.create(LabelId.CODE_REVIEW)))
+            .value(2)
+            .copied(true)
+            .granted(TimeUtil.nowTs())
+            .build());
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    List<PatchSetApproval> approvals = Lists.newArrayList(notes.getApprovalsWithCopied().values());
+    assertThat(approvals).hasSize(2);
+    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(LabelId.CODE_REVIEW);
+    assertThat(approvals.get(1).value()).isEqualTo((short) 2);
+    assertThat(approvals.get(1).postSubmit()).isTrue();
+  }
+
+  @Test
   public void multipleReviewers() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -1602,6 +1778,7 @@
     ChangeNotes notes = newNotes(c);
     ChangeMessage cm1 = Iterables.getOnlyElement(notes.getChangeMessages());
     assertThat(cm1.getMessage()).isEqualTo("Testing trailing double newline\n\n");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
@@ -1621,10 +1798,29 @@
                 + "Testing paragraph 2\n"
                 + "\n"
                 + "Testing paragraph 3");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
   }
 
   @Test
+  public void changeMessageWithTemplate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(changeOwner.getAccount().id(), REVIEWER);
+    String messageTemplate =
+        String.format(
+            "Change update by %s, also includes %s",
+            AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()),
+            AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId()));
+    update.setChangeMessage(messageTemplate);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    ChangeMessage cm = Iterables.getOnlyElement(notes.getChangeMessages());
+    assertThat(cm.getMessage()).isEqualTo(messageTemplate);
+  }
+
+  @Test
   public void changeMessagesMultiplePatchSets() throws Exception {
     Change c = newChange();
     ChangeUpdate update = newUpdate(c, changeOwner);
@@ -1646,11 +1842,13 @@
     ChangeMessage cm1 = notes.getChangeMessages().get(0);
     assertThat(cm1.getPatchSetId()).isEqualTo(ps1);
     assertThat(cm1.getMessage()).isEqualTo("This is the change message for the first PS.");
+
     assertThat(cm1.getAuthor()).isEqualTo(changeOwner.getAccount().id());
 
     ChangeMessage cm2 = notes.getChangeMessages().get(1);
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
     assertThat(cm2.getMessage()).isEqualTo("This is the change message for the second PS.");
+
     assertThat(cm2.getAuthor()).isEqualTo(changeOwner.getAccount().id());
     assertThat(cm2.getPatchSetId()).isEqualTo(ps2);
   }
diff --git a/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
new file mode 100644
index 0000000..98721fd
--- /dev/null
+++ b/javatests/com/google/gerrit/server/notedb/CommitRewriterTest.java
@@ -0,0 +1,2422 @@
+// 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.notedb;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.entities.LabelId.CODE_REVIEW;
+import static com.google.gerrit.entities.LabelId.VERIFIED;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+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.ChangeMessage;
+import com.google.gerrit.entities.LabelId;
+import com.google.gerrit.entities.PatchSetApproval;
+import com.google.gerrit.entities.RefNames;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.json.OutputFormat;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.notedb.ChangeNoteUtil.AttentionStatusInNoteDb;
+import com.google.gerrit.server.notedb.CommitRewriter.BackfillResult;
+import com.google.gerrit.server.notedb.CommitRewriter.CommitDiff;
+import com.google.gerrit.server.notedb.CommitRewriter.RunOptions;
+import com.google.gerrit.server.util.AccountTemplateUtil;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.IntStream;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Tests for {@link CommitRewriter} */
+public class CommitRewriterTest extends AbstractChangeNotesTest {
+
+  private @Inject CommitRewriter rewriter;
+  @Inject private ChangeNoteUtil changeNoteUtil;
+
+  private static final Gson gson = OutputFormat.JSON_COMPACT.newGson();
+
+  @Before
+  public void setUp() throws Exception {}
+
+  @After
+  public void cleanUp() throws Exception {
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+    bru.setAllowNonFastForwards(true);
+    for (Ref ref : repo.getRefDatabase().getRefsByPrefix(RefNames.REFS_CHANGES)) {
+      Change.Id changeId = Change.Id.fromRef(ref.getName());
+      if (changeId == null || !ref.getName().equals(RefNames.changeMetaRef(changeId))) {
+        continue;
+      }
+      bru.addCommand(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    }
+
+    RefUpdateUtil.executeChecked(bru, repo);
+  }
+
+  @Test
+  public void validHistoryNoOp() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("verification from jenkins");
+    update.setTag(tag);
+    update.commit();
+
+    ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+    updateWithSubject.setSubjectForCommit("Update with subject");
+    updateWithSubject.commit();
+
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+    Ref metaRefBefore = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    Ref metaRefAfter = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    assertThat(notesBeforeRewrite.getMetaId()).isEqualTo(notesAfterRewrite.getMetaId());
+    assertThat(metaRefBefore.getObjectId()).isEqualTo(metaRefAfter.getObjectId());
+    assertThat(backfillResult.fixedRefDiff).isEmpty();
+  }
+
+  @Test
+  public void failedVerification() throws Exception {
+    String tag = "jenkins";
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Unknown commit " + changeOwner.getName());
+    update.setTag(tag);
+    update.commit();
+
+    ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+    updateWithSubject.setSubjectForCommit("Update with subject");
+    updateWithSubject.commit();
+
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+    Ref metaRefBefore = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff).isEmpty();
+    assertThat(backfillResult.refsStillInvalidAfterFix)
+        .containsExactly(RefNames.changeMetaRef(c.getId()));
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    Ref metaRefAfter = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    assertThat(notesBeforeRewrite.getMetaId()).isEqualTo(notesAfterRewrite.getMetaId());
+    assertThat(metaRefBefore.getObjectId()).isEqualTo(metaRefAfter.getObjectId());
+  }
+
+  @Test
+  public void outputDiffOff_refsReported() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeMessage("Change has been successfully merged by " + changeOwner.getName());
+    ObjectId commitToFix = update.commit();
+
+    ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+    updateWithSubject.setSubjectForCommit("Update with subject");
+    updateWithSubject.commit();
+
+    Ref metaRefBefore = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    options.outputDiff = false;
+    options.verifyCommits = false;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff.keySet())
+        .containsExactly(RefNames.changeMetaRef(c.getId()));
+    Ref metaRefAfter = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    assertThat(metaRefBefore.getObjectId()).isNotEqualTo(metaRefAfter.getObjectId());
+
+    assertFixedCommits(ImmutableList.of(commitToFix), backfillResult, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(backfillResult, c.getId());
+    assertThat(commitHistoryDiff).containsExactly("");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void numRefs_greater_maxRefsToUpdate_allFixed() throws Exception {
+    int numberOfChanges = 12;
+    ImmutableMap.Builder<String, Ref> refsToOldMetaBuilder = new ImmutableMap.Builder<>();
+    for (int i = 0; i < numberOfChanges; i++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setChangeMessage("Change has been successfully merged by " + changeOwner.getName());
+      update.commit();
+      ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+      updateWithSubject.setSubjectForCommit("Update with subject");
+      updateWithSubject.commit();
+      String refName = RefNames.changeMetaRef(c.getId());
+      Ref metaRefBeforeRewrite = repo.exactRef(refName);
+      refsToOldMetaBuilder.put(refName, metaRefBeforeRewrite);
+    }
+    ImmutableMap<String, Ref> refsToOldMeta = refsToOldMetaBuilder.build();
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    options.outputDiff = false;
+    options.verifyCommits = false;
+    options.maxRefsInBatch = 10;
+    options.maxRefsToUpdate = 12;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff.keySet()).isEqualTo(refsToOldMeta.keySet());
+    for (Map.Entry<String, Ref> refEntry : refsToOldMeta.entrySet()) {
+      Ref metaRefAfterRewrite = repo.exactRef(refEntry.getKey());
+      assertThat(refEntry.getValue()).isNotEqualTo(metaRefAfterRewrite);
+    }
+  }
+
+  @Test
+  public void maxRefsToUpdate_coversAllInvalid_inMultipleBatches() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 12,
+        /*maxRefsInBatch=*/ 2);
+  }
+
+  @Test
+  public void maxRefsToUpdate_coversAllInvalid_inSingleBatch() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 12,
+        /*maxRefsInBatch=*/ 12);
+  }
+
+  @Test
+  public void moreInvalidRefs_thenMaxRefsToUpdate_inMultipleBatches() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 10,
+        /*maxRefsInBatch=*/ 2);
+  }
+
+  @Test
+  public void moreInvalidRefs_thenMaxRefsToUpdate_inSingleBatch() throws Exception {
+    testMaxRefsToUpdate(
+        /*numberOfInvalidChanges=*/ 11,
+        /*numberOfValidChanges=*/ 9,
+        /*maxRefsToUpdate=*/ 10,
+        /*maxRefsInBatch=*/ 10);
+  }
+
+  private void testMaxRefsToUpdate(
+      int numberOfInvalidChanges, int numberOfValidChanges, int maxRefsToUpdate, int maxRefsInBatch)
+      throws Exception {
+    ImmutableMap.Builder<String, ObjectId> expectedFixedRefsToOldMetaBuilder =
+        new ImmutableMap.Builder<>();
+    ImmutableMap.Builder<String, ObjectId> expectedSkippedRefsToOldMetaBuilder =
+        new ImmutableMap.Builder<>();
+    for (int i = 0; i < numberOfValidChanges; i++) {
+      Change c = newChange();
+      ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+      updateWithSubject.setSubjectForCommit("Update with subject");
+      updateWithSubject.commit();
+      String refName = RefNames.changeMetaRef(c.getId());
+      Ref metaRefBeforeRewrite = repo.exactRef(refName);
+      expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+    }
+    for (int i = 0; i < numberOfInvalidChanges; i++) {
+      Change c = newChange();
+      ChangeUpdate update = newUpdate(c, changeOwner);
+      update.setChangeMessage("Change has been successfully merged by " + changeOwner.getName());
+      update.commit();
+      ChangeUpdate updateWithSubject = newUpdate(c, changeOwner);
+      updateWithSubject.setSubjectForCommit("Update with subject");
+      updateWithSubject.commit();
+      String refName = RefNames.changeMetaRef(c.getId());
+      Ref metaRefBeforeRewrite = repo.exactRef(refName);
+      if (i < maxRefsToUpdate) {
+        expectedFixedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      } else {
+        expectedSkippedRefsToOldMetaBuilder.put(refName, metaRefBeforeRewrite.getObjectId());
+      }
+    }
+    ImmutableMap<String, ObjectId> expectedFixedRefsToOldMeta =
+        expectedFixedRefsToOldMetaBuilder.build();
+    ImmutableMap<String, ObjectId> expectedSkippedRefsToOldMeta =
+        expectedSkippedRefsToOldMetaBuilder.build();
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    options.outputDiff = false;
+    options.verifyCommits = false;
+    options.maxRefsInBatch = maxRefsInBatch;
+    options.maxRefsToUpdate = maxRefsToUpdate;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    assertThat(backfillResult.fixedRefDiff.keySet()).isEqualTo(expectedFixedRefsToOldMeta.keySet());
+    for (Map.Entry<String, ObjectId> refEntry : expectedFixedRefsToOldMeta.entrySet()) {
+      Ref metaRefAfterRewrite = repo.exactRef(refEntry.getKey());
+      assertThat(refEntry.getValue()).isNotEqualTo(metaRefAfterRewrite.getObjectId());
+    }
+    for (Map.Entry<String, ObjectId> refEntry : expectedSkippedRefsToOldMeta.entrySet()) {
+      Ref metaRefAfterRewrite = repo.exactRef(refEntry.getKey());
+      assertThat(refEntry.getValue()).isEqualTo(metaRefAfterRewrite.getObjectId());
+    }
+    RunOptions secondRunOptions = new RunOptions();
+    secondRunOptions.dryRun = false;
+    secondRunOptions.outputDiff = false;
+    secondRunOptions.verifyCommits = false;
+    secondRunOptions.maxRefsInBatch = maxRefsInBatch;
+    secondRunOptions.maxRefsToUpdate = numberOfInvalidChanges + numberOfValidChanges;
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    int expectedSecondRunResult =
+        numberOfInvalidChanges > maxRefsToUpdate ? numberOfInvalidChanges - maxRefsToUpdate : 0;
+    assertThat(secondRunResult.fixedRefDiff.keySet().size()).isEqualTo(expectedSecondRunResult);
+  }
+
+  @Test
+  public void fixAuthorIdent() throws Exception {
+    Change c = newChange();
+    Timestamp when = TimeUtil.nowTs();
+    PersonIdent invalidAuthorIdent =
+        new PersonIdent(
+            changeOwner.getName(),
+            changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
+            when,
+            serverIdent.getTimeZone());
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(c, /*changeMessage=*/ null),
+            invalidAuthorIdent);
+    ChangeUpdate validUpdate = newUpdate(c, changeOwner);
+    validUpdate.setChangeMessage("verification from jenkins");
+    validUpdate.setTag("jenkins");
+    validUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesAfterRewrite.getChange().getOwner())
+        .isEqualTo(notesBeforeRewrite.getChange().getOwner());
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+    assertFixedCommits(ImmutableList.of(invalidUpdateCommit.getId()), result, c.getId());
+
+    RevCommit fixedUpdateCommit = commitsAfterRewrite.get(invalidCommitIndex);
+    PersonIdent originalAuthorIdent = invalidUpdateCommit.getAuthorIdent();
+    PersonIdent fixedAuthorIdent = fixedUpdateCommit.getAuthorIdent();
+    assertThat(originalAuthorIdent).isNotEqualTo(fixedAuthorIdent);
+    assertThat(fixedUpdateCommit.getAuthorIdent().getName())
+        .isEqualTo("Gerrit User " + changeOwner.getAccountId());
+    assertThat(originalAuthorIdent.getEmailAddress()).isEqualTo(fixedAuthorIdent.getEmailAddress());
+    assertThat(originalAuthorIdent.getWhen()).isEqualTo(fixedAuthorIdent.getWhen());
+    assertThat(originalAuthorIdent.getTimeZone()).isEqualTo(fixedAuthorIdent.getTimeZone());
+    assertThat(invalidUpdateCommit.getFullMessage()).isEqualTo(fixedUpdateCommit.getFullMessage());
+    assertThat(invalidUpdateCommit.getCommitterIdent())
+        .isEqualTo(fixedUpdateCommit.getCommitterIdent());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff).hasSize(1);
+    assertThat(commitHistoryDiff.get(0)).contains("-author Change Owner <1@gerrit>");
+    assertThat(commitHistoryDiff.get(0)).contains("+author Gerrit User 1 <1@gerrit>");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRealUserFooterIdent() throws Exception {
+    Change c = newChange();
+
+    String realUserIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    ObjectId invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(c, "Comment on behalf of user", "Real-user: " + realUserIdentToFix),
+            getAuthorIdent(changeOwner.getAccount()));
+
+    IdentifiedUser impersonatedChangeOwner =
+        this.userFactory.runAs(
+            null, changeOwner.getAccountId(), requireNonNull(otherUser).getRealUser());
+    ChangeUpdate impersonatedChangeMessageUpdate = newUpdate(c, impersonatedChangeOwner);
+    impersonatedChangeMessageUpdate.setChangeMessage("Other comment on behalf of");
+    impersonatedChangeMessageUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly("Comment on behalf of user", "Other comment on behalf of");
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getAuthor())
+        .isEqualTo(changeOwner.getAccountId());
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getRealAuthor())
+        .isEqualTo(otherUser.getAccountId());
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly("Comment on behalf of user", "Other comment on behalf of");
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getAuthor())
+        .isEqualTo(changeOwner.getAccountId());
+    assertThat(notesBeforeRewrite.getChangeMessages().get(0).getRealAuthor())
+        .isEqualTo(otherUser.getAccountId());
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+    assertFixedCommits(ImmutableList.of(invalidUpdateCommit), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -9 +9 @@\n"
+                + "-Real-user: Other Account <2@gerrit>\n"
+                + "+Real-user: Gerrit User 2 <2@gerrit>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixReviewerFooterIdent() throws Exception {
+    Change c = newChange();
+    String reviewerIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    ImmutableList<ObjectId> commitsToFix =
+        new ImmutableList.Builder<ObjectId>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    // valid change message that should not be overwritten
+                    getChangeUpdateBody(
+                        c,
+                        "Removed reviewer <GERRIT_ACCOUNT_1>.",
+                        "Reviewer: " + reviewerIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    // valid change message that should not be overwritten
+                    getChangeUpdateBody(
+                        c,
+                        "Removed cc <GERRIT_ACCOUNT_2> with the following votes:\n\n * Code-Review+2 by <GERRIT_ACCOUNT_2>",
+                        "CC: " + reviewerIdentToFix),
+                    getAuthorIdent(otherUser.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(c, "Removed cc", "Removed: " + reviewerIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
+        ImmutableList.of(
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REVIEWER),
+            ReviewerStatusUpdate.create(updateTimestamp, otherUserId, otherUserId, CC),
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+    assertThat(notesAfterRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix, result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -9 +9 @@\n"
+                + "-Reviewer: Other Account <2@gerrit>\n"
+                + "+Reviewer: Gerrit User 2 <2@gerrit>\n",
+            "@@ -11 +11 @@\n"
+                + "-CC: Other Account <2@gerrit>\n"
+                + "+CC: Gerrit User 2 <2@gerrit>\n",
+            "@@ -9 +9 @@\n"
+                + "-Removed: Other Account <2@gerrit>\n"
+                + "+Removed: Gerrit User 2 <2@gerrit>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixReviewerMessage() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate addReviewerUpdate = newUpdate(c, changeOwner);
+    addReviewerUpdate.putReviewer(otherUserId, REVIEWER);
+    addReviewerUpdate.commit();
+
+    commitsToFix.add(
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                String.format("Removed reviewer %s.", otherUser.getAccount().fullName()),
+                "Removed: " + getValidIdentAsString(otherUser.getAccount())),
+            getAuthorIdent(changeOwner.getAccount())));
+
+    ChangeUpdate addCcUpdate = newUpdate(c, changeOwner);
+    addCcUpdate.putReviewer(otherUserId, CC);
+    addCcUpdate.commit();
+
+    commitsToFix.add(
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                String.format(
+                    "Removed cc %s with the following votes:\n\n * Code-Review+2",
+                    otherUser.getAccount().fullName()),
+                "Removed: " + getValidIdentAsString(otherUser.getAccount())),
+            getAuthorIdent(changeOwner.getAccount())));
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<ReviewerStatusUpdate> expectedReviewerUpdates =
+        ImmutableList.of(
+            ReviewerStatusUpdate.create(
+                new Timestamp(addReviewerUpdate.when.getTime()),
+                changeOwner.getAccountId(),
+                otherUserId,
+                REVIEWER),
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED),
+            ReviewerStatusUpdate.create(
+                new Timestamp(addCcUpdate.when.getTime()),
+                changeOwner.getAccountId(),
+                otherUserId,
+                CC),
+            ReviewerStatusUpdate.create(
+                updateTimestamp, changeOwner.getAccountId(), otherUserId, REMOVED));
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Removed reviewer Other Account.",
+            "Removed cc Other Account with the following votes:\n\n * Code-Review+2");
+    assertThat(notesAfterRewrite.getReviewerUpdates()).isEqualTo(expectedReviewerUpdates);
+    assertThat(changeMessages(notesAfterRewrite)).containsExactly("Removed reviewer", "Removed cc");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix.build(), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n" + "-Removed reviewer Other Account.\n" + "+Removed reviewer\n",
+            "@@ -6,3 +6 @@\n"
+                + "-Removed cc Other Account with the following votes:\n"
+                + "-\n"
+                + "- * Code-Review+2\n"
+                + "+Removed cc\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixReviewerMessageNoReviewerFooter() throws Exception {
+    Change c = newChange();
+
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c, String.format("Removed reviewer %s.", otherUser.getAccount().fullName())),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            String.format(
+                "Removed cc %s with the following votes:\n\n * Code-Review+2",
+                otherUser.getAccount().fullName())),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n" + "-Removed reviewer Other Account.\n" + "+Removed reviewer\n",
+            "@@ -6,3 +6 @@\n"
+                + "-Removed cc Other Account with the following votes:\n"
+                + "-\n"
+                + "- * Code-Review+2\n"
+                + "+Removed cc\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixLabelFooterIdent() throws Exception {
+    Change c = newChange();
+    String approverIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    ChangeUpdate approvalUpdateByOtherUser = newUpdate(c, otherUser);
+    approvalUpdateByOtherUser.putApproval(VERIFIED, (short) -1);
+    approvalUpdateByOtherUser.commit();
+
+    ImmutableList<ObjectId> commitsToFix =
+        new ImmutableList.Builder<ObjectId>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ null,
+                        "Label: -Verified " + approverIdentToFix,
+                        "Label: Custom-Label-1=-1 " + approverIdentToFix,
+                        "Label: Verified=+1",
+                        "Label: Custom-Label-1=+1",
+                        "Label: Custom-Label-2=+2 " + approverIdentToFix,
+                        "Label: Custom-Label-3=0 " + approverIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ null,
+                        "Label: -Verified " + changeOwnerIdentToFix,
+                        "Label: Custom-Label-1=+1"),
+                    getAuthorIdent(otherUser.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<PatchSetApproval> expectedApprovals =
+        ImmutableList.of(
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(),
+                        changeOwner.getAccountId(),
+                        LabelId.create(VERIFIED)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(),
+                        changeOwner.getAccountId(),
+                        LabelId.create("Custom-Label-1")))
+                .value(+1)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create(VERIFIED)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label-1")))
+                .value(+1)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label-2")))
+                .value(+2)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label-3")))
+                .value(0)
+                .granted(updateTimestamp)
+                .build());
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix, result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -7,2 +7,2 @@\n"
+                + "-Label: -Verified Other Account <2@gerrit>\n"
+                + "-Label: Custom-Label-1=-1 Other Account <2@gerrit>\n"
+                + "+Label: -Verified Gerrit User 2 <2@gerrit>\n"
+                + "+Label: Custom-Label-1=-1 Gerrit User 2 <2@gerrit>\n"
+                + "@@ -11,2 +11,2 @@\n"
+                + "-Label: Custom-Label-2=+2 Other Account <2@gerrit>\n"
+                + "-Label: Custom-Label-3=0 Other Account <2@gerrit>\n"
+                + "+Label: Custom-Label-2=+2 Gerrit User 2 <2@gerrit>\n"
+                + "+Label: Custom-Label-3=0 Gerrit User 2 <2@gerrit>\n",
+            "@@ -7 +7 @@\n"
+                + "-Label: -Verified Change Owner <1@gerrit>\n"
+                + "+Label: -Verified Gerrit User 1 <1@gerrit>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessage() throws Exception {
+    Change c = newChange();
+    String approverIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    ChangeUpdate approvalUpdateByOtherUser = newUpdate(c, otherUser);
+    approvalUpdateByOtherUser.putApproval(CODE_REVIEW, (short) +2);
+    approvalUpdateByOtherUser.putApproval("Custom-Label", (short) -1);
+    approvalUpdateByOtherUser.putApprovalFor(changeOwner.getAccountId(), VERIFIED, (short) -1);
+    approvalUpdateByOtherUser.commit();
+
+    ImmutableList<ObjectId> commitsToFix =
+        new ImmutableList.Builder<ObjectId>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ "Removed Code-Review+2 by " + otherUser.getNameEmail(),
+                        "Label: -Code-Review " + approverIdentToFix),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ "Removed Custom-Label-1 by " + otherUser.getNameEmail(),
+                        "Label: -Custom-Label " + getValidIdentAsString(otherUser.getAccount())),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail(),
+                        "Label: -Verified"),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<PatchSetApproval> expectedApprovals =
+        ImmutableList.of(
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(),
+                        changeOwner.getAccountId(),
+                        LabelId.create(VERIFIED)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create("Custom-Label")))
+                .value(0)
+                .granted(updateTimestamp)
+                .build(),
+            PatchSetApproval.builder()
+                .key(
+                    PatchSetApproval.key(
+                        c.currentPatchSetId(), otherUserId, LabelId.create(CODE_REVIEW)))
+                .value(0)
+                .granted(updateTimestamp)
+                .build());
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Removed Code-Review+2 by Other Account <other@account.com>",
+            "Removed Custom-Label-1 by Other Account <other@account.com>",
+            "Removed Verified+2 by Change Owner <change@owner.com>");
+
+    assertThat(notesBeforeRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Removed Code-Review+2 by <GERRIT_ACCOUNT_2>",
+            "Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>",
+            "Removed Verified+2 by <GERRIT_ACCOUNT_1>");
+    assertThat(notesAfterRewrite.getApprovals().get(c.currentPatchSetId()))
+        .containsExactlyElementsIn(expectedApprovals);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix, result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Removed Code-Review+2 by Other Account <other@account.com>\n"
+                + "+Removed Code-Review+2 by <GERRIT_ACCOUNT_2>\n"
+                + "@@ -9 +9 @@\n"
+                + "-Label: -Code-Review Other Account <2@gerrit>\n"
+                + "+Label: -Code-Review Gerrit User 2 <2@gerrit>\n",
+            "@@ -6 +6 @@\n"
+                + "-Removed Custom-Label-1 by Other Account <other@account.com>\n"
+                + "+Removed Custom-Label-1 by <GERRIT_ACCOUNT_2>\n",
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Change Owner <change@owner.com>\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_1>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessageWithUnparsableAuthorIdent() throws Exception {
+    Change c = newChange();
+    PersonIdent invalidAuthorIdent =
+        new PersonIdent(
+            changeOwner.getName(),
+            "server@" + serverId,
+            TimeUtil.nowTs(),
+            serverIdent.getTimeZone());
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            /*changeMessage=*/ "Removed Verified+2 by " + otherUser.getNameEmail(),
+            "Label: -Verified"),
+        invalidAuthorIdent);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    // Other Account does not applier in any change updates, replaced with default
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Other Account <other@account.com>\n"
+                + "+Removed Verified+2\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessageWithNoFooterLabel() throws Exception {
+    Change c = newChange();
+    ChangeUpdate approvalUpdate = newUpdate(c, changeOwner);
+    approvalUpdate.putApproval(VERIFIED, (short) +2);
+
+    approvalUpdate.putApprovalFor(otherUserId, VERIFIED, (short) -1);
+    approvalUpdate.commit();
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+
+        // Even though footer is missing, accounts are matched among the account in change updates.
+        getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified-1 by Other Account (0002)"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c, /*changeMessage=*/ "Removed Verified+2 by " + changeOwner.getNameEmail()),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    // No rewrite for default
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified+2 by Gerrit Account"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified-1 by Other Account (0002)\n"
+                + "+Removed Verified-1 by <GERRIT_ACCOUNT_2>\n",
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Change Owner <change@owner.com>\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_1>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessageWithNoFooterLabel_matchByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate approvalUpdate = newUpdate(c, changeOwner);
+    approvalUpdate.putApproval(VERIFIED, (short) +2);
+
+    approvalUpdate.putApprovalFor(otherUserId, VERIFIED, (short) -1);
+    approvalUpdate.commit();
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c, /*changeMessage=*/ "Removed Verified+2 by Renamed Change Owner <change@owner.com>"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Renamed Change Owner <change@owner.com>\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_1>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessageWithNoFooterLabel_matchByName() throws Exception {
+    Change c = newChange();
+    ChangeUpdate approvalUpdate = newUpdate(c, changeOwner);
+    approvalUpdate.putApproval(VERIFIED, (short) +2);
+
+    approvalUpdate.putApprovalFor(otherUserId, VERIFIED, (short) -1);
+    approvalUpdate.commit();
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, /*changeMessage=*/ "Removed Verified+2 by Change Owner"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Change Owner\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_1>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVoteChangeMessageWithNoFooterLabel_matchDuplicateAccounts()
+      throws Exception {
+    Account duplicateCodeOwner =
+        Account.builder(Account.id(4), TimeUtil.nowTs())
+            .setFullName(changeOwner.getName())
+            .setPreferredEmail("other@test.com")
+            .build();
+    accountCache.put(duplicateCodeOwner);
+    Change c = newChange();
+    ChangeUpdate approvalUpdate = newUpdate(c, changeOwner);
+    approvalUpdate.putApproval(VERIFIED, (short) +2);
+
+    approvalUpdate.putApprovalFor(duplicateCodeOwner.id(), VERIFIED, (short) -1);
+    approvalUpdate.commit();
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c, /*changeMessage=*/ "Removed Verified+2 by Change Owner <other@test.com>"),
+        getAuthorIdent(changeOwner.getAccount()));
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c, /*changeMessage=*/ "Removed Verified+2 by Change Owner <change@owner.com>"),
+        getAuthorIdent(changeOwner.getAccount()));
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c, /*changeMessage=*/ "Removed Verified-1 by Change Owner <other@test.com>"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Change Owner <other@test.com>\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_4>\n",
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified+2 by Change Owner <change@owner.com>\n"
+                + "+Removed Verified+2 by <GERRIT_ACCOUNT_1>\n",
+            "@@ -6 +6 @@\n"
+                + "-Removed Verified-1 by Change Owner <other@test.com>\n"
+                + "+Removed Verified-1 by <GERRIT_ACCOUNT_4>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixRemoveVotesChangeMessage() throws Exception {
+    Change c = newChange();
+    ChangeUpdate approvalUpdate = newUpdate(c, changeOwner);
+    approvalUpdate.putApproval(VERIFIED, (short) +2);
+
+    approvalUpdate.putApprovalFor(otherUserId, VERIFIED, (short) -1);
+    approvalUpdate.commit();
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+
+        // Even though footer is missing, accounts are matched among the account in change updates.
+        getChangeUpdateBody(
+            c,
+            /*changeMessage=*/ "Removed the following votes:\n"
+                + String.format("* Verified-1 by %s\n", otherUser.getNameEmail())),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            /*changeMessage=*/ "Removed the following votes:\n"
+                + String.format("* Verified+2 by %s\n", changeOwner.getNameEmail())
+                + String.format("* Verified-1 by %s\n", changeOwner.getNameEmail())
+                + String.format("* Code-Review by %s\n", otherUser.getNameEmail())),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    // No rewrite for default
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            /*changeMessage=*/ "Removed the following votes:\n"
+                + "* Verified+2 by Gerrit Account\n"
+                + "* Verified-1 by <GERRIT_ACCOUNT_2>\n"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -7 +7 @@\n"
+                + "-* Verified-1 by Other Account <other@account.com>\n"
+                + "+* Verified-1 by <GERRIT_ACCOUNT_2>\n",
+            "@@ -7,3 +7,3 @@\n"
+                + "-* Verified+2 by Change Owner <change@owner.com>\n"
+                + "-* Verified-1 by Change Owner <change@owner.com>\n"
+                + "-* Code-Review by Other Account <other@account.com>\n"
+                + "+* Verified+2 by <GERRIT_ACCOUNT_1>\n"
+                + "+* Verified-1 by <GERRIT_ACCOUNT_1>\n"
+                + "+* Code-Review by <GERRIT_ACCOUNT_2>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixAttentionFooter() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    // Only 'reason' fix is required
+    ChangeUpdate invalidAttentionSetUpdate = newUpdate(c, changeOwner);
+    invalidAttentionSetUpdate.putReviewer(otherUserId, REVIEWER);
+    invalidAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            otherUserId,
+            Operation.ADD,
+            String.format("Added by %s using the hovercard menu", otherUser.getName())));
+    commitsToFix.add(invalidAttentionSetUpdate.commit());
+    ChangeUpdate invalidMultipleAttentionSetUpdate = newUpdate(c, changeOwner);
+    invalidMultipleAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            changeOwner.getAccountId(),
+            Operation.ADD,
+            String.format("%s replied on the change", otherUser.getName())));
+    invalidMultipleAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            otherUserId,
+            Operation.REMOVE,
+            String.format("Removed by %s using the hovercard menu", otherUser.getName())));
+    commitsToFix.add(invalidMultipleAttentionSetUpdate.commit());
+    String otherUserIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    commitsToFix.add(
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                /*changeMessage=*/ null,
+                // Only 'person_ident' fix is required
+                "Attention: "
+                    + gson.toJson(
+                        new AttentionStatusInNoteDb(
+                            otherUserIdentToFix,
+                            Operation.ADD,
+                            "Added by someone using the hovercard menu")),
+                // Both 'reason' and 'person_ident' fix is required
+                "Attention: "
+                    + gson.toJson(
+                        new AttentionStatusInNoteDb(
+                            changeOwnerIdentToFix,
+                            Operation.REMOVE,
+                            String.format("%s replied on the change", otherUser.getName())))),
+            getAuthorIdent(changeOwner.getAccount())));
+
+    ChangeUpdate validAttentionSetUpdate = newUpdate(c, changeOwner);
+    validAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(otherUserId, Operation.REMOVE, "Removed by someone"));
+    validAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            changeOwner.getAccountId(), Operation.ADD, "Added by someone"));
+    validAttentionSetUpdate.commit();
+
+    ChangeUpdate invalidRemovedByClickUpdate = newUpdate(c, changeOwner);
+    invalidRemovedByClickUpdate.addToPlannedAttentionSetUpdates(
+        AttentionSetUpdate.createForWrite(
+            changeOwner.getAccountId(),
+            Operation.REMOVE,
+            String.format("Removed by %s by clicking the attention icon", otherUser.getName())));
+    commitsToFix.add(invalidRemovedByClickUpdate.commit());
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+    notesBeforeRewrite.getAttentionSetUpdates();
+    Timestamp updateTimestamp = new Timestamp(serverIdent.getWhen().getTime());
+    ImmutableList<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
+        ImmutableList.of(
+            AttentionSetUpdate.createFromRead(
+                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                changeOwner.getAccountId(),
+                Operation.REMOVE,
+                String.format("Removed by %s by clicking the attention icon", otherUser.getName())),
+            AttentionSetUpdate.createFromRead(
+                validAttentionSetUpdate.getWhen().toInstant(),
+                changeOwner.getAccountId(),
+                Operation.ADD,
+                "Added by someone"),
+            AttentionSetUpdate.createFromRead(
+                validAttentionSetUpdate.getWhen().toInstant(),
+                otherUserId,
+                Operation.REMOVE,
+                "Removed by someone"),
+            AttentionSetUpdate.createFromRead(
+                updateTimestamp.toInstant(),
+                changeOwner.getAccountId(),
+                Operation.REMOVE,
+                String.format("%s replied on the change", otherUser.getName())),
+            AttentionSetUpdate.createFromRead(
+                updateTimestamp.toInstant(),
+                otherUserId,
+                Operation.ADD,
+                "Added by someone using the hovercard menu"),
+            AttentionSetUpdate.createFromRead(
+                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                otherUserId,
+                Operation.REMOVE,
+                String.format("Removed by %s using the hovercard menu", otherUser.getName())),
+            AttentionSetUpdate.createFromRead(
+                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                changeOwner.getAccountId(),
+                Operation.ADD,
+                String.format("%s replied on the change", otherUser.getName())),
+            AttentionSetUpdate.createFromRead(
+                invalidAttentionSetUpdate.getWhen().toInstant(),
+                otherUserId,
+                Operation.ADD,
+                String.format("Added by %s using the hovercard menu", otherUser.getName())));
+
+    ImmutableList<AttentionSetUpdate> attentionSetUpdatesAfterRewrite =
+        ImmutableList.of(
+            AttentionSetUpdate.createFromRead(
+                invalidRemovedByClickUpdate.getWhen().toInstant(),
+                changeOwner.getAccountId(),
+                Operation.REMOVE,
+                "Removed by someone by clicking the attention icon"),
+            AttentionSetUpdate.createFromRead(
+                validAttentionSetUpdate.getWhen().toInstant(),
+                changeOwner.getAccountId(),
+                Operation.ADD,
+                "Added by someone"),
+            AttentionSetUpdate.createFromRead(
+                validAttentionSetUpdate.getWhen().toInstant(),
+                otherUserId,
+                Operation.REMOVE,
+                "Removed by someone"),
+            AttentionSetUpdate.createFromRead(
+                updateTimestamp.toInstant(),
+                changeOwner.getAccountId(),
+                Operation.REMOVE,
+                "Someone replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                updateTimestamp.toInstant(),
+                otherUserId,
+                Operation.ADD,
+                "Added by someone using the hovercard menu"),
+            AttentionSetUpdate.createFromRead(
+                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                otherUserId,
+                Operation.REMOVE,
+                "Removed by someone using the hovercard menu"),
+            AttentionSetUpdate.createFromRead(
+                invalidMultipleAttentionSetUpdate.getWhen().toInstant(),
+                changeOwner.getAccountId(),
+                Operation.ADD,
+                "Someone replied on the change"),
+            AttentionSetUpdate.createFromRead(
+                invalidAttentionSetUpdate.getWhen().toInstant(),
+                otherUserId,
+                Operation.ADD,
+                "Added by someone using the hovercard menu"));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(notesBeforeRewrite.getAttentionSetUpdates())
+        .containsExactlyElementsIn(attentionSetUpdatesBeforeRewrite);
+    assertThat(notesAfterRewrite.getAttentionSetUpdates())
+        .containsExactlyElementsIn(attentionSetUpdatesAfterRewrite);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix.build(), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff).hasSize(4);
+    assertThat(commitHistoryDiff.get(0))
+        .isEqualTo(
+            "@@ -8 +8 @@\n"
+                + "-Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by Other Account using the hovercard menu\"}\n"
+                + "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}\n");
+    assertThat(Arrays.asList(commitHistoryDiff.get(1).split("\n")))
+        .containsExactly(
+            "@@ -7,2 +7,2 @@",
+            "-Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Other Account replied on the change\"}",
+            "-Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other Account using the hovercard menu\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Someone replied on the change\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by someone using the hovercard menu\"}");
+    assertThat(Arrays.asList(commitHistoryDiff.get(2).split("\n")))
+        .containsExactly(
+            "@@ -7,2 +7,2 @@",
+            "-Attention: {\"person_ident\":\"Other Account \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}",
+            "-Attention: {\"person_ident\":\"Change Owner \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Other Account replied on the change\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 2 \\u003c2@gerrit\\u003e\",\"operation\":\"ADD\",\"reason\":\"Added by someone using the hovercard menu\"}",
+            "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Someone replied on the change\"}");
+    assertThat(commitHistoryDiff.get(3))
+        .isEqualTo(
+            "@@ -7 +7 @@\n"
+                + "-Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by Other Account by clicking the attention icon\"}\n"
+                + "+Attention: {\"person_ident\":\"Gerrit User 1 \\u003c1@gerrit\\u003e\",\"operation\":\"REMOVE\",\"reason\":\"Removed by someone by clicking the attention icon\"}\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixAttentionFooter_okReason_noRewrite() throws Exception {
+    Change c = newChange();
+    ImmutableList<String> okAccountNames =
+        ImmutableList.of(
+            "Someone",
+            "Someone else",
+            "someone",
+            "someone else",
+            "Anonymous",
+            "anonymous",
+            "<GERRIT_ACCOUNT_1>",
+            "<GERRIT_ACCOUNT_2>");
+    ImmutableList.Builder<AttentionSetUpdate> attentionSetUpdatesBeforeRewrite =
+        new ImmutableList.Builder<>();
+    for (String okAccountName : okAccountNames) {
+      ChangeUpdate firstAttentionSetUpdate = newUpdate(c, changeOwner);
+      firstAttentionSetUpdate.putReviewer(otherUserId, REVIEWER);
+      firstAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+          AttentionSetUpdate.createForWrite(
+              otherUserId,
+              Operation.ADD,
+              String.format("Added by %s using the hovercard menu", okAccountName)));
+      firstAttentionSetUpdate.commit();
+      ChangeUpdate secondAttentionSetUpdate = newUpdate(c, changeOwner);
+      secondAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+          AttentionSetUpdate.createForWrite(
+              changeOwner.getAccountId(),
+              Operation.ADD,
+              String.format("%s replied on the change", okAccountName)));
+      secondAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+          AttentionSetUpdate.createForWrite(
+              otherUserId,
+              Operation.REMOVE,
+              String.format("Removed by %s using the hovercard menu", okAccountName)));
+      secondAttentionSetUpdate.commit();
+      ChangeUpdate thirdAttentionSetUpdate = newUpdate(c, changeOwner);
+      thirdAttentionSetUpdate.addToPlannedAttentionSetUpdates(
+          AttentionSetUpdate.createForWrite(
+              changeOwner.getAccountId(),
+              Operation.REMOVE,
+              String.format("Removed by %s by clicking the attention icon", okAccountName)));
+      thirdAttentionSetUpdate.commit();
+      attentionSetUpdatesBeforeRewrite.add(
+          AttentionSetUpdate.createFromRead(
+              thirdAttentionSetUpdate.getWhen().toInstant(),
+              changeOwner.getAccountId(),
+              Operation.REMOVE,
+              String.format("Removed by %s by clicking the attention icon", okAccountName)),
+          AttentionSetUpdate.createFromRead(
+              secondAttentionSetUpdate.getWhen().toInstant(),
+              otherUserId,
+              Operation.REMOVE,
+              String.format("Removed by %s using the hovercard menu", okAccountName)),
+          AttentionSetUpdate.createFromRead(
+              secondAttentionSetUpdate.getWhen().toInstant(),
+              changeOwner.getAccountId(),
+              Operation.ADD,
+              String.format("%s replied on the change", okAccountName)),
+          AttentionSetUpdate.createFromRead(
+              firstAttentionSetUpdate.getWhen().toInstant(),
+              otherUserId,
+              Operation.ADD,
+              String.format("Added by %s using the hovercard menu", okAccountName)));
+    }
+
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getAttentionSetUpdates())
+        .containsExactlyElementsIn(attentionSetUpdatesBeforeRewrite.build());
+
+    Ref metaRefBefore = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult backfillResult = rewriter.backfillProject(project, repo, options);
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    Ref metaRefAfter = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    assertThat(notesBeforeRewrite.getMetaId()).isEqualTo(notesAfterRewrite.getMetaId());
+    assertThat(metaRefBefore.getObjectId()).isEqualTo(metaRefAfter.getObjectId());
+    assertThat(backfillResult.fixedRefDiff).isEmpty();
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixSubmitChangeMessage() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate invalidMergedMessageUpdate = newUpdate(c, changeOwner);
+    invalidMergedMessageUpdate.setChangeMessage(
+        "Change has been successfully merged by " + changeOwner.getName());
+    invalidMergedMessageUpdate.setTopic("");
+
+    commitsToFix.add(invalidMergedMessageUpdate.commit());
+    ChangeUpdate invalidCherryPickedMessageUpdate = newUpdate(c, changeOwner);
+    invalidCherryPickedMessageUpdate.setChangeMessage(
+        "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+            + changeOwner.getName());
+
+    commitsToFix.add(invalidCherryPickedMessageUpdate.commit());
+    ChangeUpdate invalidRebasedMessageUpdate = newUpdate(c, changeOwner);
+    invalidRebasedMessageUpdate.setChangeMessage(
+        "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by "
+            + changeOwner.getName());
+
+    commitsToFix.add(invalidRebasedMessageUpdate.commit());
+    ChangeUpdate validSubmitMessageUpdate = newUpdate(c, changeOwner);
+    validSubmitMessageUpdate.setChangeMessage(
+        "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+    validSubmitMessageUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Change has been successfully merged by Change Owner",
+            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Change has been successfully merged",
+            "Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b",
+            "Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix.build(), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Change has been successfully merged by Change Owner\n"
+                + "+Change has been successfully merged\n",
+            "@@ -6 +6 @@\n"
+                + "-Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+                + "+Change has been successfully cherry-picked as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n",
+            "@@ -6 +6 @@\n"
+                + "-Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b by Change Owner\n"
+                + "+Change has been successfully rebased and submitted as e40dc1a50dc7f457a37579e2755374f3e1a5413b\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixSubmitChangeMessageAndFooters() throws Exception {
+    Change c = newChange();
+    PersonIdent invalidAuthorIdent =
+        new PersonIdent(
+            changeOwner.getName(),
+            changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
+            TimeUtil.nowTs(),
+            serverIdent.getTimeZone());
+    String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            "Change has been successfully merged by " + changeOwner.getName(),
+            "Status: merged",
+            "Tag: autogenerated:gerrit:merged",
+            "Reviewer: " + changeOwnerIdentToFix,
+            "Label: SUBM=+1",
+            "Submission-id: 6310-1521542139810-cfb7e159",
+            "Submitted-with: OK",
+            "Submitted-with: OK: Code-Review: " + changeOwnerIdentToFix),
+        invalidAuthorIdent);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -1 +1 @@\n"
+                + "-author Change Owner <1@gerrit> 1254344405 -0700\n"
+                + "+author Gerrit User 1 <1@gerrit> 1254344405 -0700\n"
+                + "@@ -6 +6 @@\n"
+                + "-Change has been successfully merged by Change Owner\n"
+                + "+Change has been successfully merged\n"
+                + "@@ -11 +11 @@\n"
+                + "-Reviewer: Change Owner <1@gerrit>\n"
+                + "+Reviewer: Gerrit User 1 <1@gerrit>\n"
+                + "@@ -15 +15 @@\n"
+                + "-Submitted-with: OK: Code-Review: Change Owner <1@gerrit>\n"
+                + "+Submitted-with: OK: Code-Review: Gerrit User 1 <1@gerrit>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixSubmittedWithFooterIdent() throws Exception {
+    Change c = newChange();
+
+    ChangeUpdate preSubmitUpdate = newUpdate(c, changeOwner);
+    preSubmitUpdate.setChangeMessage("Per-submit update");
+    preSubmitUpdate.commit();
+
+    String otherUserIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    String changeOwnerIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                /*changeMessage=*/ null,
+                "Label: SUBM=+1",
+                "Submission-id: 5271-1496917120975-10a10df9",
+                "Submitted-with: NOT_READY",
+                "Submitted-with: NEED: Code-Review: " + otherUserIdentToFix,
+                "Submitted-with: OK: Code-Style",
+                "Submitted-with: OK: Verified: " + changeOwnerIdentToFix,
+                "Submitted-with: FORCED with error"),
+            getAuthorIdent(changeOwner.getAccount()));
+
+    ChangeUpdate postSubmitUpdate = newUpdate(c, changeOwner);
+    postSubmitUpdate.setChangeMessage("Per-submit update");
+    postSubmitUpdate.commit();
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    ImmutableList<SubmitRecord> expectedRecords =
+        ImmutableList.of(
+            submitRecord(
+                "NOT_READY",
+                null,
+                submitLabel(CODE_REVIEW, "NEED", otherUserId),
+                submitLabel("Code-Style", "OK", null),
+                submitLabel(VERIFIED, "OK", changeOwner.getAccountId())),
+            submitRecord("FORCED", " with error"));
+    assertThat(notesBeforeRewrite.getSubmitRecords()).isEqualTo(expectedRecords);
+    assertThat(notesAfterRewrite.getSubmitRecords()).isEqualTo(expectedRecords);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+    assertFixedCommits(ImmutableList.of(invalidUpdateCommit.getId()), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -10 +10 @@\n"
+                + "-Submitted-with: NEED: Code-Review: Other Account <2@gerrit>\n"
+                + "+Submitted-with: NEED: Code-Review: Gerrit User 2 <2@gerrit>\n"
+                + "@@ -12 +12 @@\n"
+                + "-Submitted-with: OK: Verified: Change Owner <1@gerrit>\n"
+                + "+Submitted-with: OK: Verified: Gerrit User 1 <1@gerrit>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixDeleteChangeMessageCommitMessage() throws Exception {
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate invalidDeleteChangeMessageUpdate = newUpdate(c, changeOwner);
+    invalidDeleteChangeMessageUpdate.setChangeMessage(
+        "Change message removed by: " + changeOwner.getName());
+    commitsToFix.add(invalidDeleteChangeMessageUpdate.commit());
+    ChangeUpdate invalidDeleteChangeMessageUpdateWithReason = newUpdate(c, changeOwner);
+    invalidDeleteChangeMessageUpdateWithReason.setChangeMessage(
+        String.format(
+            "Change message removed by: %s\nReason: %s",
+            changeOwner.getName(), "contains confidential information"));
+    commitsToFix.add(invalidDeleteChangeMessageUpdateWithReason.commit());
+    ChangeUpdate validDeleteChangeMessageUpdate = newUpdate(c, changeOwner);
+    validDeleteChangeMessageUpdate.setChangeMessage(
+        "Change message removed by: <GERRIT_ACCOUNT_1>");
+    validDeleteChangeMessageUpdate.commit();
+    ChangeUpdate validDeleteChangeMessageUpdateWithReason = newUpdate(c, changeOwner);
+    validDeleteChangeMessageUpdateWithReason.setChangeMessage(
+        "Change message removed by: <GERRIT_ACCOUNT_1>\nReason: abusive language");
+    validDeleteChangeMessageUpdateWithReason.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Change message removed by: Change Owner",
+            "Change message removed by: Change Owner\n"
+                + "Reason: contains confidential information",
+            "Change message removed by: <GERRIT_ACCOUNT_1>",
+            "Change message removed by: <GERRIT_ACCOUNT_1>\n" + "Reason: abusive language");
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Change message removed",
+            "Change message removed\n" + "Reason: contains confidential information",
+            "Change message removed by: <GERRIT_ACCOUNT_1>",
+            "Change message removed by: <GERRIT_ACCOUNT_1>\n" + "Reason: abusive language");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix.build(), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Change message removed by: Change Owner\n"
+                + "+Change message removed\n",
+            "@@ -6 +6 @@\n"
+                + "-Change message removed by: Change Owner\n"
+                + "+Change message removed\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixCodeOwnersOnAddReviewerChangeMessage() throws Exception {
+
+    Account reviewer =
+        Account.builder(Account.id(3), TimeUtil.nowTs())
+            .setFullName("Reviewer User")
+            .setPreferredEmail("reviewer@account.com")
+            .build();
+    accountCache.put(reviewer);
+    Account duplicateCodeOwner =
+        Account.builder(Account.id(4), TimeUtil.nowTs()).setFullName(changeOwner.getName()).build();
+    accountCache.put(duplicateCodeOwner);
+    Account duplicateReviewer =
+        Account.builder(Account.id(5), TimeUtil.nowTs()).setFullName(reviewer.getName()).build();
+    accountCache.put(duplicateReviewer);
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+    ChangeUpdate addReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    addReviewerUpdate.putReviewer(reviewer.id(), REVIEWER);
+    addReviewerUpdate.commit();
+    ChangeUpdate invalidOnAddReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    invalidOnAddReviewerUpdate.setChangeMessage(
+        "Reviewer User who was added as reviewer owns the following files:\n"
+            + "   * file1.java\n"
+            + "   * file2.ts\n");
+    commitsToFix.add(invalidOnAddReviewerUpdate.commit());
+    ChangeUpdate addOtherReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    addOtherReviewerUpdate.putReviewer(otherUserId, REVIEWER);
+    addOtherReviewerUpdate.commit();
+    ChangeUpdate invalidOnAddReviewerMultipleReviewerUpdate =
+        newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    invalidOnAddReviewerMultipleReviewerUpdate.setChangeMessage(
+        "Reviewer User who was added as reviewer owns the following files:\n"
+            + "   * file1.java\n"
+            + "\nOther Account who was added as reviewer owns the following files:\n"
+            + "   * file3.js\n"
+            + "\nMissing Reviewer who was added as reviewer owns the following files:\n"
+            + "   * file4.java\n");
+    commitsToFix.add(invalidOnAddReviewerMultipleReviewerUpdate.commit());
+    ChangeUpdate addDuplicateReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    addDuplicateReviewerUpdate.putReviewer(duplicateReviewer.id(), REVIEWER);
+    addDuplicateReviewerUpdate.commit();
+    // Reviewer name resolves to multiple accounts in the same change
+    ChangeUpdate onAddReviewerUpdateWithDuplicate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    onAddReviewerUpdateWithDuplicate.setChangeMessage(
+        "Reviewer User who was added as reviewer owns the following files:\n"
+            + "   * file6.java\n");
+    commitsToFix.add(onAddReviewerUpdateWithDuplicate.commit());
+
+    ChangeUpdate validOnAddReviewerUpdate = newCodeOwnerAddReviewerUpdate(c, changeOwner);
+    validOnAddReviewerUpdate.setChangeMessage(
+        "Gerrit Account who was added as reviewer owns the following files:\n"
+            + "   * file1.java\n"
+            + "\n<GERRIT_ACCOUNT_1> who was added as reviewer owns the following files:\n"
+            + "   * file3.js\n");
+    validOnAddReviewerUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(changeMessages(notesBeforeRewrite)).hasSize(4);
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "<GERRIT_ACCOUNT_3>, who was added as reviewer owns the following files:\n"
+                + "   * file1.java\n"
+                + "   * file2.ts\n",
+            "<GERRIT_ACCOUNT_3>, who was added as reviewer owns the following files:\n"
+                + "   * file1.java\n"
+                + "\n<GERRIT_ACCOUNT_2>, who was added as reviewer owns the following files:\n"
+                + "   * file3.js\n"
+                + "\nAdded reviewer owns the following files:\n"
+                + "   * file4.java\n",
+            "Added reviewer owns the following files:\n" + "   * file6.java\n",
+            "Gerrit Account who was added as reviewer owns the following files:\n"
+                + "   * file1.java\n"
+                + "\n<GERRIT_ACCOUNT_1> who was added as reviewer owns the following files:\n"
+                + "   * file3.js\n");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix.build(), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Reviewer User who was added as reviewer owns the following files:\n"
+                + "+<GERRIT_ACCOUNT_3>, who was added as reviewer owns the following files:\n",
+            "@@ -6 +6 @@\n"
+                + "-Reviewer User who was added as reviewer owns the following files:\n"
+                + "+<GERRIT_ACCOUNT_3>, who was added as reviewer owns the following files:\n"
+                + "@@ -9 +9 @@\n"
+                + "-Other Account who was added as reviewer owns the following files:\n"
+                + "+<GERRIT_ACCOUNT_2>, who was added as reviewer owns the following files:\n"
+                + "@@ -12 +12 @@\n"
+                + "-Missing Reviewer who was added as reviewer owns the following files:\n"
+                + "+Added reviewer owns the following files:\n",
+            "@@ -6 +6 @@\n"
+                + "-Reviewer User who was added as reviewer owns the following files:\n"
+                + "+Added reviewer owns the following files:\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixCodeOwnersOnReviewChangeMessage() throws Exception {
+
+    Change c = newChange();
+    ImmutableList.Builder<ObjectId> commitsToFix = new ImmutableList.Builder<>();
+
+    ChangeUpdate invalidOnReviewUpdate = newUpdate(c, changeOwner);
+    invalidOnReviewUpdate.setChangeMessage(
+        "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
+            + "By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+            + "   * file1.java\n"
+            + "   * file2.ts\n"
+            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
+            + "By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n");
+    commitsToFix.add(invalidOnReviewUpdate.commit());
+
+    ChangeUpdate invalidOnReviewUpdateAnyOrder = newUpdate(c, changeOwner);
+    invalidOnReviewUpdateAnyOrder.setChangeMessage(
+        "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
+            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
+            + "By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
+            + "By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+            + "   * file1.java\n"
+            + "   * file2.ts\n");
+    commitsToFix.add(invalidOnReviewUpdateAnyOrder.commit());
+    ChangeUpdate invalidOnApprovalUpdate = newUpdate(c, otherUser);
+    invalidOnApprovalUpdate.setChangeMessage(
+        "Patch Set 1: -Code-Review\n\n"
+            + "By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by Other Account:\n"
+            + "   * file1.java\n"
+            + "   * file2.ts\n"
+            + "\nThe listed files are still implicitly approved by Other Account.\n");
+    commitsToFix.add(invalidOnApprovalUpdate.commit());
+
+    ChangeUpdate invalidOnOverrideUpdate = newUpdate(c, changeOwner);
+    invalidOnOverrideUpdate.setChangeMessage(
+        "Patch Set 1: -Owners-Override\n\n"
+            + "(1 comment)\n\n"
+            + "By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by Change Owner\n");
+
+    commitsToFix.add(invalidOnOverrideUpdate.commit());
+
+    ChangeUpdate partiallyValidOnReviewUpdate = newUpdate(c, changeOwner);
+    partiallyValidOnReviewUpdate.setChangeMessage(
+        "Patch Set 1: Any-Label+2 Code-Review+2\n\n"
+            + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+            + "   * file1.java\n"
+            + "   * file2.ts\n"
+            + "By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n");
+    commitsToFix.add(partiallyValidOnReviewUpdate.commit());
+
+    ChangeUpdate validOnApprovalUpdate = newUpdate(c, changeOwner);
+    validOnApprovalUpdate.setChangeMessage(
+        "Patch Set 1: Code-Review-2\n\n"
+            + "By voting Code-Review-2 the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+            + "   * file4.java\n");
+    validOnApprovalUpdate.commit();
+
+    ChangeUpdate validOnOverrideUpdate = newUpdate(c, changeOwner);
+    validOnOverrideUpdate.setChangeMessage(
+        "Patch Set 1: Owners-Override+1\n\n"
+            + "By voting Owners-Override+1 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n");
+    validOnOverrideUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.build().stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+
+    assertThat(changeMessages(notesBeforeRewrite)).hasSize(7);
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
+                + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "   * file1.java\n"
+                + "   * file2.ts\n"
+                + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
+                + "By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n",
+            "Patch Set 1: Any-Label+2 Other-Label+2 Code-Review+2\n\n"
+                + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
+                + "By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n"
+                + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "   * file1.java\n"
+                + "   * file2.ts\n",
+            "Patch Set 1: -Code-Review\n"
+                + "\n"
+                + "By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+                + "   * file1.java\n"
+                + "   * file2.ts\n"
+                + "\nThe listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
+            "Patch Set 1: -Owners-Override\n"
+                + "\n"
+                + "(1 comment)\n"
+                + "\n"
+                + "By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by <GERRIT_ACCOUNT_1>\n",
+            "Patch Set 1: Any-Label+2 Code-Review+2\n\n"
+                + "By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "   * file1.java\n"
+                + "   * file2.ts\n"
+                + "By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n",
+            "Patch Set 1: Code-Review-2\n\n"
+                + "By voting Code-Review-2 the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "   * file4.java\n",
+            "Patch Set 1: Owners-Override+1\n"
+                + "\n"
+                + "By voting Owners-Override+1 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n");
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix.build(), result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -8 +8 @@\n"
+                + "-By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+                + "+By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n"
+                + "@@ -11,2 +11,2 @@\n"
+                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
+                + "-By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
+                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
+                + "+By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n",
+            "@@ -8,3 +8,3 @@\n"
+                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
+                + "-By voting Other-Label+2 the code-owners submit requirement is still overridden by Change Owner\n"
+                + "-By voting Code-Review+2 the following files are now code-owner approved by Change Owner:\n"
+                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n"
+                + "+By voting Other-Label+2 the code-owners submit requirement is still overridden by <GERRIT_ACCOUNT_1>\n"
+                + "+By voting Code-Review+2 the following files are now code-owner approved by <GERRIT_ACCOUNT_1>:\n",
+            "@@ -8 +8 @@\n"
+                + "-By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by Other Account:\n"
+                + "+By removing the Code-Review+2 vote the following files are no longer explicitly code-owner approved by <GERRIT_ACCOUNT_2>:\n"
+                + "@@ -12 +12 @@\n"
+                + "-The listed files are still implicitly approved by Other Account.\n"
+                + "+The listed files are still implicitly approved by <GERRIT_ACCOUNT_2>.\n",
+            "@@ -10 +10 @@\n"
+                + "-By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by Change Owner\n"
+                + "+By removing the Owners-Override+1 vote the code-owners submit requirement is no longer overridden by <GERRIT_ACCOUNT_1>\n",
+            "@@ -11 +11 @@\n"
+                + "-By voting Any-Label+2 the code-owners submit requirement is overridden by Change Owner\n"
+                + "+By voting Any-Label+2 the code-owners submit requirement is overridden by <GERRIT_ACCOUNT_1>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixAssigneeFooterIdent() throws Exception {
+    Change c = newChange();
+
+    String assigneeIdentToFix = getAccountIdentToFix(changeOwner.getAccount());
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(c, "Assignee added", "Assignee: " + assigneeIdentToFix),
+            getAuthorIdent(changeOwner.getAccount()));
+
+    ChangeUpdate changeAssigneeUpdate = newUpdate(c, changeOwner);
+    changeAssigneeUpdate.setAssignee(otherUserId);
+    changeAssigneeUpdate.commit();
+
+    ChangeUpdate removeAssigneeUpdate = newUpdate(c, changeOwner);
+    removeAssigneeUpdate.removeAssignee();
+    removeAssigneeUpdate.commit();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
+    assertThat(notesAfterRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+    assertFixedCommits(ImmutableList.of(invalidUpdateCommit.getId()), result, c.getId());
+
+    RevCommit fixedUpdateCommit = commitsAfterRewrite.get(invalidCommitIndex);
+    assertThat(invalidUpdateCommit.getAuthorIdent()).isEqualTo(fixedUpdateCommit.getAuthorIdent());
+    assertThat(invalidUpdateCommit.getCommitterIdent())
+        .isEqualTo(fixedUpdateCommit.getCommitterIdent());
+    assertThat(invalidUpdateCommit.getFullMessage())
+        .isNotEqualTo(fixedUpdateCommit.getFullMessage());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
+    assertThat(invalidUpdateCommit.getFullMessage()).contains(assigneeIdentToFix);
+    String expectedFixedIdent = getValidIdentAsString(changeOwner.getAccount());
+    assertThat(fixedUpdateCommit.getFullMessage()).contains(expectedFixedIdent);
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -9 +9 @@\n"
+                + "-Assignee: Change Owner <1@gerrit>\n"
+                + "+Assignee: Gerrit User 1 <1@gerrit>\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixAssigneeChangeMessage() throws Exception {
+    Change c = newChange();
+
+    ImmutableList<ObjectId> commitsToFix =
+        new ImmutableList.Builder<ObjectId>()
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        "Assignee added: " + changeOwner.getNameEmail(),
+                        "Assignee: " + getValidIdentAsString(changeOwner.getAccount())),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c,
+                        String.format(
+                            "Assignee changed from: %s to: %s",
+                            changeOwner.getNameEmail(), otherUser.getNameEmail()),
+                        "Assignee: " + getValidIdentAsString(otherUser.getAccount())),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .add(
+                writeUpdate(
+                    RefNames.changeMetaRef(c.getId()),
+                    getChangeUpdateBody(
+                        c, "Assignee deleted: " + otherUser.getNameEmail(), "Assignee:"),
+                    getAuthorIdent(changeOwner.getAccount())))
+            .build();
+
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    ImmutableList<Integer> invalidCommits =
+        commitsToFix.stream()
+            .map(commit -> commitsBeforeRewrite.indexOf(commit))
+            .collect(toImmutableList());
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesBeforeRewrite.getChange().getAssignee()).isNull();
+    assertThat(changeMessages(notesBeforeRewrite))
+        .containsExactly(
+            "Assignee added: Change Owner <change@owner.com>",
+            "Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>",
+            "Assignee deleted: Other Account <other@account.com>");
+
+    assertThat(notesAfterRewrite.getPastAssignees())
+        .containsExactly(changeOwner.getAccountId(), otherUser.getAccountId());
+    assertThat(notesAfterRewrite.getChange().getAssignee()).isNull();
+    assertThat(changeMessages(notesAfterRewrite))
+        .containsExactly(
+            "Assignee added: " + AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()),
+            String.format(
+                "Assignee changed from: %s to: %s",
+                AccountTemplateUtil.getAccountTemplate(changeOwner.getAccountId()),
+                AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId())),
+            "Assignee deleted: "
+                + AccountTemplateUtil.getAccountTemplate(otherUser.getAccountId()));
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(commitsBeforeRewrite, commitsAfterRewrite, invalidCommits);
+    assertFixedCommits(commitsToFix, result, c.getId());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Assignee added: Change Owner <change@owner.com>\n"
+                + "+Assignee added: <GERRIT_ACCOUNT_1>\n",
+            "@@ -6 +6 @@\n"
+                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+                + "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n",
+            "@@ -6 +6 @@\n"
+                + "-Assignee deleted: Other Account <other@account.com>\n"
+                + "+Assignee deleted: <GERRIT_ACCOUNT_2>\n"
+                // Both empty value and space are parsed as deleted assignee anyway.
+                + "@@ -9 +9 @@\n"
+                + "-Assignee:\n"
+                + "+Assignee: \n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void fixAssigneeChangeMessageNoAssigneeFooter() throws Exception {
+    Change c = newChange();
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, "Assignee added: " + changeOwner.getName()),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(
+            c,
+            String.format(
+                "Assignee changed from: %s to: %s",
+                changeOwner.getNameEmail(), otherUser.getNameEmail())),
+        getAuthorIdent(otherUser.getAccount()));
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, "Assignee deleted: " + otherUser.getName()),
+        getAuthorIdent(changeOwner.getAccount()));
+    Account reviewer =
+        Account.builder(Account.id(3), TimeUtil.nowTs())
+            .setFullName("Reviewer User")
+            .setPreferredEmail("reviewer@account.com")
+            .build();
+    accountCache.put(reviewer);
+    // Even though account is present in the cache, it won't be used because it does not appear in
+    // the history of this change.
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, "Assignee added: " + reviewer.getName()),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    writeUpdate(
+        RefNames.changeMetaRef(c.getId()),
+        getChangeUpdateBody(c, "Assignee deleted: Gerrit Account"),
+        getAuthorIdent(changeOwner.getAccount()));
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff)
+        .containsExactly(
+            "@@ -6 +6 @@\n"
+                + "-Assignee added: Change Owner\n"
+                + "+Assignee added: <GERRIT_ACCOUNT_1>\n",
+            "@@ -6 +6 @@\n"
+                + "-Assignee changed from: Change Owner <change@owner.com> to: Other Account <other@account.com>\n"
+                + "+Assignee changed from: <GERRIT_ACCOUNT_1> to: <GERRIT_ACCOUNT_2>\n",
+            "@@ -6 +6 @@\n"
+                + "-Assignee deleted: Other Account\n"
+                + "+Assignee deleted: <GERRIT_ACCOUNT_2>\n",
+            "@@ -6 +6 @@\n" + "-Assignee added: Reviewer User\n" + "+Assignee was added.\n");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  @Test
+  public void singleRunFixesAll() throws Exception {
+    Change c = newChange();
+    Timestamp when = TimeUtil.nowTs();
+    String assigneeIdentToFix = getAccountIdentToFix(otherUser.getAccount());
+    PersonIdent authorIdentToFix =
+        new PersonIdent(
+            changeOwner.getName(),
+            changeNoteUtil.getAccountIdAsEmailAddress(changeOwner.getAccountId()),
+            when,
+            serverIdent.getTimeZone());
+
+    RevCommit invalidUpdateCommit =
+        writeUpdate(
+            RefNames.changeMetaRef(c.getId()),
+            getChangeUpdateBody(
+                c,
+                "Assignee added: Other Account <other@account.com>",
+                "Assignee: " + assigneeIdentToFix),
+            authorIdentToFix);
+    Ref metaRefBeforeRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+
+    ImmutableList<RevCommit> commitsBeforeRewrite = logMetaRef(repo, metaRefBeforeRewrite);
+
+    int invalidCommitIndex = commitsBeforeRewrite.indexOf(invalidUpdateCommit);
+    ChangeNotes notesBeforeRewrite = newNotes(c);
+
+    RunOptions options = new RunOptions();
+    options.dryRun = false;
+    BackfillResult result = rewriter.backfillProject(project, repo, options);
+    assertThat(result.fixedRefDiff.keySet()).containsExactly(RefNames.changeMetaRef(c.getId()));
+
+    ChangeNotes notesAfterRewrite = newNotes(c);
+    assertThat(notesBeforeRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
+    assertThat(notesAfterRewrite.getChange().getAssignee()).isEqualTo(otherUserId);
+
+    Ref metaRefAfterRewrite = repo.exactRef(RefNames.changeMetaRef(c.getId()));
+    assertThat(metaRefAfterRewrite.getObjectId()).isNotEqualTo(metaRefBeforeRewrite.getObjectId());
+
+    ImmutableList<RevCommit> commitsAfterRewrite = logMetaRef(repo, metaRefAfterRewrite);
+    assertValidCommits(
+        commitsBeforeRewrite, commitsAfterRewrite, ImmutableList.of(invalidCommitIndex));
+    assertFixedCommits(ImmutableList.of(invalidUpdateCommit.getId()), result, c.getId());
+
+    RevCommit fixedUpdateCommit = commitsAfterRewrite.get(invalidCommitIndex);
+    assertThat(invalidUpdateCommit.getAuthorIdent())
+        .isNotEqualTo(fixedUpdateCommit.getAuthorIdent());
+    assertThat(invalidUpdateCommit.getFullMessage()).contains(otherUser.getName());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(changeOwner.getName());
+    assertThat(fixedUpdateCommit.getFullMessage()).doesNotContain(otherUser.getName());
+
+    List<String> commitHistoryDiff = commitHistoryDiff(result, c.getId());
+    assertThat(commitHistoryDiff).hasSize(1);
+    assertThat(commitHistoryDiff.get(0)).contains("-author Change Owner <1@gerrit>");
+    assertThat(commitHistoryDiff.get(0)).contains("+author Gerrit User 1 <1@gerrit>");
+    assertThat(commitHistoryDiff.get(0))
+        .contains(
+            "@@ -6 +6 @@\n"
+                + "-Assignee added: Other Account <other@account.com>\n"
+                + "+Assignee added: <GERRIT_ACCOUNT_2>\n"
+                + "@@ -9 +9 @@\n"
+                + "-Assignee: Other Account <2@gerrit>\n"
+                + "+Assignee: Gerrit User 2 <2@gerrit>");
+    BackfillResult secondRunResult = rewriter.backfillProject(project, repo, options);
+    assertThat(secondRunResult.fixedRefDiff.keySet()).isEmpty();
+    assertThat(secondRunResult.refsFailedToFix).isEmpty();
+  }
+
+  private RevCommit writeUpdate(String metaRef, String body, PersonIdent author) throws Exception {
+    return tr.branch(metaRef).commit().message(body).author(author).committer(serverIdent).create();
+  }
+
+  private String getChangeUpdateBody(Change change, String changeMessage, String... footers) {
+    StringBuilder commitBody = new StringBuilder();
+    commitBody.append("Update patch set ").append(change.currentPatchSetId().get());
+    commitBody.append("\n\n");
+    if (changeMessage != null) {
+      commitBody.append(changeMessage);
+      commitBody.append("\n\n");
+    }
+    commitBody.append("Patch-set: " + change.currentPatchSetId().get());
+    commitBody.append("\n");
+    for (String footer : footers) {
+      commitBody.append(footer);
+      commitBody.append("\n");
+    }
+    return commitBody.toString();
+  }
+
+  private ImmutableList<RevCommit> logMetaRef(Repository repo, Ref metaRef) throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      rw.sort(RevSort.TOPO);
+      rw.sort(RevSort.REVERSE);
+      if (metaRef == null) {
+        return ImmutableList.of();
+      }
+      rw.markStart(rw.parseCommit(metaRef.getObjectId()));
+      return ImmutableList.copyOf(rw);
+    }
+  }
+
+  private void assertValidCommits(
+      ImmutableList<RevCommit> commitsBeforeRewrite,
+      ImmutableList<RevCommit> commitsAfterRewrite,
+      ImmutableList<Integer> invalidCommits) {
+    ImmutableList<RevCommit> validCommitsBeforeRewrite =
+        IntStream.range(0, commitsBeforeRewrite.size())
+            .filter(i -> !invalidCommits.contains(i))
+            .mapToObj(commitsBeforeRewrite::get)
+            .collect(toImmutableList());
+
+    ImmutableList<RevCommit> validCommitsAfterRewrite =
+        IntStream.range(0, commitsAfterRewrite.size())
+            .filter(i -> !invalidCommits.contains(i))
+            .mapToObj(commitsAfterRewrite::get)
+            .collect(toImmutableList());
+
+    assertThat(validCommitsBeforeRewrite).hasSize(validCommitsAfterRewrite.size());
+    for (int i = 0; i < validCommitsAfterRewrite.size(); i++) {
+      RevCommit actual = validCommitsAfterRewrite.get(i);
+      RevCommit expected = validCommitsBeforeRewrite.get(i);
+      assertThat(actual.getAuthorIdent()).isEqualTo(expected.getAuthorIdent());
+      assertThat(actual.getCommitterIdent()).isEqualTo(expected.getCommitterIdent());
+      assertThat(actual.getFullMessage()).isEqualTo(expected.getFullMessage());
+    }
+  }
+
+  private void assertFixedCommits(
+      ImmutableList<ObjectId> expectedFixedCommits, BackfillResult result, Change.Id changeId) {
+    assertThat(
+            result.fixedRefDiff.get(RefNames.changeMetaRef(changeId)).stream()
+                .map(CommitDiff::oldSha1)
+                .collect(toImmutableList()))
+        .containsExactlyElementsIn(expectedFixedCommits);
+  }
+
+  private String getAccountIdentToFix(Account account) {
+    return String.format("%s <%s>", account.getName(), account.id().get() + "@" + serverId);
+  }
+
+  private String getValidIdentAsString(Account account) {
+    return String.format(
+        "%s <%s>",
+        ChangeNoteUtil.getAccountIdAsUsername(account.id()), account.id().get() + "@" + serverId);
+  }
+
+  private ImmutableList<String> changeMessages(ChangeNotes changeNotes) {
+    return changeNotes.getChangeMessages().stream()
+        .map(ChangeMessage::getMessage)
+        .collect(toImmutableList());
+  }
+
+  protected ChangeUpdate newCodeOwnerAddReviewerUpdate(Change c, CurrentUser user)
+      throws Exception {
+    ChangeUpdate update = newUpdate(c, user, true);
+    update.setTag("autogenerated:gerrit:code-owners:addReviewer");
+    return update;
+  }
+
+  private ImmutableList<String> commitHistoryDiff(BackfillResult result, Change.Id changeId) {
+    return result.fixedRefDiff.get(RefNames.changeMetaRef(changeId)).stream()
+        .map(CommitDiff::diff)
+        .collect(toImmutableList());
+  }
+
+  private PersonIdent getAuthorIdent(Account account) {
+    Timestamp when = TimeUtil.nowTs();
+    return changeNoteUtil.newAccountIdIdent(account.id(), when, serverIdent);
+  }
+}
diff --git a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
index 5bf5154..d47afb0 100644
--- a/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
+++ b/javatests/com/google/gerrit/server/patch/DiffOperationsTest.java
@@ -18,7 +18,9 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
+import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.filediff.FileDiffOutput;
 import com.google.gerrit.server.util.time.TimeUtil;
@@ -72,7 +74,7 @@
 
     FileDiffOutput diffOutput =
         diffOperations.getModifiedFileAgainstParent(
-            testProjectName, newCommitId, /* parentNum=*/ null, fileName2, /* whitespace=*/ null);
+            testProjectName, newCommitId, /* parentNum=*/ 0, fileName2, /* whitespace=*/ null);
 
     assertThat(diffOutput.oldCommitId()).isEqualTo(oldCommitId);
     assertThat(diffOutput.newCommitId()).isEqualTo(newCommitId);
@@ -80,15 +82,58 @@
     assertThat(diffOutput.edits()).hasSize(1);
   }
 
-  private ObjectId createCommit(
-      Repository repo, ObjectId parentCommit, ImmutableMap<String, String> fileNameToContent)
-      throws IOException {
-    ObjectId treeId = createTree(repo, fileNameToContent);
-    return createCommitInRepo(repo, treeId, parentCommit);
+  @Test
+  public void diffAgainstAutoMergePersistsAutoMergeInRepo() throws Exception {
+    ObjectId parent1 = createCommit(repo, null, ImmutableMap.of("file_1.txt", "file 1 content"));
+    ObjectId parent2 = createCommit(repo, null, ImmutableMap.of("file_2.txt", "file 2 content"));
+
+    ObjectId merge =
+        createMergeCommit(
+            repo,
+            ImmutableMap.of(
+                "file_1.txt",
+                "file 1 content",
+                "file_2.txt",
+                "file 2 content",
+                "file_3.txt",
+                "file 3 content"),
+            parent1,
+            parent2);
+
+    String autoMergeRef = RefNames.refsCacheAutomerge(merge.name());
+    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNull();
+
+    Map<String, FileDiffOutput> changedFiles =
+        diffOperations.listModifiedFilesAgainstParent(testProjectName, merge, /* parentNum=*/ 0);
+    assertThat(changedFiles.keySet()).containsExactly("/COMMIT_MSG", "/MERGE_LIST", "file_3.txt");
+
+    // Requesting diff against auto-merge had the side effect of updating the auto-merge ref
+    assertThat(repo.getRefDatabase().exactRef(autoMergeRef)).isNotNull();
   }
 
-  private static ObjectId createCommitInRepo(
-      Repository repo, ObjectId treeId, ObjectId parentCommit) throws IOException {
+  private ObjectId createMergeCommit(
+      Repository repo,
+      ImmutableMap<String, String> fileNameToContent,
+      ObjectId parent1,
+      ObjectId parent2)
+      throws IOException {
+    ObjectId treeId = createTree(repo, fileNameToContent);
+    return createCommitInRepo(repo, treeId, parent1, parent2);
+  }
+
+  private ObjectId createCommit(
+      Repository repo,
+      @Nullable ObjectId parentCommit,
+      ImmutableMap<String, String> fileNameToContent)
+      throws IOException {
+    ObjectId treeId = createTree(repo, fileNameToContent);
+    return parentCommit == null
+        ? createCommitInRepo(repo, treeId)
+        : createCommitInRepo(repo, treeId, parentCommit);
+  }
+
+  private static ObjectId createCommitInRepo(Repository repo, ObjectId treeId, ObjectId... parents)
+      throws IOException {
     try (ObjectInserter oi = repo.newObjectInserter()) {
       PersonIdent committer =
           new PersonIdent(new PersonIdent("Foo Bar", "foo.bar@baz.com"), TimeUtil.nowTs());
@@ -97,8 +142,8 @@
       cb.setCommitter(committer);
       cb.setAuthor(committer);
       cb.setMessage("Test commit");
-      if (parentCommit != null) {
-        cb.setParentIds(parentCommit);
+      if (parents != null && parents.length > 0) {
+        cb.setParentIds(parents);
       }
       ObjectId commitId = oi.insert(cb);
       oi.flush();
diff --git a/javatests/com/google/gerrit/server/patch/PatchListTest.java b/javatests/com/google/gerrit/server/patch/PatchListTest.java
deleted file mode 100644
index 182ce49..0000000
--- a/javatests/com/google/gerrit/server/patch/PatchListTest.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2017 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.patch;
-
-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 {
-
-  @Test
-  public void fileOrder() {
-    String[] names = {
-      "zzz",
-      "def/g",
-      "/!xxx",
-      "abc",
-      Patch.MERGE_LIST,
-      "qrx",
-      Patch.COMMIT_MSG,
-      Patch.PATCHSET_LEVEL
-    };
-    String[] want = {
-      Patch.COMMIT_MSG,
-      Patch.MERGE_LIST,
-      Patch.PATCHSET_LEVEL,
-      "/!xxx",
-      "abc",
-      "def/g",
-      "qrx",
-      "zzz",
-    };
-    Arrays.sort(names, 0, names.length, PatchList.FILE_PATH_CMP);
-    assertThat(names).isEqualTo(want);
-  }
-
-  @Test
-  public void fileOrderNoMerge() {
-    String[] names = {
-      "zzz", "def/g", "/!xxx", "abc", "qrx", Patch.COMMIT_MSG,
-    };
-    String[] want = {
-      Patch.COMMIT_MSG, "/!xxx", "abc", "def/g", "qrx", "zzz",
-    };
-
-    Arrays.sort(names, 0, names.length, PatchList.FILE_PATH_CMP);
-    assertThat(names).isEqualTo(want);
-  }
-
-  @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;
-    try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
-        ObjectOutputStream objectStream = new ObjectOutputStream(baos)) {
-      objectStream.writeObject(new PatchListCacheImpl.LargeObjectTombstone());
-      serializedObject = baos.toByteArray();
-      assertThat(serializedObject).isNotNull();
-    }
-    // Deserialize
-    try (InputStream is = new ByteArrayInputStream(serializedObject);
-        ObjectInputStream ois = new ObjectInputStream(is)) {
-      assertThat(ois.readObject()).isInstanceOf(PatchListCacheImpl.LargeObjectTombstone.class);
-    }
-  }
-}
diff --git a/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java b/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java
index 9ec1625..0ba3b56 100644
--- a/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java
+++ b/javatests/com/google/gerrit/server/permissions/SectionSortCacheTest.java
@@ -78,7 +78,7 @@
     // Cache preserves relative order (reference equality) for identical elements
     AccessSection[] expected = {sectionBClone, sectionB, sectionA, sectionAClone, sectionA};
     for (int i = 0; i < sorted.size(); i++) {
-      assert (sorted.get(i) == expected[i]);
+      assertThat(sorted.get(i)).isSameInstanceAs(expected[i]);
     }
   }
 }
diff --git a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
index 1035fe7..5daf68e 100644
--- a/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
+++ b/javatests/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -64,6 +64,7 @@
   @Inject protected AllProjectsName allProjects;
   @Inject private CommitsCollection commits;
   @Inject private ProjectOperations projectOperations;
+  @Inject private AuthRequest.Factory authRequestFactory;
 
   private TestRepository<InMemoryRepository> repo;
   private Project.NameKey project;
@@ -72,7 +73,8 @@
   public void setUp() throws Exception {
     setUpPermissions();
 
-    Account.Id user = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    Account.Id user =
+        accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId();
     testEnvironment.setApiUser(user);
     project = projectOperations.newProject().create();
     repo = new TestRepository<>(repoManager.openRepository(project));
diff --git a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
index aecb5d3..9df59c2 100644
--- a/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
+++ b/javatests/com/google/gerrit/server/project/ProjectConfigTest.java
@@ -32,8 +32,11 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.entities.StoredCommentLinkInfo;
+import com.google.gerrit.entities.SubmitRequirement;
+import com.google.gerrit.entities.SubmitRequirementExpression;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -46,6 +49,7 @@
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -65,7 +69,10 @@
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
+@RunWith(JUnit4.class)
 public class ProjectConfigTest {
   private static final String LABEL_SCORES_CONFIG =
       "  copyAnyScore = "
@@ -110,7 +117,8 @@
   public void setUp() throws Exception {
     sitePaths = new SitePaths(temporaryFolder.newFolder().toPath());
     Files.createDirectories(sitePaths.etc_dir);
-    factory = new ProjectConfig.Factory(sitePaths, ALL_PROJECTS);
+    factory =
+        new ProjectConfig.Factory(ALL_PROJECTS, new FileBasedAllProjectsConfigProvider(sitePaths));
     db = new InMemoryRepository(new DfsRepositoryDescription("repo"));
     tr = new TestRepository<>(db);
   }
@@ -200,6 +208,133 @@
   }
 
   @Test
+  public void readSubmitRequirements() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submit-requirement \"Code-review\"]\n"
+                    + "  description =  At least one Code Review +2\n"
+                    + "  applicableIf =branch(refs/heads/master)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[submit-requirement \"api-review\"]\n"
+                    + "  description =  Additional review required for API modifications\n"
+                    + "  applicableIf =commit_filepath_contains(\\\"/api/.*\\\")\n"
+                    + "  submittableIf =  label(api-review, +2)\n"
+                    + "  overrideIf =  label(build-cop-override, +1)\n"
+                    + "  canOverrideInChildProjects = true\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements)
+        .containsExactly(
+            "Code-review", // Capitalization preserved
+            SubmitRequirement.builder()
+                .setName("Code-review") // Capitalization preserved
+                .setDescription(Optional.of("At least one Code Review +2"))
+                .setApplicabilityExpression(
+                    SubmitRequirementExpression.of("branch(refs/heads/master)"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setOverrideExpression(Optional.empty())
+                .setAllowOverrideInChildProjects(false)
+                .build(),
+            "api-review",
+            SubmitRequirement.builder()
+                .setName("api-review")
+                .setDescription(Optional.of("Additional review required for API modifications"))
+                .setApplicabilityExpression(
+                    SubmitRequirementExpression.of("commit_filepath_contains(\"/api/.*\")"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(api-review, +2)"))
+                .setOverrideExpression(
+                    SubmitRequirementExpression.of("label(build-cop-override, +1)"))
+                .setAllowOverrideInChildProjects(true)
+                .build());
+  }
+
+  @Test
+  public void readSubmitRequirementEmpty() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submit-requirement \"code-review\"]\n"
+                    + "  submittableIf =  label(code-review, +2)\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements)
+        .containsExactly(
+            "code-review",
+            SubmitRequirement.builder()
+                .setName("code-review")
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setAllowOverrideInChildProjects(false)
+                .build());
+  }
+
+  @Test
+  public void readSubmitRequirementsIdentical_WithCapitalizationDifference() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submit-requirement \"code-review\"]\n"
+                    + "  description = At least one Code Review +2\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[submit-requirement \"Code-Review\"]\n"
+                    + "  description = Another code review label\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "  canOverrideInChildProjects = true\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements)
+        .containsExactly(
+            "code-review",
+            SubmitRequirement.builder()
+                .setName("code-review")
+                .setDescription(Optional.of("At least one Code Review +2"))
+                .setSubmittabilityExpression(
+                    SubmitRequirementExpression.create("label(code-review, +2)"))
+                .setAllowOverrideInChildProjects(false)
+                .build());
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Submit requirement 'Code-Review' conflicts with 'code-review'.");
+  }
+
+  @Test
+  public void readSubmitRequirementNoSubmittabilityExpression() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add("groups", group(developers))
+            .add(
+                "project.config",
+                "[submit-requirement \"code-review\"]\n"
+                    + "  applicableIf =label(code-review, +2)\n")
+            .create();
+
+    ProjectConfig cfg = read(rev);
+    Map<String, SubmitRequirement> submitRequirements = cfg.getSubmitRequirementSections();
+    assertThat(submitRequirements).isEmpty();
+    assertThat(cfg.getValidationErrors()).hasSize(1);
+    assertThat(Iterables.getOnlyElement(cfg.getValidationErrors()).getMessage())
+        .isEqualTo(
+            "project.config: Submit requirement 'code-review' does not define a submittability"
+                + " expression.");
+  }
+
+  @Test
   public void readConfigLabelOldStyleWithLeadingSpace() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -734,7 +869,9 @@
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
-            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+            "[commentlink \"bugzilla\"]\n"
+                + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
   }
 
   @Test
@@ -759,7 +896,9 @@
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
-            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+            "[commentlink \"bugzilla\"]\n"
+                + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
   }
 
   @Test
@@ -781,7 +920,9 @@
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
-            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+            "[commentlink \"bugzilla\"]\n"
+                + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
   }
 
   @Test
@@ -806,6 +947,28 @@
   }
 
   @Test
+  public void submitRequirementSectionIsUnsetIfNoSubmitRequirementsAreSet() throws Exception {
+    RevCommit rev =
+        tr.commit()
+            .add(
+                "project.config",
+                "[submit-requirement \"code-review\"]\n"
+                    + "  description =  At least one Code Review +2\n"
+                    + "  applicableIf =branch(refs/heads/master)\n"
+                    + "  submittableIf =  label(code-review, +2)\n"
+                    + "[notify \"name\"]\n"
+                    + "  email = example@example.com\n")
+            .create();
+    update(rev);
+
+    ProjectConfig cfg = read(rev);
+    cfg.getSubmitRequirementSections().clear();
+    rev = commit(cfg);
+    assertThat(text(rev, "project.config"))
+        .isEqualTo("[notify \"name\"]\n\temail = example@example.com\n");
+  }
+
+  @Test
   public void pluginSectionIsUnsetIfAllPluginConfigsAreEmpty() throws Exception {
     RevCommit rev =
         tr.commit()
@@ -824,7 +987,9 @@
     rev = commit(cfg);
     assertThat(text(rev, "project.config"))
         .isEqualTo(
-            "[commentlink \"bugzilla\"]\n\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
+            "[commentlink \"bugzilla\"]\n"
+                + "\tmatch = \"(bug\\\\s+#?)(\\\\d+)\"\n"
+                + "\tlink = http://bugs.example.com/show_bug.cgi?id=$2\n");
   }
 
   @Test
@@ -833,8 +998,11 @@
     Path tmp = Files.createTempFile("gerrit_test_", "_site");
     Files.deleteIfExists(tmp);
     SitePaths sitePaths = new SitePaths(tmp);
+    FileBasedAllProjectsConfigProvider allProjectsConfigProvider =
+        new FileBasedAllProjectsConfigProvider(sitePaths);
     byte[] hashedContents =
-        ProjectCacheImpl.allProjectsFileProjectConfigHash(ALL_PROJECTS, sitePaths);
+        ProjectCacheImpl.allProjectsFileProjectConfigHash(
+            allProjectsConfigProvider.get(ALL_PROJECTS));
     assertThat(hashedContents).isEqualTo(new byte[16]); // Empty/absent config
     FileBasedConfig fileBasedConfig =
         new FileBasedConfig(
@@ -846,7 +1014,9 @@
             FS.DETECTED);
     fileBasedConfig.setString("plugin", "my-plugin", "key", "value");
     fileBasedConfig.save();
-    hashedContents = ProjectCacheImpl.allProjectsFileProjectConfigHash(ALL_PROJECTS, sitePaths);
+    hashedContents =
+        ProjectCacheImpl.allProjectsFileProjectConfigHash(
+            allProjectsConfigProvider.get(ALL_PROJECTS));
     assertThat(hashedContents)
         .isEqualTo(
             new byte[] {
diff --git a/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
new file mode 100644
index 0000000..05eb6e0
--- /dev/null
+++ b/javatests/com/google/gerrit/server/project/SubmitRequirementsAdapterTest.java
@@ -0,0 +1,317 @@
+// 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.project;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.LabelFunction;
+import com.google.gerrit.entities.LabelType;
+import com.google.gerrit.entities.LabelValue;
+import com.google.gerrit.entities.SubmitRecord;
+import com.google.gerrit.entities.SubmitRecord.Label;
+import com.google.gerrit.entities.SubmitRecord.Status;
+import com.google.gerrit.entities.SubmitRequirementExpressionResult;
+import com.google.gerrit.entities.SubmitRequirementResult;
+import java.util.Arrays;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Before;
+import org.junit.Test;
+
+public class SubmitRequirementsAdapterTest {
+  private List<LabelType> labelTypes;
+
+  private static final ObjectId psCommitId = ObjectId.zeroId();
+
+  @Before
+  public void setup() {
+    LabelType codeReview =
+        LabelType.builder(
+                "Code-Review",
+                ImmutableList.of(
+                    LabelValue.create((short) 1, "Looks good to me"),
+                    LabelValue.create((short) 0, "No score"),
+                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+            .setFunction(LabelFunction.MAX_WITH_BLOCK)
+            .build();
+
+    LabelType verified =
+        LabelType.builder(
+                "Verified",
+                ImmutableList.of(
+                    LabelValue.create((short) 1, "Looks good to me"),
+                    LabelValue.create((short) 0, "No score"),
+                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+            .setFunction(LabelFunction.MAX_NO_BLOCK)
+            .build();
+
+    LabelType codeStyle =
+        LabelType.builder(
+                "Code-Style",
+                ImmutableList.of(
+                    LabelValue.create((short) 1, "Looks good to me"),
+                    LabelValue.create((short) 0, "No score"),
+                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+            .setFunction(LabelFunction.ANY_WITH_BLOCK)
+            .build();
+
+    LabelType ignoreSelfApprovalLabel =
+        LabelType.builder(
+                "ISA-Label",
+                ImmutableList.of(
+                    LabelValue.create((short) 1, "Looks good to me"),
+                    LabelValue.create((short) 0, "No score"),
+                    LabelValue.create((short) -1, "I would prefer this is not merged as is")))
+            .setFunction(LabelFunction.MAX_WITH_BLOCK)
+            .setIgnoreSelfApproval(true)
+            .build();
+
+    labelTypes = Arrays.asList(codeReview, verified, codeStyle, ignoreSelfApprovalLabel);
+  }
+
+  @Test
+  public void defaultSubmitRule_withLabelsAllPass() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~DefaultSubmitRule",
+            Status.OK,
+            Arrays.asList(
+                createLabel("Code-Review", Label.Status.OK),
+                createLabel("Verified", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(2);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+    assertResult(
+        requirements.get(1),
+        /* reqName= */ "Verified",
+        /* submitExpression= */ "label:Verified=MAX",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void defaultSubmitRule_withLabelsAllNeed() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~DefaultSubmitRule",
+            Status.OK,
+            Arrays.asList(
+                createLabel("Code-Review", Label.Status.NEED),
+                createLabel("Verified", Label.Status.NEED)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(2);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "Code-Review",
+        /* submitExpression= */ "label:Code-Review=MAX -label:Code-Review=MIN",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+    assertResult(
+        requirements.get(1),
+        /* reqName= */ "Verified",
+        /* submitExpression= */ "label:Verified=MAX",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void defaultSubmitRule_withLabelStatusNeed_labelHasIgnoreSelfApproval() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~DefaultSubmitRule",
+            Status.NOT_READY,
+            Arrays.asList(createLabel("ISA-Label", Label.Status.NEED)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "ISA-Label",
+        /* submitExpression= */ "label:ISA-Label=MAX,user=non_uploader -label:ISA-Label=MIN",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void defaultSubmitRule_withLabelStatusOk_labelHasIgnoreSelfApproval() throws Exception {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~DefaultSubmitRule",
+            Status.OK,
+            Arrays.asList(createLabel("ISA-Label", Label.Status.OK)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "ISA-Label",
+        /* submitExpression= */ "label:ISA-Label=MAX,user=non_uploader -label:ISA-Label=MIN",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void customSubmitRule_noLabels_withStatusOk() {
+    SubmitRecord submitRecord =
+        createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, Arrays.asList());
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "gerrit~IgnoreSelfApprovalRule",
+        /* submitExpression= */ "rule:gerrit~IgnoreSelfApprovalRule",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void customSubmitRule_nullLabels_withStatusOk() {
+    SubmitRecord submitRecord =
+        createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.OK, /* labels= */ null);
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "gerrit~IgnoreSelfApprovalRule",
+        /* submitExpression= */ "rule:gerrit~IgnoreSelfApprovalRule",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+  }
+
+  @Test
+  public void customSubmitRule_noLabels_withStatusNotReady() {
+    SubmitRecord submitRecord =
+        createSubmitRecord("gerrit~IgnoreSelfApprovalRule", Status.NOT_READY, Arrays.asList());
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(1);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "gerrit~IgnoreSelfApprovalRule",
+        /* submitExpression= */ "rule:gerrit~IgnoreSelfApprovalRule",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void customSubmitRule_withLabels() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~PrologRule",
+            Status.NOT_READY,
+            Arrays.asList(
+                createLabel("custom-label-1", Label.Status.NEED),
+                createLabel("custom-label-2", Label.Status.REJECT)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(2);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "custom-label-1",
+        /* submitExpression= */ "label:custom-label-1=gerrit~PrologRule",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+    assertResult(
+        requirements.get(1),
+        /* reqName= */ "custom-label-2",
+        /* submitExpression= */ "label:custom-label-2=gerrit~PrologRule",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  @Test
+  public void customSubmitRule_withMixOfPassingAndFailingLabels() {
+    SubmitRecord submitRecord =
+        createSubmitRecord(
+            "gerrit~PrologRule",
+            Status.NOT_READY,
+            Arrays.asList(
+                createLabel("custom-label-1", Label.Status.OK),
+                createLabel("custom-label-2", Label.Status.REJECT)));
+
+    List<SubmitRequirementResult> requirements =
+        SubmitRequirementsAdapter.createResult(submitRecord, labelTypes, psCommitId);
+
+    assertThat(requirements).hasSize(2);
+    assertResult(
+        requirements.get(0),
+        /* reqName= */ "custom-label-1",
+        /* submitExpression= */ "label:custom-label-1=gerrit~PrologRule",
+        SubmitRequirementResult.Status.SATISFIED,
+        SubmitRequirementExpressionResult.Status.PASS);
+    assertResult(
+        requirements.get(1),
+        /* reqName= */ "custom-label-2",
+        /* submitExpression= */ "label:custom-label-2=gerrit~PrologRule",
+        SubmitRequirementResult.Status.UNSATISFIED,
+        SubmitRequirementExpressionResult.Status.FAIL);
+  }
+
+  private void assertResult(
+      SubmitRequirementResult r,
+      String reqName,
+      String submitExpression,
+      SubmitRequirementResult.Status status,
+      SubmitRequirementExpressionResult.Status expressionStatus) {
+    assertThat(r.submitRequirement().name()).isEqualTo(reqName);
+    assertThat(r.submitRequirement().submittabilityExpression().expressionString())
+        .isEqualTo(submitExpression);
+    assertThat(r.status()).isEqualTo(status);
+    assertThat(r.submittabilityExpressionResult().status()).isEqualTo(expressionStatus);
+  }
+
+  private SubmitRecord createSubmitRecord(
+      String ruleName, SubmitRecord.Status status, @Nullable List<Label> labels) {
+    SubmitRecord record = new SubmitRecord();
+    record.ruleName = ruleName;
+    record.status = status;
+    record.labels = labels;
+    return record;
+  }
+
+  private Label createLabel(String name, Label.Status status) {
+    Label label = new Label();
+    label.label = name;
+    label.status = status;
+    return label;
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index f5c9628..16f7199 100644
--- a/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/javatests/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -58,13 +58,14 @@
 import com.google.gerrit.server.ServerInitiated;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.AccountDelta;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.InternalAccountUpdate;
 import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdKeyFactory;
 import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
@@ -139,6 +140,10 @@
 
   @Inject protected ExternalIds externalIds;
 
+  @Inject private ExternalIdKeyFactory externalIdKeyFactory;
+
+  @Inject protected AuthRequest.Factory authRequestFactory;
+
   protected LifecycleManager lifecycle;
   protected Injector injector;
   protected AccountInfo currentUserInfo;
@@ -146,6 +151,8 @@
 
   protected abstract Injector createInjector();
 
+  protected void validateAssumptions() {}
+
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
@@ -155,6 +162,7 @@
     lifecycle.start();
     initAfterLifecycleStart();
     setUpDatabase();
+    validateAssumptions();
   }
 
   @After
@@ -604,7 +612,6 @@
   @Test
   public void reindex() throws Exception {
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
-
     // update account without reindex so that account index is stale
     Account.Id accountId = Account.id(user1._accountId);
     String newName = "Test User";
@@ -615,13 +622,14 @@
       md.getCommitBuilder().setCommitter(ident);
       new AccountConfig(accountId, allUsers, repo)
           .load()
-          .setAccountUpdate(InternalAccountUpdate.builder().setFullName(newName).build())
+          .setAccountDelta(AccountDelta.builder().setFullName(newName).build())
           .commit(md);
     }
-
-    assertQuery("name:" + quote(user1.name), user1);
-    assertQuery("name:" + quote(newName));
-
+    // Querying for the account here will not result in a stale document because
+    // we load AccountStates from the cache after reading documents from the index
+    // which means we always read fresh data when matching.
+    //
+    // Reindex document
     gApi.accounts().id(user1.username).index();
     assertQuery("name:" + quote(user1.name));
     assertQuery("name:" + quote(newName), user1);
@@ -656,7 +664,7 @@
     List<AccountExternalIdInfo> externalIdInfos = gApi.accounts().self().getExternalIds();
     List<ByteArrayWrapper> blobs = new ArrayList<>();
     for (AccountExternalIdInfo info : externalIdInfos) {
-      Optional<ExternalId> extId = externalIds.get(ExternalId.Key.parse(info.identity));
+      Optional<ExternalId> extId = externalIds.get(externalIdKeyFactory.parse(info.identity));
       assertThat(extId).isPresent();
       blobs.add(new ByteArrayWrapper(extId.get().toByteArray()));
     }
@@ -769,9 +777,10 @@
   private Account.Id createAccount(String username, String fullName, String email, boolean active)
       throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      Account.Id id =
+          accountManager.authenticate(authRequestFactory.createForUser(username)).getAccountId();
       if (email != null) {
-        accountManager.link(id, AuthRequest.forEmail(email));
+        accountManager.link(id, authRequestFactory.createForEmail(email));
       }
       accountsUpdate
           .get()
@@ -788,7 +797,7 @@
   private void addEmails(AccountInfo account, String... emails) throws Exception {
     Account.Id id = Account.id(account._accountId);
     for (String email : emails) {
-      accountManager.link(id, AuthRequest.forEmail(email));
+      accountManager.link(id, authRequestFactory.createForEmail(email));
     }
     accountIndexer.index(id);
   }
diff --git a/javatests/com/google/gerrit/server/query/account/BUILD b/javatests/com/google/gerrit/server/query/account/BUILD
index 5c910a0..4ae2039 100644
--- a/javatests/com/google/gerrit/server/query/account/BUILD
+++ b/javatests/com/google/gerrit/server/query/account/BUILD
@@ -32,8 +32,7 @@
     name = "lucene_query_test",
     size = "large",
     srcs = glob(
-        ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST,
+        ["LuceneQueryAccountsTest.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
@@ -44,3 +43,21 @@
         "//lib/guice",
     ],
 )
+
+junit_tests(
+    name = "fake_query_test",
+    size = "large",
+    srcs = glob(
+        ["FakeQueryAccountsTest.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/entities",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/guice",
+        "@truth//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
new file mode 100644
index 0000000..31d256e
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/account/FakeQueryAccountsTest.java
@@ -0,0 +1,50 @@
+// 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.account;
+
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class FakeQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 99ccd31..9acce8f 100644
--- a/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/javatests/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 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.extensions.client.ListChangesOption.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
@@ -41,6 +42,9 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
+import com.google.gerrit.acceptance.ExtensionRegistry;
+import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
+import com.google.gerrit.acceptance.FakeSubmitRule;
 import com.google.gerrit.acceptance.config.GerritConfig;
 import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
 import com.google.gerrit.common.Nullable;
@@ -58,7 +62,6 @@
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.entities.RefNames;
 import com.google.gerrit.extensions.api.GerritApi;
-import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.AttentionSetInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
@@ -67,7 +70,7 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
-import com.google.gerrit.extensions.api.changes.StarsInput;
+import com.google.gerrit.extensions.api.changes.ReviewerInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
@@ -102,7 +105,7 @@
 import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.VersionedAccountQueries;
-import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdFactory;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.NotifyResolver;
@@ -136,7 +139,6 @@
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
@@ -173,6 +175,7 @@
   @Inject protected IdentifiedUser.GenericFactory userFactory;
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
+  @Inject protected ExtensionRegistry extensionRegistry;
   @Inject protected IndexConfig indexConfig;
   @Inject protected InMemoryRepositoryManager repoManager;
   @Inject protected Provider<AnonymousUser> anonymousUserProvider;
@@ -189,6 +192,8 @@
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
+  @Inject protected AuthRequest.Factory authRequestFactory;
+  @Inject protected ExternalIdFactory externalIdFactory;
 
   @Inject private ProjectConfig.Factory projectConfigFactory;
   @Inject private ProjectOperations projectOperations;
@@ -223,14 +228,16 @@
   protected void setUpDatabase() throws Exception {
     schemaCreator.create();
 
-    userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    userId = accountManager.authenticate(authRequestFactory.createForUser("user")).getAccountId();
     String email = "user@example.com";
     accountsUpdate
         .get()
         .update(
             "Add Email",
             userId,
-            u -> u.addExternalId(ExternalId.createEmail(userId, email)).setPreferredEmail(email));
+            u ->
+                u.addExternalId(externalIdFactory.createEmail(userId, email))
+                    .setPreferredEmail(email));
     resetUser();
   }
 
@@ -414,7 +421,7 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
 
     // No private changes.
@@ -582,7 +589,7 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
 
     assertQuery("is:owner", change1);
@@ -594,6 +601,29 @@
   }
 
   @Test
+  public void byUploader() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    assertQuery("is:uploader", change1);
+    assertQuery("uploader:" + userId.get(), change1);
+    change1 = newPatchSet(repo, change1, user2CurrentUser);
+    // Uploader has changed
+    assertQuery("uploader:" + userId.get());
+    assertQuery("uploader:" + user2.get(), change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:uploader", change1); // self (user2)
+
+    String nameEmail = user2CurrentUser.asIdentifiedUser().getNameEmail();
+    assertQuery("uploader: \"" + nameEmail + "\"", change1);
+  }
+
+  @Test
   public void byAuthorExact() throws Exception {
     assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
     byAuthorOrCommitterExact("author:");
@@ -627,7 +657,7 @@
     Change change1 = createChange(repo, johnDoe);
     Change change2 = createChange(repo, john);
     Change change3 = createChange(repo, doeSmith);
-    Change change4 = createChange(repo, selfName);
+    createChange(repo, selfName);
 
     // Only email address.
     assertQuery(searchOperator + "john.doe@example.com", change1);
@@ -696,7 +726,7 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo), userId);
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
     Change change3 = insert(repo, newChange(repo), user2);
     gApi.changes().id(change3.getId().get()).current().review(ReviewInput.approve());
@@ -709,6 +739,20 @@
   }
 
   @Test
+  public void byUploaderIn() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.UPLOADER)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    assertQuery("uploaderin:Administrators", change1);
+
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    CurrentUser user2CurrentUser = userFactory.create(user2);
+    newPatchSet(repo, change1, user2CurrentUser);
+    assertQuery("uploaderin:Administrators");
+  }
+
+  @Test
   public void byProject() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     TestRepository<Repo> repo2 = createProject("repo2");
@@ -722,6 +766,23 @@
   }
 
   @Test
+  public void byProjectWithHidden() throws Exception {
+    TestRepository<Repo> hiddenProject = createProject("hiddenProject");
+    insert(hiddenProject, newChange(hiddenProject));
+    projectOperations
+        .project(Project.nameKey("hiddenProject"))
+        .forUpdate()
+        .add(block(Permission.READ).ref("refs/*").group(REGISTERED_USERS))
+        .update();
+
+    TestRepository<Repo> visibleProject = createProject("visibleProject");
+    Change visibleChange = insert(visibleProject, newChange(visibleProject));
+    assertQuery("project:visibleProject", visibleChange);
+    assertQuery("project:hiddenProject");
+    assertQuery("project:visibleProject OR project:hiddenProject", visibleChange);
+  }
+
+  @Test
   public void byParentOf() throws Exception {
     TestRepository<Repo> repo1 = createProject("repo1");
     RevCommit commit1 = repo1.parseBody(repo1.commit().message("message").create());
@@ -933,6 +994,21 @@
   }
 
   @Test
+  public void fullTextMultipleTerms() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    RevCommit commit1 = repo.parseBody(repo.commit().message("Signed-off: owner").create());
+    Change change1 = insert(repo, newChangeForCommit(repo, commit1));
+    RevCommit commit2 = repo.parseBody(repo.commit().message("Signed by owner").create());
+    Change change2 = insert(repo, newChangeForCommit(repo, commit2));
+    RevCommit commit3 = repo.parseBody(repo.commit().message("This change is off").create());
+    Change change3 = insert(repo, newChangeForCommit(repo, commit3));
+
+    assertQuery("message:\"Signed-off: owner\"", change1);
+    assertQuery("message:\"Signed\"", change2, change1);
+    assertQuery("message:\"off\"", change3, change1);
+  }
+
+  @Test
   public void byMessageMixedCase() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("Hello gerrit").create());
@@ -954,7 +1030,7 @@
 
   @Test
   public void byLabel() throws Exception {
-    accountManager.authenticate(AuthRequest.forUser("anotheruser"));
+    accountManager.authenticate(authRequestFactory.createForUser("anotheruser"));
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
     ChangeInserter ins2 = newChange(repo);
@@ -991,6 +1067,7 @@
     changes.put(-1, reviewMinus1Change);
     changes.put(-2, reviewMinus2Change);
 
+    assertQuery("label:Code-Review=MIN", reviewMinus2Change);
     assertQuery("label:Code-Review=-2", reviewMinus2Change);
     assertQuery("label:Code-Review-2", reviewMinus2Change);
     assertQuery("label:Code-Review=-1", reviewMinus1Change);
@@ -1002,6 +1079,13 @@
     assertQuery("label:Code-Review=+2", reviewPlus2Change);
     assertQuery("label:Code-Review=2", reviewPlus2Change);
     assertQuery("label:Code-Review+2", reviewPlus2Change);
+    assertQuery("label:Code-Review=MAX", reviewPlus2Change);
+    assertQuery(
+        "label:Code-Review=ANY",
+        reviewPlus2Change,
+        reviewPlus1Change,
+        reviewMinus1Change,
+        reviewMinus2Change);
 
     assertQuery("label:Code-Review>-3", codeReviewInRange(changes, -2, 2));
     assertQuery("label:Code-Review>=-2", codeReviewInRange(changes, -2, 2));
@@ -1119,6 +1203,26 @@
     assertQuery("label:Code-Review=+1,owner");
   }
 
+  @Test
+  public void byLabelNonUploader() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    ChangeInserter ins = newChange(repo);
+    Account.Id user1 = createAccount("user1");
+
+    // create a change with "user"
+    Change reviewPlus1Change = insert(repo, ins);
+
+    // add a +1 vote with "user". Query doesn't match since voter is the uploader.
+    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    assertQuery("label:Code-Review=+1,user=non_uploader");
+
+    // add a +1 vote with "user1". Query will match since voter is a non-uploader.
+    requestContext.setContext(newRequestContext(user1));
+    gApi.changes().id(reviewPlus1Change.getId().get()).current().review(ReviewInput.recommend());
+    assertQuery("label:Code-Review=+1,user=non_uploader", reviewPlus1Change);
+    assertQuery("label:Code-Review=+1,non_uploader", reviewPlus1Change);
+  }
+
   private Change[] codeReviewInRange(Map<Integer, Change> changes, int start, int end) {
     int size = 0;
     Change[] range = new Change[end - start + 1];
@@ -1140,7 +1244,7 @@
   }
 
   private Account.Id createAccount(String name) throws Exception {
-    return accountManager.authenticate(AuthRequest.forUser(name)).getAccountId();
+    return accountManager.authenticate(authRequestFactory.createForUser(name)).getAccountId();
   }
 
   @Test
@@ -1300,7 +1404,7 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo), userId);
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
       insert(repo, newChange(repo), user2);
     }
@@ -1313,7 +1417,7 @@
   public void filterOutAllResults() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     for (int i = 0; i < 5; i++) {
       insert(repo, newChange(repo), user2);
     }
@@ -1495,6 +1599,7 @@
         insert(repo, newChangeWithFiles(repo, "documentation/training/slides/README.txt"));
     Change change4 = insert(repo, newChangeWithFiles(repo, "a.txt"));
     Change change5 = insert(repo, newChangeWithFiles(repo, "a/b/c/d/e/foo.txt"));
+    Change change6 = insert(repo, newChangeWithFiles(repo, "all/caps/DIRECTORY/file.txt"));
 
     // matching by directory prefix works
     assertQuery("directory:src", change2, change1);
@@ -1510,6 +1615,7 @@
 
     // case doesn't matter
     assertQuery("directory:Documentation/TrAiNiNg/SLIDES", change3);
+    assertQuery("directory:all/caps/directory", change6);
 
     // leading and trailing '/' doesn't matter
     assertQuery("directory:/documentation/training/slides", change3);
@@ -1521,8 +1627,8 @@
     assertQuery("directory:documentation/training/slides/README.txt");
 
     // root directory matches all changes
-    assertQuery("directory:/", change5, change4, change3, change2, change1);
-    assertQuery("directory:\"\"", change5, change4, change3, change2, change1);
+    assertQuery("directory:/", change6, change5, change4, change3, change2, change1);
+    assertQuery("directory:\"\"", change6, change5, change4, change3, change2, change1);
     assertFailingQuery("directory:");
 
     // matching single directory segments works
@@ -1657,6 +1763,8 @@
       assertQuery(predicate + "\"2009-10-01 21:02:00\"", change1);
       assertQuery(predicate + "2009-10-01", change1);
       assertQuery(predicate + "2009-10-03", change2, change1);
+      assertQuery(predicate + "\"2009-09-30 21:00:00 -0000\"", change1);
+      assertQuery(predicate + "\"2009-10-02 03:00:00 -0000\"", change2, change1);
     }
 
     // Same test as above, but using filter code path.
@@ -1673,6 +1781,12 @@
       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);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 21:00:00 -0000\""), change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-02 03:00:00 -0000\""),
+          change2,
+          change1);
     }
   }
 
@@ -1694,6 +1808,8 @@
       assertQuery(predicate + "\"2009-10-01 20:59:59 -0000\"", change2);
       assertQuery(predicate + "2009-10-01", change2);
       assertQuery(predicate + "2009-09-30", change2, change1);
+      assertQuery(predicate + "\"2009-09-30 21:00:00 -0000\"", change2, change1);
+      assertQuery(predicate + "\"2009-10-02 03:00:00 -0000\"", change2);
     }
 
     // Same test as above, but using filter code path.
@@ -1705,6 +1821,12 @@
           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);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-09-30 21:00:00 -0000\""),
+          change2,
+          change1);
+      assertQuery(
+          makeIndexedPredicateFilterQuery(predicate + "\"2009-10-02 03:00:00 -0000\""), change2);
     }
   }
 
@@ -1904,7 +2026,7 @@
     assertQuery("-added:<=4");
 
     assertQuery("added:3", change1);
-    assertQuery("-(added:<3 OR added>3)", change1);
+    assertQuery("-(added:<3 OR added:>3)", change1);
 
     assertQuery("added:>2", change1);
     assertQuery("-added:<=2", change1);
@@ -1922,7 +2044,7 @@
     assertQuery("-deleted:<=3");
 
     assertQuery("deleted:2", change2);
-    assertQuery("-(deleted:<2 OR deleted>2)", change2);
+    assertQuery("-(deleted:<2 OR deleted:>2)", change2);
 
     assertQuery("deleted:>1", change2);
     assertQuery("-deleted:<=1", change2);
@@ -1950,16 +2072,17 @@
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
-    HashtagsInput in = new HashtagsInput();
-    in.add = ImmutableSet.of("foo");
-    gApi.changes().id(change1.getId().get()).setHashtags(in);
-
-    in.add = ImmutableSet.of("foo", "bar", "a tag", "ACamelCaseTag");
-    gApi.changes().id(change2.getId().get()).setHashtags(in);
-
+    addHashtags(change1.getId(), "foo", "aaa-bbb-ccc");
+    addHashtags(change2.getId(), "foo", "bar", "a tag", "ACamelCaseTag");
     return ImmutableList.of(change1, change2);
   }
 
+  private void addHashtags(Change.Id changeId, String... hashtags) throws Exception {
+    HashtagsInput in = new HashtagsInput();
+    in.add = ImmutableSet.copyOf(hashtags);
+    gApi.changes().id(changeId.get()).setHashtags(in);
+  }
+
   @Test
   public void byHashtag() throws Exception {
     List<Change> changes = setUpHashtagChanges();
@@ -1975,6 +2098,31 @@
   }
 
   @Test
+  public void byHashtagFullText() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.FUZZY_HASHTAG)).isTrue();
+    List<Change> changes = setUpHashtagChanges();
+    assertQuery("inhashtag:foo", changes.get(1), changes.get(0));
+    assertQuery("inhashtag:bbb", changes.get(0));
+    assertQuery("inhashtag:tag", changes.get(1));
+  }
+
+  @Test
+  public void byHashtagRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
+    addHashtags(change1.getId(), "feature1");
+    addHashtags(change1.getId(), "trending");
+    addHashtags(change2.getId(), "Cherrypick-feature1");
+    addHashtags(change3.getId(), "feature1-fixup");
+
+    assertQuery("inhashtag:^feature1.*", change3, change1);
+    assertQuery("inhashtag:{^.*feature1$}", change2, change1);
+    assertQuery("inhashtag:^trending.*", change1);
+  }
+
+  @Test
   public void byDefault() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
 
@@ -2012,7 +2160,7 @@
     assertQuery("user@example.com", expected);
     assertQuery("repo", expected);
 
-    assertQuery("Code-Review:+1", change4);
+    assertQuery("Code-Review=+1", change4);
   }
 
   @Test
@@ -2071,11 +2219,11 @@
     Account.Id user3 = createAccount("user3");
 
     // Explicitly authenticate user2 and user3 so that display name gets set
-    AuthRequest authRequest = AuthRequest.forUser("user2");
+    AuthRequest authRequest = authRequestFactory.createForUser("user2");
     authRequest.setDisplayName("Another User");
     authRequest.setEmailAddress("user2@example.com");
     accountManager.authenticate(authRequest);
-    authRequest = AuthRequest.forUser("user3");
+    authRequest = authRequestFactory.createForUser("user3");
     authRequest.setDisplayName("Another User");
     authRequest.setEmailAddress("user3@example.com");
     accountManager.authenticate(authRequest);
@@ -2146,7 +2294,10 @@
     Change change2 = insert(repo, newChange(repo));
 
     int user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
+        accountManager
+            .authenticate(authRequestFactory.createForUser("anotheruser"))
+            .getAccountId()
+            .get();
 
     ReviewInput input = new ReviewInput();
     input.message = "toplevel";
@@ -2165,7 +2316,49 @@
   }
 
   @Test
-  public void byDraftBy() throws Exception {
+  public void bySubmitRuleResult() throws Exception {
+    if (getSchemaVersion() < 68) {
+      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
+      return;
+    }
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
+      TestRepository<Repo> repo = createProject("repo");
+      Change change = insert(repo, newChange(repo));
+      assertQuery("rule:gerrit~FakeSubmitRule");
+
+      // FakeSubmitRule returns true if change has one or more hashtags.
+      HashtagsInput hashtag = new HashtagsInput();
+      hashtag.add = ImmutableSet.of("Tag1");
+      gApi.changes().id(change.getId().get()).setHashtags(hashtag);
+      assertQuery("rule:gerrit~FakeSubmitRule", change);
+      assertQuery("rule:gerrit~FakeSubmitRule=OK", change);
+      assertQuery("rule:gerrit~FakeSubmitRule=NOT_READY");
+
+      // The 'gerrit~' prefix can be omitted for core submit rules
+      assertQuery("rule:FakeSubmitRule", change);
+    }
+  }
+
+  @Test
+  public void byNonExistingSubmitRule_returnsEmpty() throws Exception {
+    // Some submit rules could be removed from the gerrit.config but there can be records for
+    // merged changes in NoteDb for these rules. We allow querying for non-existent rules to handle
+    // this case.
+    if (getSchemaVersion() < 68) {
+      assertMissingField(ChangeField.SUBMIT_RULE_RESULT);
+      return;
+    }
+    try (Registration registration =
+        extensionRegistry.newRegistration().add(new FakeSubmitRule())) {
+      TestRepository<Repo> repo = createProject("repo");
+      insert(repo, newChange(repo));
+      assertQuery("rule:non-existent-rule");
+    }
+  }
+
+  @Test
+  public void byHasDraft() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -2184,16 +2377,17 @@
     in.path = Patch.COMMIT_MSG;
     gApi.changes().id(change2.getId().get()).current().createDraft(in);
 
-    int user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
     assertQuery("has:draft", change2, change1);
-    assertQuery("draftby:" + userId.get(), change2, change1);
-    assertQuery("draftby:" + user2);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:draft");
   }
 
   @Test
-  public void byDraftByExcludesZombieDrafts() throws Exception {
+  public void byHasDraftExcludesZombieDrafts() throws Exception {
     Project.NameKey project = Project.nameKey("repo");
     TestRepository<Repo> repo = createProject(project.get());
     Change change = insert(repo, newChange(repo));
@@ -2205,7 +2399,7 @@
     in.path = Patch.COMMIT_MSG;
     gApi.changes().id(id.get()).current().createDraft(in);
 
-    assertQuery("draftby:" + userId, change);
+    assertQuery("has:draft", change);
     assertQuery("commentby:" + userId);
 
     try (TestRepository<Repo> allUsers =
@@ -2217,7 +2411,7 @@
       rin.drafts = DraftHandling.PUBLISH_ALL_REVISIONS;
       gApi.changes().id(id.get()).current().review(rin);
 
-      assertQuery("draftby:" + userId);
+      assertQuery("has:draft");
       assertQuery("commentby:" + userId, change);
       assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNull();
 
@@ -2227,7 +2421,7 @@
     }
 
     indexer.index(project, id);
-    assertQuery("draftby:" + userId);
+    assertQuery("has:draft");
   }
 
   @Test
@@ -2240,69 +2434,62 @@
     gApi.accounts().self().starChange(change1.getId().toString());
     gApi.accounts().self().starChange(change2.getId().toString());
 
-    int user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId().get();
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
-    assertQuery("starredby:self", change2, change1);
-    assertQuery("starredby:" + user2);
+    assertQuery("has:star", change2, change1);
+    assertQuery("star:star", change2, change1);
+
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("has:star");
+    assertQuery("star:star");
   }
 
   @Test
   public void byStar() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
+    Change change1 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    Change change4 = insert(repo, newChange(repo));
+    Change change3 = insert(repo, newChange(repo));
 
-    gApi.accounts()
-        .self()
-        .setStars(
-            change1.getId().toString(),
-            new StarsInput(new HashSet<>(Arrays.asList("red", "blue"))));
-    gApi.accounts()
-        .self()
-        .setStars(
-            change2.getId().toString(),
-            new StarsInput(
-                new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
+    requestContext.setContext(newRequestContext(user2));
 
-    gApi.accounts()
-        .self()
-        .setStars(
-            change4.getId().toString(), new StarsInput(new HashSet<>(Arrays.asList("ignore"))));
-
-    // check labeled stars
-    assertQuery("star:red", change1);
-    assertQuery("star:blue", change2, change1);
-    assertQuery("has:stars", change4, change2, change1);
+    gApi.accounts().self().starChange(change1.getId().toString());
+    gApi.changes().id(change3.getChangeId()).ignore(true);
 
     // check default star
-    assertQuery("has:star", change2);
-    assertQuery("is:starred", change2);
-    assertQuery("starredby:self", change2);
-    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
+    assertQuery("has:star", change1);
+    assertQuery("is:starred", change1);
+    assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change1);
 
     // check ignored
-    assertQuery("is:ignored", change4);
-    assertQuery("-is:ignored", change3, change2, change1);
+    assertQuery("is:ignored", change3);
+    assertQuery("-is:ignored", change2, change1);
+    assertQuery("star:ignore", change3);
+    assertQuery("-star:ignore", change2, change1);
   }
 
   @Test
   public void byIgnore() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change1 = insert(repo, newChange(repo), user2);
     Change change2 = insert(repo, newChange(repo), user2);
 
     gApi.changes().id(change1.getId().toString()).ignore(true);
     assertQuery("is:ignored", change1);
     assertQuery("-is:ignored", change2);
+    assertQuery("star:ignore", change1);
+    assertQuery("-star:ignore", change2);
 
     gApi.changes().id(change1.getId().toString()).ignore(false);
     assertQuery("is:ignored");
     assertQuery("-is:ignored", change2, change1);
+    assertQuery("star:ignore");
+    assertQuery("-star:ignore", change2, change1);
   }
 
   @Test
@@ -2311,7 +2498,7 @@
     Change change1 = insert(repo, newChange(repo));
 
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change2 = insert(repo, newChange(repo), user2);
 
     ReviewInput input = new ReviewInput();
@@ -2382,6 +2569,17 @@
   }
 
   @Test
+  public void cherrypick() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.CHERRY_PICK)).isTrue();
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newCherryPickChange(repo, "foo", change1.currentPatchSetId()));
+
+    assertQuery("is:cherrypick", change2);
+    assertQuery("-is:cherrypick", change1);
+  }
+
+  @Test
   public void merge() throws Exception {
     assume().that(getSchema().hasField(ChangeField.MERGE)).isTrue();
     TestRepository<Repo> repo = createProject("repo");
@@ -2418,13 +2616,13 @@
     gApi.changes().id(change1.getId().get()).current().review(new ReviewInput().message("comment"));
 
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     requestContext.setContext(newRequestContext(user2));
 
     gApi.changes().id(change2.getId().get()).current().review(new ReviewInput().message("comment"));
 
     PatchSet.Id ps3_1 = change3.currentPatchSetId();
-    change3 = newPatchSet(repo, change3);
+    change3 = newPatchSet(repo, change3, user);
     assertThat(change3.currentPatchSetId()).isNotEqualTo(ps3_1);
     // Response to previous patch set still counts as reviewing.
     gApi.changes()
@@ -2457,12 +2655,12 @@
     Change change3 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
@@ -2484,7 +2682,7 @@
   public void byReviewed() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Account.Id otherUser =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
 
@@ -2504,26 +2702,29 @@
 
   @Test
   public void reviewerin() throws Exception {
-    Account.Id user1 = accountManager.authenticate(AuthRequest.forUser("user1")).getAccountId();
-    Account.Id user2 = accountManager.authenticate(AuthRequest.forUser("user2")).getAccountId();
-    Account.Id user3 = accountManager.authenticate(AuthRequest.forUser("user3")).getAccountId();
+    Account.Id user1 =
+        accountManager.authenticate(authRequestFactory.createForUser("user1")).getAccountId();
+    Account.Id user2 =
+        accountManager.authenticate(authRequestFactory.createForUser("user2")).getAccountId();
+    Account.Id user3 =
+        accountManager.authenticate(authRequestFactory.createForUser("user3")).getAccountId();
     TestRepository<Repo> repo = createProject("repo");
 
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
     Change change3 = insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = user1.toString();
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = user2.toString();
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = user3.toString();
     rin.state = ReviewerState.CC;
     gApi.changes().id(change3.getId().get()).addReviewer(rin);
@@ -2563,12 +2764,12 @@
     Change change2 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = userByEmailWithName;
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
@@ -2595,12 +2796,12 @@
     Change change2 = insert(repo, newChange(repo));
     insert(repo, newChange(repo));
 
-    AddReviewerInput rin = new AddReviewerInput();
+    ReviewerInput rin = new ReviewerInput();
     rin.reviewer = userByEmail;
     rin.state = ReviewerState.REVIEWER;
     gApi.changes().id(change1.getId().get()).addReviewer(rin);
 
-    rin = new AddReviewerInput();
+    rin = new ReviewerInput();
     rin.reviewer = userByEmail;
     rin.state = ReviewerState.CC;
     gApi.changes().id(change2.getId().get()).addReviewer(rin);
@@ -2783,7 +2984,6 @@
     insert(repo2, ins2);
 
     assertQuery("is:watched");
-    assertQuery("watchedby:self");
 
     List<ProjectWatchInfo> projectsToWatch = new ArrayList<>();
     ProjectWatchInfo pwi = new ProjectWatchInfo();
@@ -2797,7 +2997,6 @@
     resetUser();
 
     assertQuery("is:watched", change1);
-    assertQuery("watchedby:self", change1);
   }
 
   @Test
@@ -2823,19 +3022,6 @@
   }
 
   @Test
-  public void selfAndMe() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo));
-    Change change2 = insert(repo, newChange(repo), userId);
-    insert(repo, newChange(repo));
-    gApi.accounts().self().starChange(change1.getId().toString());
-    gApi.accounts().self().starChange(change2.getId().toString());
-
-    assertQuery("starredby:self", change2, change1);
-    assertQuery("starredby:me", change2, change1);
-  }
-
-  @Test
   public void defaultFieldWithManyUsers() throws Exception {
     for (int i = 0; i < ChangeQueryBuilder.MAX_ACCOUNTS_PER_DEFAULT_FIELD * 2; i++) {
       createAccount("user" + i, "User " + i, "user" + i + "@example.com", true);
@@ -2962,15 +3148,14 @@
         cApi.addReviewer("" + reviewerId);
       }
       for (Account.Id reviewerId : cced) {
-        AddReviewerInput in = new AddReviewerInput();
+        ReviewerInput in = new ReviewerInput();
         in.reviewer = reviewerId.toString();
         in.state = ReviewerState.CC;
         cApi.addReviewer(in);
       }
       for (Account.Id ignorerId : ignoredBy) {
         requestContext.setContext(newRequestContext(ignorerId));
-        StarsInput in = new StarsInput(new HashSet<>(Arrays.asList("ignore")));
-        gApi.accounts().self().setStars("" + id, in);
+        gApi.changes().id(change.getChangeId()).ignore(true);
       }
       DraftInput in = new DraftInput();
       in.path = Patch.COMMIT_MSG;
@@ -3300,6 +3485,7 @@
   @Test
   public void attentionSetIndexed() throws Exception {
     assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS)).isTrue();
+    assume().that(getSchema().hasField(ChangeField.ATTENTION_SET_USERS_COUNT)).isTrue();
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChange(repo));
@@ -3307,8 +3493,18 @@
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "some reason");
     gApi.changes().id(change1.getChangeId()).addToAttentionSet(input);
 
+    assertQuery("is:attention", change1);
+    assertQuery("-is:attention", change2);
+    assertQuery("has:attention", change1);
+    assertQuery("-has:attention", change2);
     assertQuery("attention:" + user.getUserName().get(), change1);
     assertQuery("-attention:" + userId.toString(), change2);
+
+    gApi.changes()
+        .id(change1.getChangeId())
+        .attention(userId.toString())
+        .remove(new AttentionSetInput("removed again"));
+    assertQuery("-is:attention", change1, change2);
   }
 
   @Test
@@ -3320,14 +3516,14 @@
     AttentionSetInput input = new AttentionSetInput(userId.toString(), "reason 1");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
     Account.Id user2Id =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("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);
+    ReviewerInput reviewerInput = new ReviewerInput();
+    reviewerInput.reviewer = user2Id.toString();
+    reviewerInput.state = ReviewerState.CC;
+    gApi.changes().id(change.getChangeId()).addReviewer(reviewerInput);
 
     input = new AttentionSetInput(user2Id.toString(), "reason 2");
     gApi.changes().id(change.getChangeId()).addToAttentionSet(input);
@@ -3372,7 +3568,7 @@
         .isEqualTo("Unknown named destination: foo");
 
     Account.Id anotherUserId =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     String destination1 = "refs/heads/master\trepo1";
     String destination2 = "refs/heads/master\trepo2";
     String destination3 = "refs/heads/master\trepo1\nrefs/heads/master\trepo2";
@@ -3449,7 +3645,7 @@
     Change change2 = insert(repo, newChangeForBranch(repo, "stable"));
 
     Account.Id anotherUserId =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
     String queryListText =
         "query1\tproject:repo\n"
             + "query2\tproject:repo status:open\n"
@@ -3532,8 +3728,34 @@
   }
 
   @Test
+  public void bySubmitRequirement_notAllowed() throws Exception {
+    Exception thrown =
+        assertThrows(
+            QueryParseException.class,
+            () ->
+                queryProcessorProvider
+                    .get()
+                    .query(
+                        new SubmitRequirementPredicate("submit-requirement", "value") {
+                          @Override
+                          public boolean match(ChangeData object) {
+                            return false;
+                          }
+
+                          @Override
+                          public int getCost() {
+                            return 0;
+                          }
+                        }));
+
+    assertThat(thrown)
+        .hasMessageThat()
+        .isEqualTo("Operator 'submit-requirement:value' cannot be used in queries");
+  }
+
+  @Test
   public void selfFailsForAnonymousUser() throws Exception {
-    for (String query : ImmutableList.of("assignee:self", "starredby:self", "is:starred")) {
+    for (String query : ImmutableList.of("assignee:self", "has:star", "is:starred", "star:star")) {
       assertQuery(query);
       RequestContext oldContext = requestContext.setContext(anonymousUserProvider::get);
 
@@ -3551,22 +3773,20 @@
   @Test
   public void selfSucceedsForInactiveAccount() throws Exception {
     Account.Id user2 =
-        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+        accountManager.authenticate(authRequestFactory.createForUser("anotheruser")).getAccountId();
 
     TestRepository<Repo> repo = createProject("repo");
     Change change = insert(repo, newChange(repo));
-    AssigneeInput ain = new AssigneeInput();
-    ain.assignee = user2.toString();
-    gApi.changes().id(change.getId().get()).setAssignee(ain);
+    gApi.changes().id(change.getId().get()).addReviewer(user2.toString());
 
     RequestContext adminContext = requestContext.setContext(newRequestContext(user2));
-    assertQuery("assignee:self", change);
+    assertQuery("reviewer:self", change);
 
     requestContext.setContext(adminContext);
     gApi.accounts().id(user2.get()).setActive(false);
 
     requestContext.setContext(newRequestContext(user2));
-    assertQuery("assignee:self", change);
+    assertQuery("reviewer:self", change);
   }
 
   @Test
@@ -3605,12 +3825,12 @@
   }
 
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, false, false);
+    return newChange(repo, null, null, null, null, null, false, false);
   }
 
   protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
       throws Exception {
-    return newChange(repo, commit, null, null, null, false, false);
+    return newChange(repo, commit, null, null, null, null, false, false);
   }
 
   protected ChangeInserter newChangeWithFiles(TestRepository<Repo> repo, String... paths)
@@ -3624,25 +3844,30 @@
 
   protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
       throws Exception {
-    return newChange(repo, null, branch, null, null, false, false);
+    return newChange(repo, null, branch, null, null, null, false, false);
   }
 
   protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
       throws Exception {
-    return newChange(repo, null, null, status, null, false, false);
+    return newChange(repo, null, null, status, null, null, false, false);
   }
 
   protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
       throws Exception {
-    return newChange(repo, null, null, null, topic, false, false);
+    return newChange(repo, null, null, null, topic, null, false, false);
   }
 
   protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, true, false);
+    return newChange(repo, null, null, null, null, null, true, false);
   }
 
   protected ChangeInserter newChangePrivate(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null, false, true);
+    return newChange(repo, null, null, null, null, null, false, true);
+  }
+
+  protected ChangeInserter newCherryPickChange(
+      TestRepository<Repo> repo, String branch, PatchSet.Id cherryPickOf) throws Exception {
+    return newChange(repo, null, branch, null, null, cherryPickOf, false, true);
   }
 
   protected ChangeInserter newChange(
@@ -3651,6 +3876,7 @@
       @Nullable String branch,
       @Nullable Change.Status status,
       @Nullable String topic,
+      @Nullable PatchSet.Id cherryPickOf,
       boolean workInProgress,
       boolean isPrivate)
       throws Exception {
@@ -3671,7 +3897,8 @@
             .setStatus(status)
             .setTopic(topic)
             .setWorkInProgress(workInProgress)
-            .setPrivate(isPrivate);
+            .setPrivate(isPrivate)
+            .setCherryPickOf(cherryPickOf);
     return ins;
   }
 
@@ -3701,7 +3928,8 @@
     }
   }
 
-  protected Change newPatchSet(TestRepository<Repo> repo, Change c) throws Exception {
+  protected Change newPatchSet(TestRepository<Repo> repo, Change c, CurrentUser user)
+      throws Exception {
     // Add a new file so the patch set is not a trivial rebase, to avoid default
     // Code-Review label copying.
     int n = c.currentPatchSetId().get() + 1;
@@ -3924,9 +4152,10 @@
   private Account.Id createAccount(String username, String fullName, String email, boolean active)
       throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      Account.Id id =
+          accountManager.authenticate(authRequestFactory.createForUser(username)).getAccountId();
       if (email != null) {
-        accountManager.link(id, AuthRequest.forEmail(email));
+        accountManager.link(id, authRequestFactory.createForEmail(email));
       }
       accountsUpdate
           .get()
diff --git a/javatests/com/google/gerrit/server/query/change/BUILD b/javatests/com/google/gerrit/server/query/change/BUILD
index 43b9690..08456d1 100644
--- a/javatests/com/google/gerrit/server/query/change/BUILD
+++ b/javatests/com/google/gerrit/server/query/change/BUILD
@@ -4,6 +4,7 @@
 ABSTRACT_QUERY_TEST = [
     "AbstractQueryChangesTest.java",
     "LuceneQueryChangesTest.java",
+    "FakeQueryChangesTest.java",
 ]
 
 java_library(
@@ -16,6 +17,7 @@
         "//prolog:gerrit-prolog-common",
     ],
     deps = [
+        "//java/com/google/gerrit/acceptance:lib",
         "//java/com/google/gerrit/acceptance/config",
         "//java/com/google/gerrit/acceptance/testsuite/project",
         "//java/com/google/gerrit/common:annotations",
@@ -24,6 +26,7 @@
         "//java/com/google/gerrit/httpd",
         "//java/com/google/gerrit/index",
         "//java/com/google/gerrit/index:query_exception",
+        "//java/com/google/gerrit/index/testing",
         "//java/com/google/gerrit/lifecycle",
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/server/project/testing:project-test-util",
@@ -38,9 +41,11 @@
     ],
 )
 
-LUCENE_QUERY_TEST = [
+QUERY_TEST = [
     "LuceneQueryChangesLatestIndexVersionTest.java",
     "LuceneQueryChangesPreviousIndexVersionTest.java",
+    "FakeQueryChangesLatestIndexVersionTest.java",
+    "FakeQueryChangesPreviousIndexVersionTest.java",
 ]
 
 [junit_tests(
@@ -60,14 +65,14 @@
         "//lib/guice",
         "//lib/truth",
     ],
-) for f in LUCENE_QUERY_TEST]
+) for f in QUERY_TEST]
 
 junit_tests(
     name = "small_tests",
     size = "small",
     srcs = glob(
         ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST + LUCENE_QUERY_TEST,
+        exclude = ABSTRACT_QUERY_TEST + QUERY_TEST,
     ),
     visibility = ["//visibility:public"],
     deps = [
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
new file mode 100644
index 0000000..5496f56
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesLatestIndexVersionTest.java
@@ -0,0 +1,29 @@
+// 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.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.IndexConfig;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex} using the latest schema.
+ */
+public class FakeQueryChangesLatestIndexVersionTest extends FakeQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java
new file mode 100644
index 0000000..1610eca
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesPreviousIndexVersionTest.java
@@ -0,0 +1,39 @@
+// 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.Iterables;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex} using the current schema.
+ */
+public class FakeQueryChangesPreviousIndexVersionTest extends FakeQueryChangesTest {
+  @ConfigSuite.Default
+  public static Config againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    return Iterables.getOnlyElement(
+        IndexVersions.asConfigMap(
+                ChangeSchemaDefinitions.INSTANCE,
+                IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE),
+                "againstIndexVersion",
+                IndexConfig.createForFake())
+            .values());
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
new file mode 100644
index 0000000..1e23420
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/change/FakeQueryChangesTest.java
@@ -0,0 +1,35 @@
+// 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.gerrit.testing.InMemoryModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Test against {@link com.google.gerrit.index.testing.AbstractFakeIndex}. This test might seem
+ * obsolete, but it makes sure that the fake index implementation used in tests gives the same
+ * results as production indices.
+ */
+public abstract class FakeQueryChangesTest extends AbstractQueryChangesTest {
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index d760003..568b5a0 100644
--- a/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/javatests/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -48,8 +48,8 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.db.GroupDelta;
 import com.google.gerrit.server.group.db.GroupsUpdate;
-import com.google.gerrit.server.group.db.InternalGroupUpdate;
 import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
@@ -106,6 +106,8 @@
 
   @Inject protected GroupIndexCollection indexes;
 
+  @Inject protected AuthRequest.Factory authRequestFactory;
+
   @Inject private GroupIndexCollection groupIndexes;
 
   protected LifecycleManager lifecycle;
@@ -115,6 +117,8 @@
 
   protected abstract Injector createInjector();
 
+  protected void validateAssumptions() {}
+
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
@@ -124,6 +128,7 @@
     lifecycle.start();
     initAfterLifecycleStart();
     setUpDatabase();
+    validateAssumptions();
   }
 
   @After
@@ -347,9 +352,8 @@
     // update group in the database so that group index is stale
     String newDescription = "barY";
     AccountGroup.UUID groupUuid = AccountGroup.uuid(group1.id);
-    InternalGroupUpdate groupUpdate =
-        InternalGroupUpdate.builder().setDescription(newDescription).build();
-    groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupUpdate);
+    GroupDelta groupDelta = GroupDelta.builder().setDescription(newDescription).build();
+    groupsUpdateProvider.get().updateGroupInNoteDb(groupUuid, groupDelta);
 
     assertQuery("description:" + group1.description, group1);
     assertQuery("description:" + newDescription);
@@ -395,9 +399,10 @@
   private Account.Id createAccountOutsideRequestContext(
       String username, String fullName, String email, boolean active) throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      Account.Id id =
+          accountManager.authenticate(authRequestFactory.createForUser(username)).getAccountId();
       if (email != null) {
-        accountManager.link(id, AuthRequest.forEmail(email));
+        accountManager.link(id, authRequestFactory.createForEmail(email));
       }
       accountsUpdate
           .get()
diff --git a/javatests/com/google/gerrit/server/query/group/BUILD b/javatests/com/google/gerrit/server/query/group/BUILD
index e14350f..4b74325 100644
--- a/javatests/com/google/gerrit/server/query/group/BUILD
+++ b/javatests/com/google/gerrit/server/query/group/BUILD
@@ -29,8 +29,7 @@
     name = "lucene_query_test",
     size = "large",
     srcs = glob(
-        ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST,
+        ["LuceneQueryGroupsTest.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
@@ -41,3 +40,20 @@
         "//lib/guice",
     ],
 )
+
+junit_tests(
+    name = "fake_query_test",
+    size = "large",
+    srcs = glob(
+        ["FakeQueryGroupsTest.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/guice",
+        "@truth//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
new file mode 100644
index 0000000..e4f228a
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/group/FakeQueryGroupsTest.java
@@ -0,0 +1,49 @@
+// 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.group;
+
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class FakeQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
index dfd7928..60d1655 100644
--- a/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
+++ b/javatests/com/google/gerrit/server/query/project/AbstractQueryProjectsTest.java
@@ -104,6 +104,8 @@
 
   @Inject protected AllUsersName allUsers;
 
+  @Inject protected AuthRequest.Factory authRequestFactory;
+
   protected LifecycleManager lifecycle;
   protected Injector injector;
   protected AccountInfo currentUserInfo;
@@ -111,6 +113,8 @@
 
   protected abstract Injector createInjector();
 
+  protected void validateAssumptions() {}
+
   @Before
   public void setUpInjector() throws Exception {
     lifecycle = new LifecycleManager();
@@ -120,6 +124,7 @@
     lifecycle.start();
     initAfterLifecycleStart();
     setUpDatabase();
+    validateAssumptions();
   }
 
   @After
@@ -306,9 +311,10 @@
   private Account.Id createAccount(String username, String fullName, String email, boolean active)
       throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      Account.Id id =
+          accountManager.authenticate(authRequestFactory.createForUser(username)).getAccountId();
       if (email != null) {
-        accountManager.link(id, AuthRequest.forEmail(email));
+        accountManager.link(id, authRequestFactory.createForEmail(email));
       }
       accountsUpdate
           .get()
diff --git a/javatests/com/google/gerrit/server/query/project/BUILD b/javatests/com/google/gerrit/server/query/project/BUILD
index 984d824..a65306c 100644
--- a/javatests/com/google/gerrit/server/query/project/BUILD
+++ b/javatests/com/google/gerrit/server/query/project/BUILD
@@ -29,8 +29,7 @@
     name = "lucene_query_test",
     size = "large",
     srcs = glob(
-        ["*.java"],
-        exclude = ABSTRACT_QUERY_TEST,
+        ["LuceneQueryProjectsTest.java"],
     ),
     visibility = ["//visibility:public"],
     deps = [
@@ -41,3 +40,21 @@
         "//lib/guice",
     ],
 )
+
+junit_tests(
+    name = "fake_query_test",
+    size = "large",
+    srcs = glob(
+        ["FakeQueryProjectsTest.java"],
+    ),
+    visibility = ["//visibility:public"],
+    deps = [
+        ":abstract_query_tests",
+        "//java/com/google/gerrit/index/project",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
+        "//lib/guice",
+        "@truth//jar",
+    ],
+)
diff --git a/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
new file mode 100644
index 0000000..6fc0568
--- /dev/null
+++ b/javatests/com/google/gerrit/server/query/project/FakeQueryProjectsTest.java
@@ -0,0 +1,51 @@
+// 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.project;
+
+import com.google.gerrit.index.project.ProjectSchemaDefinitions;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.InMemoryModule;
+import com.google.gerrit.testing.IndexConfig;
+import com.google.gerrit.testing.IndexVersions;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.Config;
+
+public class FakeQueryProjectsTest extends AbstractQueryProjectsTest {
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    return IndexConfig.createForFake();
+  }
+
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(
+            com.google.gerrit.index.project.ProjectSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        ProjectSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config fakeConfig = new Config(config);
+    InMemoryModule.setDefaults(fakeConfig);
+    fakeConfig.setString("index", null, "type", "fake");
+    return Guice.createInjector(new InMemoryModule(fakeConfig));
+  }
+}
diff --git a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
index ebb2f38..46f9c5a 100644
--- a/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/CommentPorterTest.java
@@ -22,6 +22,7 @@
 import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.truth.Correspondence;
 import com.google.gerrit.entities.Account;
@@ -35,12 +36,8 @@
 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;
-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.gerrit.server.patch.DiffNotAvailableException;
+import com.google.gerrit.server.patch.DiffOperations;
 import com.google.gerrit.server.restapi.change.CommentPorter.Metrics;
 import com.google.gerrit.truth.NullAwareCorrespondence;
 import java.sql.Timestamp;
@@ -60,7 +57,7 @@
 
   @Rule public final MockitoRule mockito = MockitoJUnit.rule();
 
-  @Mock private PatchListCache patchListCache;
+  @Mock private DiffOperations diffOperations;
   @Mock private CommentsUtil commentsUtil;
 
   private static final CommentPorter.Metrics metrics = new Metrics(new DisabledMetricMaker());
@@ -76,12 +73,13 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
-        .thenThrow(PatchListNotAvailableException.class);
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+        .thenThrow(DiffNotAvailableException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
             changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
@@ -98,11 +96,12 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -120,7 +119,7 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenThrow(IllegalStateException.class);
@@ -140,11 +139,12 @@
     PatchSet patchset2 = createPatchset(PatchSet.id(changeId, 2));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
         .thenThrow(IllegalStateException.class);
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
@@ -165,17 +165,17 @@
     PatchSet patchset3 = createPatchset(PatchSet.id(changeId, 3));
     ChangeNotes changeNotes = mockChangeNotes(project, change, patchset1, patchset2, patchset3);
 
-    CommentPorter commentPorter = new CommentPorter(patchListCache, commentsUtil, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, 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");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    PatchList emptyDiff = getEmptyDiff();
     // Throw an exception on the first diff request but return an actual value on the second.
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
         .thenThrow(IllegalStateException.class)
-        .thenReturn(emptyDiff);
+        .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
             changeNotes, patchset3, ImmutableList.of(comment1, comment2), ImmutableList.of());
@@ -195,13 +195,13 @@
     // 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, metrics);
+    CommentPorter commentPorter = new CommentPorter(diffOperations, commentsUtil, metrics);
     HumanComment comment = createComment(patchset1.id(), "myFile");
     when(commentsUtil.determineCommitId(any(), any(), anyShort()))
         .thenReturn(Optional.of(dummyObjectId));
-    PatchList emptyDiff = getEmptyDiff();
-    when(patchListCache.get(any(PatchListKey.class), any(Project.NameKey.class)))
-        .thenReturn(emptyDiff);
+    when(diffOperations.listModifiedFiles(
+            any(Project.NameKey.class), any(ObjectId.class), any(ObjectId.class)))
+        .thenReturn(ImmutableMap.of());
     ImmutableList<HumanComment> portedComments =
         commentPorter.portComments(
             changeNotes, patchset2, ImmutableList.of(comment), ImmutableList.of());
@@ -260,13 +260,4 @@
   private Correspondence<HumanComment, String> hasFilePath() {
     return NullAwareCorrespondence.transforming(comment -> comment.key.filename, "hasFilePath");
   }
-
-  private PatchList getEmptyDiff() {
-    return new PatchList(
-        dummyObjectId,
-        dummyObjectId,
-        false,
-        ComparisonType.againstOtherPatchSet(),
-        new PatchListEntry[0]);
-  }
 }
diff --git a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
index b3e0c56..44c3cef 100644
--- a/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
+++ b/javatests/com/google/gerrit/server/restapi/change/ListChangeCommentsTest.java
@@ -125,9 +125,8 @@
   private static ChangeMessage newChangeMessage(String id, String message, String ts, String tag) {
     ChangeMessage.Key key = ChangeMessage.key(Change.id(1), id);
     ChangeMessage cm =
-        new ChangeMessage(key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null);
-    cm.setMessage(message);
-    cm.setTag(tag);
+        ChangeMessage.create(
+            key, null, Timestamp.valueOf("2000-01-01 00:00:" + ts), null, message, null, tag);
     return cm;
   }
 
diff --git a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
index da22f76..e0ba666 100644
--- a/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
+++ b/javatests/com/google/gerrit/server/schema/ProjectConfigSchemaUpdateTest.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.FileBasedAllProjectsConfigProvider;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.meta.MetaDataUpdate;
@@ -36,7 +37,10 @@
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
 
+@RunWith(JUnit4.class)
 public class ProjectConfigSchemaUpdateTest {
   private static final String ALL_PROJECTS = "All-The-Projects";
 
@@ -64,8 +68,11 @@
     try (Repository repo = new FileRepository(allProjectsRepoFile)) {
       repo.create(true);
     }
+    FileBasedAllProjectsConfigProvider configProvider =
+        new FileBasedAllProjectsConfigProvider(sitePaths);
 
-    factory = new ProjectConfigSchemaUpdate.Factory(sitePaths, new AllProjectsName(ALL_PROJECTS));
+    factory =
+        new ProjectConfigSchemaUpdate.Factory(new AllProjectsName(ALL_PROJECTS), configProvider);
   }
 
   @Test
diff --git a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
index fc6b412..9cba362 100644
--- a/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
+++ b/javatests/com/google/gerrit/server/schema/SchemaCreatorImplTest.java
@@ -72,7 +72,7 @@
 
   @Test
   public void createSchema_Label_CodeReview() throws Exception {
-    LabelType codeReview = getLabelTypes().byLabel("Code-Review");
+    LabelType codeReview = getLabelTypes().byLabel("Code-Review").get();
     assertThat(codeReview).isNotNull();
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
diff --git a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
index 287a7fe..10599c6 100644
--- a/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/javatests/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -64,6 +64,7 @@
             cfg.setInt("change", null, "maxFiles", 2);
             cfg.setInt("change", null, "maxPatchSets", MAX_PATCH_SETS);
             cfg.setInt("change", null, "maxUpdates", MAX_UPDATES);
+            cfg.setString("index", null, "type", "fake");
             return cfg;
           });
 
diff --git a/javatests/com/google/gerrit/testing/BUILD b/javatests/com/google/gerrit/testing/BUILD
index 5774707..9443b0d 100644
--- a/javatests/com/google/gerrit/testing/BUILD
+++ b/javatests/com/google/gerrit/testing/BUILD
@@ -7,6 +7,7 @@
     deps = [
         "//java/com/google/gerrit/server",
         "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:jgit",
         "//lib/truth",
     ],
 )
diff --git a/javatests/com/google/gerrit/testing/ConfigSuiteTest.java b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
new file mode 100644
index 0000000..1ec30da
--- /dev/null
+++ b/javatests/com/google/gerrit/testing/ConfigSuiteTest.java
@@ -0,0 +1,266 @@
+// 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.testing;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.verify;
+
+import com.google.gerrit.testing.ConfigSuite.AfterConfig;
+import com.google.gerrit.testing.ConfigSuite.BeforeConfig;
+import com.google.gerrit.testing.ConfigSuite.ConfigRule;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runner.Result;
+import org.junit.runner.RunWith;
+import org.junit.runner.notification.RunNotifier;
+import org.junit.runners.model.Statement;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class ConfigSuiteTest {
+  @Mock private ConfigBasedTestListener configBasedTestListener;
+  private RunNotifier notifier;
+
+  @Before
+  public void setUp() {
+    notifier = new RunNotifier();
+    ConfigBasedTest.setConfigBasedTestListener(configBasedTestListener);
+  }
+
+  @After
+  public void tearDown() {
+    ConfigBasedTest.setConfigBasedTestListener(null);
+  }
+
+  @Test
+  public void methodsExecuteInCorrectOrder() throws Exception {
+    InOrder inOrder = Mockito.inOrder(configBasedTestListener);
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).classRuleExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeClassExecuted();
+    // default config
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).configRuleExectued();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeConfigExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(2)).testExecuted(any(), any(), any());
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterConfigExecuted();
+    // first config
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).configRuleExectued();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeConfigExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(2)).testExecuted(any(), any(), any());
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterConfigExecuted();
+    // second config
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).configRuleExectued();
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).beforeConfigExecuted();
+    inOrder.verify(configBasedTestListener, Mockito.times(2)).testExecuted(any(), any(), any());
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterConfigExecuted();
+
+    inOrder.verify(configBasedTestListener, Mockito.times(1)).afterClassExecuted();
+  }
+
+  @Test
+  public void beforeClassExecutedOnce() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(1)).beforeClassExecuted();
+  }
+
+  @Test
+  public void afterClassExecutedOnce() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(1)).afterClassExecuted();
+  }
+
+  @Test
+  public void classRuleExecutedOnlyOnce() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(1)).classRuleExecuted();
+  }
+
+  @Test
+  public void beforeConfigExecutedForEachConfig() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    // default, firstConfig, secondConfig
+    verify(configBasedTestListener, Mockito.times(3)).beforeConfigExecuted();
+  }
+
+  @Test
+  public void afterConfigExecutedForEachConfig() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    // default, firstConfig, secondConfig
+    verify(configBasedTestListener, Mockito.times(3)).afterConfigExecuted();
+  }
+
+  @Test
+  public void configRuleExecutedForEachConfig() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    // default, firstConfig, secondConfig
+    verify(configBasedTestListener, Mockito.times(3)).afterConfigExecuted();
+  }
+
+  @Test
+  public void testsExecuteWithCorrectConfigAndName() throws Exception {
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    verify(configBasedTestListener, Mockito.times(6)).testExecuted(any(), any(), any());
+
+    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test1", "default", null);
+    verify(configBasedTestListener, Mockito.times(1)).testExecuted("test2", "default", null);
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test1", "firstValue", "firstConfig");
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test2", "firstValue", "firstConfig");
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test1", "secondValue", "secondConfig");
+    verify(configBasedTestListener, Mockito.times(1))
+        .testExecuted("test2", "secondValue", "secondConfig");
+  }
+
+  @Test
+  public void testResultWasSuccessful() throws Exception {
+    Result result = new Result();
+    notifier.addListener(result.createListener());
+    new ConfigSuite(ConfigBasedTest.class).run(notifier);
+    assertThat(result.wasSuccessful()).isTrue();
+  }
+
+  @Test
+  public void failedTestResultNotMissed() throws Exception {
+    Result result = new Result();
+    notifier.addListener(result.createListener());
+    new ConfigSuite(FailedConfigBasedTest.class).run(notifier);
+    // 3 fails with 3 different configs
+    assertThat(result.wasSuccessful()).isFalse();
+    assertThat(result.getFailureCount()).isEqualTo(3);
+  }
+
+  interface ConfigBasedTestListener {
+    void beforeClassExecuted();
+
+    void afterClassExecuted();
+
+    void classRuleExecuted();
+
+    void configRuleExectued();
+
+    void testExecuted(String testName, String testValue, String configName);
+
+    void beforeConfigExecuted();
+
+    void afterConfigExecuted();
+  }
+
+  public static class ConfigBasedTest {
+    private static ConfigBasedTestListener listener;
+
+    public static void setConfigBasedTestListener(ConfigBasedTestListener listener) {
+      ConfigBasedTest.listener = listener;
+    }
+
+    @BeforeClass
+    public static void beforeClass() {
+      listener.beforeClassExecuted();
+    }
+
+    @AfterClass
+    public static void afterClass() {
+      listener.afterClassExecuted();
+    }
+
+    @ClassRule
+    public static TestRule classRule =
+        new TestRule() {
+          @Override
+          public Statement apply(Statement statement, Description description) {
+            return new Statement() {
+              @Override
+              public void evaluate() throws Throwable {
+                listener.classRuleExecuted();
+                statement.evaluate();
+              }
+            };
+          }
+        };
+
+    @ConfigRule
+    public static TestRule configRule =
+        new TestRule() {
+          @Override
+          public Statement apply(Statement statement, Description description) {
+            return new Statement() {
+              @Override
+              public void evaluate() throws Throwable {
+                listener.configRuleExectued();
+                statement.evaluate();
+              }
+            };
+          }
+        };
+
+    @BeforeConfig
+    public static void beforeConfig() {
+      listener.beforeConfigExecuted();
+    }
+
+    @AfterConfig
+    public static void afterConfig() {
+      listener.afterConfigExecuted();
+    }
+
+    @ConfigSuite.Config
+    public static Config firstConfig() {
+      Config cfg = new Config();
+      cfg.setString("gerrit", null, "testValue", "firstValue");
+      return cfg;
+    }
+
+    @ConfigSuite.Config
+    public static Config secondConfig() {
+      Config cfg = new Config();
+      cfg.setString("gerrit", null, "testValue", "secondValue");
+      return cfg;
+    }
+
+    @ConfigSuite.Parameter public Config config;
+    @ConfigSuite.Name public String name;
+
+    @Test
+    public void test1() {
+      String testValue = config.getString("gerrit", null, "testValue");
+      listener.testExecuted("test1", testValue != null ? testValue : "default", name);
+    }
+
+    @Test
+    public void test2() {
+      String testValue = config.getString("gerrit", null, "testValue");
+      listener.testExecuted("test2", testValue != null ? testValue : "default", name);
+    }
+  }
+
+  public static class FailedConfigBasedTest extends ConfigBasedTest {
+    @Test
+    public void failedTest() {
+      assertThat(true).isFalse();
+    }
+  }
+}
diff --git a/lib/LICENSE-CC-BY3.0-unported b/lib/LICENSE-CC-BY3.0-unported
deleted file mode 100644
index d2f2550..0000000
--- a/lib/LICENSE-CC-BY3.0-unported
+++ /dev/null
@@ -1,333 +0,0 @@
-link:http://creativecommons.org/licenses/by/3.0/[CC-BY 3.0]
-
-THE WORK (AS DEFINED BELOW) IS PROVIDED UNDER THE TERMS OF THIS
-CREATIVE COMMONS PUBLIC LICENSE ("CCPL" OR "LICENSE").  THE WORK IS
-PROTECTED BY COPYRIGHT AND/OR OTHER APPLICABLE LAW.  ANY USE OF THE
-WORK OTHER THAN AS AUTHORIZED UNDER THIS LICENSE OR COPYRIGHT LAW IS
-PROHIBITED.
-
-BY EXERCISING ANY RIGHTS TO THE WORK PROVIDED HERE, YOU ACCEPT AND
-AGREE TO BE BOUND BY THE TERMS OF THIS LICENSE.  TO THE EXTENT THIS
-LICENSE MAY BE CONSIDERED TO BE A CONTRACT, THE LICENSOR GRANTS YOU
-THE RIGHTS CONTAINED HERE IN CONSIDERATION OF YOUR ACCEPTANCE OF SUCH
-TERMS AND CONDITIONS.
-
-1.  Definitions
-
-  a.  "Adaptation" means a work based upon the Work, or upon the Work
-      and other pre-existing works, such as a translation, adaptation,
-      derivative work, arrangement of music or other alterations of a
-      literary or artistic work, or phonogram or performance and
-      includes cinematographic adaptations or any other form in which
-      the Work may be recast, transformed, or adapted including in any
-      form recognizably derived from the original, except that a work
-      that constitutes a Collection will not be considered an
-      Adaptation for the purpose of this License.  For the avoidance
-      of doubt, where the Work is a musical work, performance or
-      phonogram, the synchronization of the Work in timed-relation
-      with a moving image ("synching") will be considered an
-      Adaptation for the purpose of this License.
-
-  b.  "Collection" means a collection of literary or artistic works,
-      such as encyclopedias and anthologies, or performances,
-      phonograms or broadcasts, or other works or subject matter other
-      than works listed in Section 1(f) below, which, by reason of the
-      selection and arrangement of their contents, constitute
-      intellectual creations, in which the Work is included in its
-      entirety in unmodified form along with one or more other
-      contributions, each constituting separate and independent works
-      in themselves, which together are assembled into a collective
-      whole.  A work that constitutes a Collection will not be
-      considered an Adaptation (as defined above) for the purposes of
-      this License.
-
-  c.  "Distribute" means to make available to the public the original
-      and copies of the Work or Adaptation, as appropriate, through
-      sale or other transfer of ownership.
-
-  d.  "Licensor" means the individual, individuals, entity or entities
-      that offer(s) the Work under the terms of this License.
-
-  e.  "Original Author" means, in the case of a literary or artistic
-      work, the individual, individuals, entity or entities who
-      created the Work or if no individual or entity can be
-      identified, the publisher; and in addition (i) in the case of a
-      performance the actors, singers, musicians, dancers, and other
-      persons who act, sing, deliver, declaim, play in, interpret or
-      otherwise perform literary or artistic works or expressions of
-      folklore; (ii) in the case of a phonogram the producer being the
-      person or legal entity who first fixes the sounds of a
-      performance or other sounds; and, (iii) in the case of
-      broadcasts, the organization that transmits the broadcast.
-
-  f.  "Work" means the literary and/or artistic work offered under the
-      terms of this License including without limitation any
-      production in the literary, scientific and artistic domain,
-      whatever may be the mode or form of its expression including
-      digital form, such as a book, pamphlet and other writing; a
-      lecture, address, sermon or other work of the same nature; a
-      dramatic or dramatico-musical work; a choreographic work or
-      entertainment in dumb show; a musical composition with or
-      without words; a cinematographic work to which are assimilated
-      works expressed by a process analogous to cinematography; a work
-      of drawing, painting, architecture, sculpture, engraving or
-      lithography; a photographic work to which are assimilated works
-      expressed by a process analogous to photography; a work of
-      applied art; an illustration, map, plan, sketch or
-      three-dimensional work relative to geography, topography,
-      architecture or science; a performance; a broadcast; a
-      phonogram; a compilation of data to the extent it is protected
-      as a copyrightable work; or a work performed by a variety or
-      circus performer to the extent it is not otherwise considered a
-      literary or artistic work.
-
-  g.  "You" means an individual or entity exercising rights under this
-      License who has not previously violated the terms of this
-      License with respect to the Work, or who has received express
-      permission from the Licensor to exercise rights under this
-      License despite a previous violation.
-
-  h.  "Publicly Perform" means to perform public recitations of the
-      Work and to communicate to the public those public recitations,
-      by any means or process, including by wire or wireless means or
-      public digital performances; to make available to the public
-      Works in such a way that members of the public may access these
-      Works from a place and at a place individually chosen by them;
-      to perform the Work to the public by any means or process and
-      the communication to the public of the performances of the Work,
-      including by public digital performance; to broadcast and
-      rebroadcast the Work by any means including signs, sounds or
-      images.
-
-  i.  "Reproduce" means to make copies of the Work by any means
-      including without limitation by sound or visual recordings and
-      the right of fixation and reproducing fixations of the Work,
-      including storage of a protected performance or phonogram in
-      digital form or other electronic medium.
-
-2.  Fair Dealing Rights.  Nothing in this License is intended to
-    reduce, limit, or restrict any uses free from copyright or rights
-    arising from limitations or exceptions that are provided for in
-    connection with the copyright protection under copyright law or
-    other applicable laws.
-
-3.  License Grant.  Subject to the terms and conditions of this
-    License, Licensor hereby grants You a worldwide, royalty-free,
-    non-exclusive, perpetual (for the duration of the applicable
-    copyright) license to exercise the rights in the Work as stated
-    below:
-
-  a.  to Reproduce the Work, to incorporate the Work into one or more
-      Collections, and to Reproduce the Work as incorporated in the
-      Collections;
-
-  b.  to create and Reproduce Adaptations provided that any such
-      Adaptation, including any translation in any medium, takes
-      reasonable steps to clearly label, demarcate or otherwise
-      identify that changes were made to the original Work.  For
-      example, a translation could be marked "The original work was
-      translated from English to Spanish," or a modification could
-      indicate "The original work has been modified.";
-
-  c.  to Distribute and Publicly Perform the Work including as
-      incorporated in Collections; and,
-
-  d.  to Distribute and Publicly Perform Adaptations.
-
-  e.  For the avoidance of doubt:
-
-    i.   Non-waivable Compulsory License Schemes.  In those
-	     jurisdictions in which the right to collect royalties
-	     through any statutory or compulsory licensing scheme
-	     cannot be waived, the Licensor reserves the exclusive
-	     right to collect such royalties for any exercise by You
-	     of the rights granted under this License;
-
-    ii.  Waivable Compulsory License Schemes.  In those jurisdictions
-	     in which the right to collect royalties through any
-	     statutory or compulsory licensing scheme can be waived,
-	     the Licensor waives the exclusive right to collect such
-	     royalties for any exercise by You of the rights granted
-	     under this License; and,
-
-    iii. Voluntary License Schemes.  The Licensor waives the right to
-	     collect royalties, whether individually or, in the event
-	     that the Licensor is a member of a collecting society
-	     that administers voluntary licensing schemes, via that
-	     society, from any exercise by You of the rights granted
-	     under this License.
-
-The above rights may be exercised in all media and formats whether now
-known or hereafter devised.  The above rights include the right to
-make such modifications as are technically necessary to exercise the
-rights in other media and formats.  Subject to Section 8(f), all
-rights not expressly granted by Licensor are hereby reserved.
-
-4.  Restrictions.  The license granted in Section 3 above is expressly
-    made subject to and limited by the following restrictions:
-
-  a.  You may Distribute or Publicly Perform the Work only under the
-      terms of this License.  You must include a copy of, or the
-      Uniform Resource Identifier (URI) for, this License with every
-      copy of the Work You Distribute or Publicly Perform.  You may
-      not offer or impose any terms on the Work that restrict the
-      terms of this License or the ability of the recipient of the
-      Work to exercise the rights granted to that recipient under the
-      terms of the License.  You may not sublicense the Work.  You
-      must keep intact all notices that refer to this License and to
-      the disclaimer of warranties with every copy of the Work You
-      Distribute or Publicly Perform.  When You Distribute or Publicly
-      Perform the Work, You may not impose any effective technological
-      measures on the Work that restrict the ability of a recipient of
-      the Work from You to exercise the rights granted to that
-      recipient under the terms of the License.  This Section 4(a)
-      applies to the Work as incorporated in a Collection, but this
-      does not require the Collection apart from the Work itself to be
-      made subject to the terms of this License.  If You create a
-      Collection, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Collection any credit as
-      required by Section 4(b), as requested.  If You create an
-      Adaptation, upon notice from any Licensor You must, to the
-      extent practicable, remove from the Adaptation any credit as
-      required by Section 4(b), as requested.
-
-  b.  If You Distribute, or Publicly Perform the Work or any
-      Adaptations or Collections, You must, unless a request has been
-      made pursuant to Section 4(a), keep intact all copyright notices
-      for the Work and provide, reasonable to the medium or means You
-      are utilizing: (i) the name of the Original Author (or
-      pseudonym, if applicable) if supplied, and/or if the Original
-      Author and/or Licensor designate another party or parties (e.g.,
-      a sponsor institute, publishing entity, journal) for attribution
-      ("Attribution Parties") in Licensor's copyright notice, terms of
-      service or by other reasonable means, the name of such party or
-      parties; (ii) the title of the Work if supplied; (iii) to the
-      extent reasonably practicable, the URI, if any, that Licensor
-      specifies to be associated with the Work, unless such URI does
-      not refer to the copyright notice or licensing information for
-      the Work; and (iv) , consistent with Section 3(b), in the case
-      of an Adaptation, a credit identifying the use of the Work in
-      the Adaptation (e.g., "French translation of the Work by
-      Original Author," or "Screenplay based on original Work by
-      Original Author").  The credit required by this Section 4 (b)
-      may be implemented in any reasonable manner; provided, however,
-      that in the case of a Adaptation or Collection, at a minimum
-      such credit will appear, if a credit for all contributing
-      authors of the Adaptation or Collection appears, then as part of
-      these credits and in a manner at least as prominent as the
-      credits for the other contributing authors.  For the avoidance
-      of doubt, You may only use the credit required by this Section
-      for the purpose of attribution in the manner set out above and,
-      by exercising Your rights under this License, You may not
-      implicitly or explicitly assert or imply any connection with,
-      sponsorship or endorsement by the Original Author, Licensor
-      and/or Attribution Parties, as appropriate, of You or Your use
-      of the Work, without the separate, express prior written
-      permission of the Original Author, Licensor and/or Attribution
-      Parties.
-
-  c.  Except as otherwise agreed in writing by the Licensor or as may
-      be otherwise permitted by applicable law, if You Reproduce,
-      Distribute or Publicly Perform the Work either by itself or as
-      part of any Adaptations or Collections, You must not distort,
-      mutilate, modify or take other derogatory action in relation to
-      the Work which would be prejudicial to the Original Author's
-      honor or reputation.  Licensor agrees that in those
-      jurisdictions (e.g.  Japan), in which any exercise of the right
-      granted in Section 3(b) of this License (the right to make
-      Adaptations) would be deemed to be a distortion, mutilation,
-      modification or other derogatory action prejudicial to the
-      Original Author's honor and reputation, the Licensor will waive
-      or not assert, as appropriate, this Section, to the fullest
-      extent permitted by the applicable national law, to enable You
-      to reasonably exercise Your right under Section 3(b) of this
-      License (right to make Adaptations) but not otherwise.
-
-5.  Representations, Warranties and Disclaimer
-
-UNLESS OTHERWISE MUTUALLY AGREED TO BY THE PARTIES IN WRITING,
-LICENSOR OFFERS THE WORK AS-IS AND MAKES NO REPRESENTATIONS OR
-WARRANTIES OF ANY KIND CONCERNING THE WORK, EXPRESS, IMPLIED,
-STATUTORY OR OTHERWISE, INCLUDING, WITHOUT LIMITATION, WARRANTIES OF
-TITLE, MERCHANTIBILITY, FITNESS FOR A PARTICULAR PURPOSE,
-NONINFRINGEMENT, OR THE ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY,
-OR THE PRESENCE OF ABSENCE OF ERRORS, WHETHER OR NOT DISCOVERABLE.
-SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OF IMPLIED WARRANTIES,
-SO SUCH EXCLUSION MAY NOT APPLY TO YOU.
-
-6.  Limitation on Liability.  EXCEPT TO THE EXTENT REQUIRED BY
-    APPLICABLE LAW, IN NO EVENT WILL LICENSOR BE LIABLE TO YOU ON ANY
-    LEGAL THEORY FOR ANY SPECIAL, INCIDENTAL, CONSEQUENTIAL, PUNITIVE
-    OR EXEMPLARY DAMAGES ARISING OUT OF THIS LICENSE OR THE USE OF THE
-    WORK, EVEN IF LICENSOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH
-    DAMAGES.
-
-7.  Termination
-
-  a.  This License and the rights granted hereunder will terminate
-      automatically upon any breach by You of the terms of this
-      License.  Individuals or entities who have received Adaptations
-      or Collections from You under this License, however, will not
-      have their licenses terminated provided such individuals or
-      entities remain in full compliance with those licenses.
-      Sections 1, 2, 5, 6, 7, and 8 will survive any termination of
-      this License.
-
-  b.  Subject to the above terms and conditions, the license granted
-      here is perpetual (for the duration of the applicable copyright
-      in the Work).  Notwithstanding the above, Licensor reserves the
-      right to release the Work under different license terms or to
-      stop distributing the Work at any time; provided, however that
-      any such election will not serve to withdraw this License (or
-      any other license that has been, or is required to be, granted
-      under the terms of this License), and this License will continue
-      in full force and effect unless terminated as stated above.
-
-8. Miscellaneous
-
-  a.  Each time You Distribute or Publicly Perform the Work or a
-      Collection, the Licensor offers to the recipient a license to
-      the Work on the same terms and conditions as the license granted
-      to You under this License.
-
-  b.  Each time You Distribute or Publicly Perform an Adaptation,
-      Licensor offers to the recipient a license to the original Work
-      on the same terms and conditions as the license granted to You
-      under this License.
-
-  c.  If any provision of this License is invalid or unenforceable
-      under applicable law, it shall not affect the validity or
-      enforceability of the remainder of the terms of this License,
-      and without further action by the parties to this agreement,
-      such provision shall be reformed to the minimum extent necessary
-      to make such provision valid and enforceable.
-
-  d.  No term or provision of this License shall be deemed waived and
-      no breach consented to unless such waiver or consent shall be in
-      writing and signed by the party to be charged with such waiver
-      or consent.
-
-  e.  This License constitutes the entire agreement between the
-      parties with respect to the Work licensed here.  There are no
-      understandings, agreements or representations with respect to
-      the Work not specified here.  Licensor shall not be bound by any
-      additional provisions that may appear in any communication from
-      You.  This License may not be modified without the mutual
-      written agreement of the Licensor and You.
-
-  f.  The rights granted under, and the subject matter referenced, in
-      this License were drafted utilizing the terminology of the Berne
-      Convention for the Protection of Literary and Artistic Works (as
-      amended on September 28, 1979), the Rome Convention of 1961, the
-      WIPO Copyright Treaty of 1996, the WIPO Performances and
-      Phonograms Treaty of 1996 and the Universal Copyright Convention
-      (as revised on July 24, 1971).  These rights and subject matter
-      take effect in the relevant jurisdiction in which the License
-      terms are sought to be enforced according to the corresponding
-      provisions of the implementation of those treaty provisions in
-      the applicable national law.  If the standard suite of rights
-      granted under applicable copyright law includes additional
-      rights not granted under this License, such additional rights
-      are deemed to be included in the License; this License is not
-      intended to restrict the license of any rights under applicable
-      law.
diff --git a/lib/LICENSE-OFL1.1 b/lib/LICENSE-OFL1.1
deleted file mode 100644
index 0754257..0000000
--- a/lib/LICENSE-OFL1.1
+++ /dev/null
@@ -1,93 +0,0 @@
-Copyright 2010, 2012 Adobe Systems Incorporated (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe Systems Incorporated in the United States and/or other countries.
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-
-This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/lib/LICENSE-ba-linkify b/lib/LICENSE-ba-linkify
deleted file mode 100644
index 93672f9..0000000
--- a/lib/LICENSE-ba-linkify
+++ /dev/null
@@ -1,22 +0,0 @@
-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.
diff --git a/lib/LICENSE-codemirror-minified b/lib/LICENSE-codemirror-minified
deleted file mode 100644
index 89f23625..0000000
--- a/lib/LICENSE-codemirror-minified
+++ /dev/null
@@ -1,22 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2016 Marijn Haverbeke <marijnh@gmail.com> and others
-Copyright (c) 2016 Michael Zhou <zhoumotongxue008@gmail.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.
diff --git a/lib/LICENSE-elasticsearch b/lib/LICENSE-elasticsearch
deleted file mode 100644
index 23cae9e..0000000
--- a/lib/LICENSE-elasticsearch
+++ /dev/null
@@ -1,5 +0,0 @@
-Elasticsearch
-Copyright 2009-2015 Elasticsearch
-
-This product includes software developed by The Apache Software
-Foundation (http://www.apache.org/).
diff --git a/lib/LICENSE-es6-promise b/lib/LICENSE-es6-promise
deleted file mode 100644
index 954ec59..0000000
--- a/lib/LICENSE-es6-promise
+++ /dev/null
@@ -1,19 +0,0 @@
-Copyright (c) 2014 Yehuda Katz, Tom Dale, Stefan Penner and contributors
-
-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.
diff --git a/lib/LICENSE-fetch b/lib/LICENSE-fetch
deleted file mode 100644
index 0e319d5..0000000
--- a/lib/LICENSE-fetch
+++ /dev/null
@@ -1,20 +0,0 @@
-Copyright (c) 2014-2016 GitHub, Inc.
-
-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.
diff --git a/lib/LICENSE-moment b/lib/LICENSE-moment
deleted file mode 100644
index 9ee5374..0000000
--- a/lib/LICENSE-moment
+++ /dev/null
@@ -1,22 +0,0 @@
-Copyright (c) 2011-2016 Tim Wood, Iskren Chernev, Moment.js contributors
-
-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.
diff --git a/lib/LICENSE-page.js b/lib/LICENSE-page.js
deleted file mode 100644
index 78152a9..0000000
--- a/lib/LICENSE-page.js
+++ /dev/null
@@ -1,20 +0,0 @@
-(The MIT License)
-
-Copyright (c) 2012 TJ Holowaychuk <tj@vision-media.ca>
-
-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.
diff --git a/lib/LICENSE-polymer b/lib/LICENSE-polymer
deleted file mode 100644
index 322c5a8..0000000
--- a/lib/LICENSE-polymer
+++ /dev/null
@@ -1,27 +0,0 @@
-Copyright (c) 2014 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 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.
diff --git a/lib/LICENSE-promise-polyfill b/lib/LICENSE-promise-polyfill
deleted file mode 100644
index 6f7c0123..0000000
--- a/lib/LICENSE-promise-polyfill
+++ /dev/null
@@ -1,20 +0,0 @@
-Copyright (c) 2014 Taylor Hakes
-Copyright (c) 2014 Forbes Lindesay
-
-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.
diff --git a/lib/LICENSE-resemblejs b/lib/LICENSE-resemblejs
deleted file mode 100644
index b265c8a..0000000
--- a/lib/LICENSE-resemblejs
+++ /dev/null
@@ -1,18 +0,0 @@
-The MIT License (MIT) Copyright © 2013 Huddle
-
-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.
diff --git a/lib/LICENSE-shadycss b/lib/LICENSE-shadycss
deleted file mode 100644
index 0fe5c52..0000000
--- a/lib/LICENSE-shadycss
+++ /dev/null
@@ -1,20 +0,0 @@
-# License
-
-Everything in this repo is BSD style license unless otherwise specified.
-
-Copyright (c) 2015 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 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.
-
diff --git a/lib/LICENSE-testcontainers b/lib/LICENSE-testcontainers
deleted file mode 100644
index 5d60e93..0000000
--- a/lib/LICENSE-testcontainers
+++ /dev/null
@@ -1,22 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2015 Richard North
-
-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.
-
diff --git a/lib/ba-linkify/BUILD b/lib/ba-linkify/BUILD
deleted file mode 100644
index 9f0b41d..0000000
--- a/lib/ba-linkify/BUILD
+++ /dev/null
@@ -1,9 +0,0 @@
-# Initially, ba-linkify.js was placed in this folder
-# Because BUILD file can't be in the npm package, the .js file was moved to a src/... subfolder.
-# Some plugin can still use ba-linkify, so we add alias to this file, so plugins
-# сan be built without any update.
-alias(
-    name = "ba-linkify.js",
-    actual = "src/ba-linkify.js",
-    visibility = ["//visibility:public"],
-)
diff --git a/lib/ba-linkify/src/LICENSE-MIT b/lib/ba-linkify/src/LICENSE-MIT
deleted file mode 100644
index 93672f9..0000000
--- a/lib/ba-linkify/src/LICENSE-MIT
+++ /dev/null
@@ -1,22 +0,0 @@
-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.
diff --git a/lib/ba-linkify/src/README.md b/lib/ba-linkify/src/README.md
deleted file mode 100644
index 8c3e9a4..0000000
--- a/lib/ba-linkify/src/README.md
+++ /dev/null
@@ -1,6 +0,0 @@
-This is the latest version of ba-linkify.js from:
-https://github.com/cowboy/javascript-linkify/blob/178ffc271f89cef403faf73cabd74dda0a79af62/ba-linkify.js
-
-The file was modified manually to include a @license JSDoc tag. The file hasn't
-been updated since 2009, but on the off chance you need to update it, please
-make sure you include a @license.
diff --git a/lib/ba-linkify/src/ba-linkify.js b/lib/ba-linkify/src/ba-linkify.js
deleted file mode 100644
index 461aff9..0000000
--- a/lib/ba-linkify/src/ba-linkify.js
+++ /dev/null
@@ -1,245 +0,0 @@
-/**
- * @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.
- */
- /**
-  * Note(taoalpha):
-  *
-  * To support emails with dots in the name: `foo.bar@test.com`,
-  * the match regex was modified to match `email` first before urls.
-  */
-/*!
- * JavaScript Linkify - v0.3 - 6/27/2009
- * http://benalman.com/projects/javascript-linkify/
- * 
- * Copyright (c) 2009 "Cowboy" Ben Alman
- * Dual licensed under the MIT and GPL licenses.
- * http://benalman.com/about/license/
- * 
- * Some regexps adapted from http://userscripts.org/scripts/review/7122
- */
-
-// Script: JavaScript Linkify: Process links in text!
-//
-// *Version: 0.3, Last updated: 6/27/2009*
-// 
-// Project Home - http://benalman.com/projects/javascript-linkify/
-// GitHub       - http://github.com/cowboy/javascript-linkify/
-// Source       - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.js
-// (Minified)   - http://github.com/cowboy/javascript-linkify/raw/master/ba-linkify.min.js (2.8kb)
-// 
-// About: License
-// 
-// Copyright (c) 2009 "Cowboy" Ben Alman,
-// Dual licensed under the MIT and GPL licenses.
-// http://benalman.com/about/license/
-// 
-// About: Examples
-// 
-// This working example, complete with fully commented code, illustrates one way
-// in which this code can be used.
-// 
-// Linkify - http://benalman.com/code/projects/javascript-linkify/examples/linkify/
-// 
-// About: Support and Testing
-// 
-// Information about what browsers this code has been tested in.
-// 
-// Browsers Tested - Internet Explorer 6-8, Firefox 2-3.7, Safari 3-4, Chrome, Opera 9.6-10.
-// 
-// About: Release History
-// 
-// 0.3 - (6/27/2009) Initial release
-
-// Function: linkify
-// 
-// Turn text into linkified html.
-// 
-// Usage:
-// 
-//  > var html = linkify( text [, options ] );
-// 
-// Arguments:
-// 
-//  text - (String) Non-HTML text containing links to be parsed.
-//  options - (Object) An optional object containing linkify parse options.
-// 
-// Options:
-// 
-//  callback (Function) - If specified, this will be called once for each link-
-//    or non-link-chunk with two arguments, text and href. If the chunk is
-//    non-link, href will be omitted. If unspecified, the default linkification
-//    callback is used.
-//  punct_regexp (RegExp) - A RegExp that will be used to trim trailing
-//    punctuation from links, instead of the default. If set to null, trailing
-//    punctuation will not be trimmed.
-// 
-// Returns:
-// 
-//  (String) An HTML string containing links.
-
-window.linkify = (function(){
-  var
-    SCHEME = "[a-z\\d.-]+://",
-    IPV4 = "(?:(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])",
-    HOSTNAME = "(?:(?:[^\\s!@#$%^&*()_=+[\\]{}\\\\|;:'\",.<>/?]+)\\.)+",
-    TLD = "(?:ac|ad|aero|ae|af|ag|ai|al|am|an|ao|aq|arpa|ar|asia|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|biz|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|cat|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|coop|com|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|info|int|in|io|iq|ir|is|it|je|jm|jobs|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mobi|mo|mp|mq|mr|ms|mt|museum|mu|mv|mw|mx|my|mz|name|na|nc|net|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pro|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|travel|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xn--0zwm56d|xn--11b5bs3a9aj6g|xn--80akhbyknj4f|xn--9t4b11yi5a|xn--deba0ad|xn--g6w251d|xn--hgbk6aj7f53bba|xn--hlcj6aya9esc7a|xn--jxalpdlp|xn--kgbechtv|xn--zckzah|ye|yt|yu|za|zm|zw)",
-    HOST_OR_IP = "(?:" + HOSTNAME + TLD + "|" + IPV4 + ")",
-    PATH = "(?:[;/][^#?<>\\s]*)?",
-    QUERY_FRAG = "(?:\\?[^#<>\\s]*)?(?:#[^<>\\s]*)?",
-    URI1 = "\\b" + SCHEME + "[^<>\\s]+",
-    URI2 = "\\b" + HOST_OR_IP + PATH + QUERY_FRAG + "(?!\\w)",
-    
-    MAILTO = "mailto:",
-    EMAIL = "(?:" + MAILTO + ")?[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@" + HOST_OR_IP + QUERY_FRAG + "(?!\\w)",
-    
-    URI_RE = new RegExp( "(?:" + EMAIL + "|" + URI1 + "|" + URI2 + ")", "ig" ),
-    SCHEME_RE = new RegExp( "^" + SCHEME, "i" ),
-    
-    quotes = {
-      "'": "`",
-      '>': '<',
-      ')': '(',
-      ']': '[',
-      '}': '{',
-      '»': '«',
-      '›': '‹'
-    },
-    
-    default_options = {
-      callback: function( text, href ) {
-        return href ? '<a href="' + href + '" title="' + href + '">' + text + '</a>' : text;
-      },
-      punct_regexp: /(?:[!?.,:;'"]|(?:&|&amp;)(?:lt|gt|quot|apos|raquo|laquo|rsaquo|lsaquo);)$/
-    };
-  
-  return function( txt, options ) {
-    options = options || {};
-    
-    // Temp variables.
-    var arr,
-      i,
-      link,
-      href,
-      
-      // Output HTML.
-      html = '',
-      
-      // Store text / link parts, in order, for re-combination.
-      parts = [],
-      
-      // Used for keeping track of indices in the text.
-      idx_prev,
-      idx_last,
-      idx,
-      link_last,
-      
-      // Used for trimming trailing punctuation and quotes from links.
-      matches_begin,
-      matches_end,
-      quote_begin,
-      quote_end;
-    
-    // Initialize options.
-    for ( i in default_options ) {
-      if ( options[ i ] === undefined ) {
-        options[ i ] = default_options[ i ];
-      }
-    }
-    
-    // Find links.
-    while ( arr = URI_RE.exec( txt ) ) {
-      
-      link = arr[0];
-      idx_last = URI_RE.lastIndex;
-      idx = idx_last - link.length;
-      
-      // Not a link if preceded by certain characters.
-      if ( /[\/:]/.test( txt.charAt( idx - 1 ) ) ) {
-        continue;
-      }
-      
-      // Trim trailing punctuation.
-      do {
-        // If no changes are made, we don't want to loop forever!
-        link_last = link;
-        
-        quote_end = link.substr( -1 )
-        quote_begin = quotes[ quote_end ];
-        
-        // Ending quote character?
-        if ( quote_begin ) {
-          matches_begin = link.match( new RegExp( '\\' + quote_begin + '(?!$)', 'g' ) );
-          matches_end = link.match( new RegExp( '\\' + quote_end, 'g' ) );
-          
-          // If quotes are unbalanced, remove trailing quote character.
-          if ( ( matches_begin ? matches_begin.length : 0 ) < ( matches_end ? matches_end.length : 0 ) ) {
-            link = link.substr( 0, link.length - 1 );
-            idx_last--;
-          }
-        }
-        
-        // Ending non-quote punctuation character?
-        if ( options.punct_regexp ) {
-          link = link.replace( options.punct_regexp, function(a){
-            idx_last -= a.length;
-            return '';
-          });
-        }
-      } while ( link.length && link !== link_last );
-      
-      href = link;
-      
-      // Add appropriate protocol to naked links.
-      if ( !SCHEME_RE.test( href ) ) {
-        href = ( href.indexOf( '@' ) !== -1 ? ( !href.indexOf( MAILTO ) ? '' : MAILTO )
-          : !href.indexOf( 'irc.' ) ? 'irc://'
-          : !href.indexOf( 'ftp.' ) ? 'ftp://'
-          : 'http://' )
-          + href;
-      }
-      
-      // Push preceding non-link text onto the array.
-      if ( idx_prev != idx ) {
-        parts.push([ txt.slice( idx_prev, idx ) ]);
-        idx_prev = idx_last;
-      }
-      
-      // Push massaged link onto the array
-      parts.push([ link, href ]);
-    };
-    
-    // Push remaining non-link text onto the array.
-    parts.push([ txt.substr( idx_prev ) ]);
-    
-    // Process the array items.
-    for ( i = 0; i < parts.length; i++ ) {
-      html += options.callback.apply( window, parts[i] );
-    }
-    
-    // In case of catastrophic failure, return the original text;
-    return html || txt;
-  };
-  
-})();
diff --git a/lib/ba-linkify/src/package.json b/lib/ba-linkify/src/package.json
deleted file mode 100644
index 813d75f..0000000
--- a/lib/ba-linkify/src/package.json
+++ /dev/null
@@ -1,8 +0,0 @@
-{
-  "name": "ba-linkify",
-  "version": "1.0.0",
-  "description": "See README.md",
-  "main": "ba-linkify.js",
-  "license": "MIT",
-  "private": true
-}
diff --git a/lib/elasticsearch-rest-client/BUILD b/lib/elasticsearch-rest-client/BUILD
deleted file mode 100644
index e323263..0000000
--- a/lib/elasticsearch-rest-client/BUILD
+++ /dev/null
@@ -1,9 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-package(default_visibility = ["//visibility:public"])
-
-java_library(
-    name = "elasticsearch-rest-client",
-    data = ["//lib:LICENSE-elasticsearch"],
-    exports = ["@elasticsearch-rest-client//jar"],
-)
diff --git a/lib/httpcomponents/BUILD b/lib/httpcomponents/BUILD
index 07d4bb9..25e09c9 100644
--- a/lib/httpcomponents/BUILD
+++ b/lib/httpcomponents/BUILD
@@ -25,20 +25,3 @@
     data = ["//lib:LICENSE-Apache2.0"],
     exports = ["@httpcore//jar"],
 )
-
-java_library(
-    name = "httpasyncclient",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = [
-        "//java/com/google/gerrit/elasticsearch:__pkg__",
-        "//javatests/com/google/gerrit/elasticsearch:__pkg__",
-    ],
-    exports = ["@httpasyncclient//jar"],
-)
-
-java_library(
-    name = "httpcore-nio",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//java/com/google/gerrit/elasticsearch:__pkg__"],
-    exports = ["@httpcore-nio//jar"],
-)
diff --git a/lib/jackson/BUILD b/lib/jackson/BUILD
deleted file mode 100644
index f11b96d..0000000
--- a/lib/jackson/BUILD
+++ /dev/null
@@ -1,20 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "jackson-annotations",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jackson-annotations//jar"],
-)
-
-java_library(
-    name = "jackson-core",
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = [
-        "//java/com/google/gerrit/acceptance:__pkg__",
-        "//java/com/google/gerrit/elasticsearch:__pkg__",
-        "//plugins:__pkg__",
-    ],
-    exports = ["@jackson-core//jar"],
-)
diff --git a/lib/js/BUILD b/lib/js/BUILD
index 106aabb..be82540 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -1,4 +1,4 @@
-load("//tools/bzl:js.bzl", "bower_component", "js_component")
+load("//tools/bzl:js.bzl", "js_component")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -16,21 +16,3 @@
     srcs = ["//lib/highlightjs:highlight.min.js"],
     data = ["//lib:LICENSE-highlightjs"],
 )
-
-js_component(
-    name = "ba-linkify",
-    srcs = ["//lib/ba-linkify:ba-linkify.js"],
-    license = "//lib:LICENSE-ba-linkify",
-)
-
-##TODO: remove after plugins migration to npm
-bower_component(
-    name = "codemirror-minified",
-    license = "//lib:LICENSE-codemirror-minified",
-)
-
-bower_component(
-    name = "resemblejs",
-    license = "//lib:LICENSE-resemblejs",
-)
-#End of removal
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
deleted file mode 100644
index 5a6a8c0..0000000
--- a/lib/js/npm.bzl
+++ /dev/null
@@ -1,11 +0,0 @@
-NPM_VERSIONS = {
-    "bower": "1.8.8",
-    "crisper": "2.0.2",
-    "polymer-bundler": "4.0.9",
-}
-
-NPM_SHA1S = {
-    "bower": "82544be34a33aeae7efb8bdf9905247b2cffa985",
-    "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "polymer-bundler": "c80c9815690d76656d1fa6a231481850b4fa3874",
-}
diff --git a/lib/log/BUILD b/lib/log/BUILD
index 4966723..21c4d47 100644
--- a/lib/log/BUILD
+++ b/lib/log/BUILD
@@ -4,7 +4,6 @@
     name = "api",
     data = ["//lib:LICENSE-slf4j"],
     visibility = [
-        "//javatests/com/google/gerrit/elasticsearch:__pkg__",
         "//lib:__pkg__",
         "//plugins:__pkg__",
     ],
diff --git a/lib/nongoogle_test.sh b/lib/nongoogle_test.sh
index ec8e018..cfb6cd0 100755
--- a/lib/nongoogle_test.sh
+++ b/lib/nongoogle_test.sh
@@ -11,12 +11,10 @@
 grep 'name = "[^"]*"' ${bzl} | sed 's|^[^"]*"||g;s|".*$||g' | sort > $TMP/names
 
 cat << EOF > $TMP/want
+backward-codecs
 cglib-3_2
 commons-io
-docker-java-api
-docker-java-transport
 dropwizard-core
-duct-tape
 eddsa
 flogger
 flogger-log4j-backend
@@ -26,19 +24,18 @@
 guice-library
 guice-servlet
 hamcrest
-httpasyncclient
-httpcore-nio
 impl-log4j
 j2objc
-jackson-annotations
-jackson-core
 jcl-over-slf4j
 jimfs
-jna
 jruby
 log-api
 log-ext
 log4j
+lucene-analyzers-common
+lucene-core
+lucene-misc
+lucene-queryparser
 mina-core
 nekohtml
 objenesis
@@ -47,13 +44,11 @@
 sshd-mina
 sshd-osgi
 sshd-sftp
-testcontainers
 truth
 truth-java8-extension
 truth-liteproto-extension
 truth-proto-extension
 tukaani-xz
-visible-assertions
 xerces
 EOF
 
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
deleted file mode 100644
index 693a386..0000000
--- a/lib/testcontainers/BUILD
+++ /dev/null
@@ -1,64 +0,0 @@
-load("@rules_java//java:defs.bzl", "java_library")
-
-java_library(
-    name = "docker-java-api",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@docker-java-api//jar"],
-)
-
-java_library(
-    name = "docker-java-transport",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@docker-java-transport//jar"],
-)
-
-java_library(
-    name = "duct-tape",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@duct-tape//jar"],
-)
-
-java_library(
-    name = "visible-assertions",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@visible-assertions//jar"],
-)
-
-java_library(
-    name = "jna",
-    testonly = True,
-    data = ["//lib:LICENSE-Apache2.0"],
-    visibility = ["//visibility:public"],
-    exports = ["@jna//jar"],
-)
-
-java_library(
-    name = "testcontainers",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@testcontainers//jar"],
-    runtime_deps = [
-        ":duct-tape",
-        ":jna",
-        ":visible-assertions",
-        "//lib/log:ext",
-    ],
-)
-
-java_library(
-    name = "testcontainers-elasticsearch",
-    testonly = True,
-    data = ["//lib:LICENSE-testcontainers"],
-    visibility = ["//visibility:public"],
-    exports = ["@testcontainers-elasticsearch//jar"],
-    runtime_deps = [":testcontainers"],
-)
diff --git a/package.json b/package.json
index f52aa74..af02301 100644
--- a/package.json
+++ b/package.json
@@ -6,41 +6,51 @@
     "@bazel/concatjs": "^5.1.0",
     "@bazel/rollup": "^5.1.0",
     "@bazel/terser": "^5.1.0",
-    "@bazel/typescript": "^5.1.0"
+    "@bazel/typescript": "^5.1.0",
+    "twinkie": "^1.1.3"
   },
   "devDependencies": {
-    "@typescript-eslint/eslint-plugin": "^4.22.0",
+    "@typescript-eslint/eslint-plugin": "^4.29.0",
     "eslint": "^7.24.0",
     "eslint-config-google": "^0.14.0",
     "eslint-plugin-html": "^6.1.2",
     "eslint-plugin-import": "^2.22.1",
     "eslint-plugin-jsdoc": "^32.3.0",
+    "eslint-plugin-lit": "^1.5.1",
     "eslint-plugin-node": "^11.1.0",
     "eslint-plugin-prettier": "^3.4.0",
+    "eslint-plugin-regex": "^1.8.0",
     "gts": "^3.1.0",
-    "polymer-cli": "^1.9.11",
-    "prettier": "2.2.1",
+    "lit-analyzer": "^1.2.1",
+    "prettier": "2.3.1",
     "rollup": "^2.45.2",
     "terser": "^5.6.1",
-    "typescript": "4.1.4"
+    "ts-lit-plugin": "^1.2.1",
+    "typescript": "4.3.2"
   },
   "scripts": {
     "clean": "git clean -fdx && bazel clean --expunge",
     "compile:local": "tsc --project ./polygerrit-ui/app/tsconfig.json",
     "compile:watch": "npm run compile:local -- --preserveWatchOutput --watch",
     "start": "polygerrit-ui/run-server.sh",
-    "test": "./polygerrit-ui/app/run_test.sh",
+    "test": "npm run safe_bazelisk test //polygerrit-ui:karma_test -- --test_verbose_timeout_warnings --test_output=all",
     "safe_bazelisk": "if which bazelisk >/dev/null; then bazel_bin=bazelisk; else bazel_bin=bazel; fi && $bazel_bin",
     "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 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"
+    "litlint": "npm run safe_bazelisk run polygerrit-ui/app:lit_analysis",
+    "test:debug": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --browsers ChromeDev --no-single-run --test-files",
+    "test:single": "npm run compile:local && npm run safe_bazelisk run //polygerrit-ui:karma_bin -- -- start $(pwd)/polygerrit-ui/karma.conf.js --root '.ts-out/polygerrit-ui/app/' --test-files",
+    "polylint": "npm run safe_bazelisk test //polygerrit-ui/app:polylint_test",
+    "polylint:dev": "rm -rf ./polygerrit-ui/app/tmpl_out && npm run safe_bazelisk build //polygerrit-ui/app:template_test_tar && mkdir ./polygerrit-ui/app/tmpl_out && tar -xf bazel-bin/polygerrit-ui/app/template_test_tar.tar -C ./polygerrit-ui/app/tmpl_out"
   },
   "repository": {
     "type": "git",
     "url": "https://gerrit.googlesource.com/gerrit"
   },
+  "resolutions": {
+    "lodash": "4.17.21",
+    "twinkie/typescript": "4.3.2"
+  },
   "author": "",
   "license": "Apache-2.0"
 }
diff --git a/plugins/.eslintignore b/plugins/.eslintignore
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/plugins/.eslintignore
diff --git a/plugins/.eslintrc.js b/plugins/.eslintrc.js
new file mode 100644
index 0000000..149a31e
--- /dev/null
+++ b/plugins/.eslintrc.js
@@ -0,0 +1,318 @@
+/**
+ * @license
+ * Copyright (C) 2020 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * This is a base template for TypeScript plugins.
+ *
+ * When extending this template you have to:
+ * - Set the __plugindir variable.
+ */
+
+const path = require('path');
+
+module.exports = {
+  extends: ['eslint:recommended', 'google'],
+  parserOptions: {
+    ecmaVersion: 9,
+    sourceType: 'module',
+  },
+  env: {
+    browser: true,
+    es6: true,
+  },
+  rules: {
+    // https://eslint.org/docs/rules/no-confusing-arrow
+    'no-confusing-arrow': 'error',
+    // https://eslint.org/docs/rules/newline-per-chained-call
+    'newline-per-chained-call': ['error', {ignoreChainWithDepth: 2}],
+    // https://eslint.org/docs/rules/arrow-body-style
+    'arrow-body-style': ['error', 'as-needed',
+      {requireReturnForObjectLiteral: true}],
+    // https://eslint.org/docs/rules/arrow-parens
+    'arrow-parens': ['error', 'as-needed'],
+    // https://eslint.org/docs/rules/block-spacing
+    'block-spacing': ['error', 'always'],
+    // https://eslint.org/docs/rules/brace-style
+    'brace-style': ['error', '1tbs', {allowSingleLine: true}],
+    // https://eslint.org/docs/rules/camelcase
+    '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',
+    }],
+    // https://eslint.org/docs/rules/eol-last
+    '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,
+    }],
+    // https://eslint.org/docs/rules/keyword-spacing
+    'keyword-spacing': ['error', {after: true, before: true}],
+    // https://eslint.org/docs/rules/lines-between-class-members
+    'lines-between-class-members': ['error', 'always'],
+    // https://eslint.org/docs/rules/max-len
+    'max-len': [
+      'error',
+      80,
+      2,
+      {
+        ignoreComments: true,
+        ignorePattern: '^import .*;$',
+      },
+    ],
+    // https://eslint.org/docs/rules/new-cap
+    'new-cap': ['error', {
+      capIsNewExceptions: ['Polymer'],
+      capIsNewExceptionPattern: '^.*Mixin$',
+    }],
+    // https://eslint.org/docs/rules/no-console
+    '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}],
+    // https://eslint.org/docs/rules/no-prototype-builtins
+    'no-prototype-builtins': 'off',
+    // https://eslint.org/docs/rules/no-redeclare
+    'no-redeclare': 'off',
+    // https://eslint.org/docs/rules/no-trailing-spaces
+    'no-trailing-spaces': 'error',
+    // https://eslint.org/docs/rules/no-irregular-whitespace
+    'no-irregular-whitespace': 'error',
+    // https://eslint.org/docs/rules/array-callback-return
+    'array-callback-return': ['error', {allowImplicit: true}],
+    // https://eslint.org/docs/rules/no-restricted-syntax
+    'no-restricted-syntax': [
+      'error',
+      {
+        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.',
+      },
+    ],
+    // no-undef disables global variable.
+    // "globals" declares allowed global variables.
+    // https://eslint.org/docs/rules/no-undef
+    'no-undef': ['error'],
+    // https://eslint.org/docs/rules/no-useless-escape
+    'no-useless-escape': 'off',
+    // https://eslint.org/docs/rules/no-var
+    'no-var': 'error',
+    // https://eslint.org/docs/rules/operator-linebreak
+    'operator-linebreak': 'off',
+    // https://eslint.org/docs/rules/object-shorthand
+    'object-shorthand': ['error', 'always'],
+    // https://eslint.org/docs/rules/padding-line-between-statements
+    'padding-line-between-statements': [
+      'error',
+      {
+        blankLine: 'always',
+        prev: 'class',
+        next: '*',
+      },
+      {
+        blankLine: 'always',
+        prev: '*',
+        next: 'class',
+      },
+    ],
+    // https://eslint.org/docs/rules/prefer-arrow-callback
+    'prefer-arrow-callback': 'error',
+    // https://eslint.org/docs/rules/prefer-const
+    'prefer-const': 'error',
+    // https://eslint.org/docs/rules/prefer-promise-reject-errors
+    'prefer-promise-reject-errors': 'error',
+    // https://eslint.org/docs/rules/prefer-spread
+    'prefer-spread': 'error',
+    // https://eslint.org/docs/rules/prefer-object-spread
+    'prefer-object-spread': 'error',
+    // https://eslint.org/docs/rules/quote-props
+    'quote-props': ['error', 'consistent-as-needed'],
+    // https://eslint.org/docs/rules/semi
+    'semi': ['error', 'always'],
+    // https://eslint.org/docs/rules/template-curly-spacing
+    'template-curly-spacing': 'error',
+
+    // https://eslint.org/docs/rules/require-jsdoc
+    'require-jsdoc': 0,
+    // https://eslint.org/docs/rules/valid-jsdoc
+    'valid-jsdoc': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-alignment
+    'jsdoc/check-alignment': 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-examples
+    'jsdoc/check-examples': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-indentation
+    'jsdoc/check-indentation': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-param-names
+    'jsdoc/check-param-names': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-syntax
+    'jsdoc/check-syntax': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-tag-names
+    'jsdoc/check-tag-names': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-types
+    'jsdoc/check-types': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-implements-on-classes
+    'jsdoc/implements-on-classes': 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-match-description
+    'jsdoc/match-description': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-newline-after-description
+    'jsdoc/newline-after-description': 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-types
+    'jsdoc/no-types': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-no-undefined-types
+    'jsdoc/no-undefined-types': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-description
+    '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,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-example
+    '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,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-jsdoc
+    'jsdoc/require-jsdoc': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param
+    'jsdoc/require-param': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-description
+    'jsdoc/require-param-description': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-param-name
+    'jsdoc/require-param-name': 2,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns
+    'jsdoc/require-returns': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-check
+    'jsdoc/require-returns-check': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-require-returns-description
+    'jsdoc/require-returns-description': 0,
+    // https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-valid-types
+    '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,
+        },
+      },
+    }],
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-self-import.md
+    '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,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-useless-path-segments.md
+    '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,
+    // https://github.com/benmosher/eslint-plugin-import/blob/master/docs/rules/no-default-export.md
+    'import/no-default-export': 2,
+    // Prevents certain identifiers being used.
+    // Prefer flush() over flushAsynchronousOperations().
+    'id-blacklist': ['error', 'flushAsynchronousOperations'],
+  },
+
+  overrides: [
+    {
+      files: ['.eslintrc.js'],
+      env: {
+        browser: false,
+        es6: true,
+        node: true,
+      },
+    },
+    {
+      // .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: ['**/*.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-comment': 'off',
+        // The following rules is required to match internal google rules
+        '@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',
+        // Disable no-invalid-this for ts files, because it incorrectly reports
+        // errors in some cases (see https://github.com/typescript-eslint/typescript-eslint/issues/491)
+        // At the same time, we are using typescript in a strict mode and
+        // it catches almost all errors related to invalid usage of this.
+        'no-invalid-this': 'off',
+
+        'node/no-extraneous-import': 'off',
+
+        // Typescript already checks for undef
+        'no-undef': 'off',
+
+        'jsdoc/no-types': 2,
+      },
+      parserOptions: {
+        // The __plugindir variable has to be defined by the plugin config.
+        project: path.resolve(__dirname, __plugindir, 'tsconfig.json'),
+      },
+    },
+  ],
+  plugins: [
+    'html',
+    'jsdoc',
+    'import',
+    'prettier',
+  ],
+  settings: {
+    'html/report-bad-indent': 'error',
+    'import/resolver': {
+      node: {},
+    },
+  },
+};
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts b/plugins/.prettierrc.js
similarity index 67%
rename from polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
rename to plugins/.prettierrc.js
index 1489006..64dbcb3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_html.ts
+++ b/plugins/.prettierrc.js
@@ -1,6 +1,6 @@
 /**
  * @license
- * Copyright (C) 2020 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.
@@ -14,6 +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``;
+/**
+ * This is a base template for TypeScript plugins.
+ */
+module.exports = {
+  "overrides": [
+    {
+      "files": ["**/*.ts"],
+      "options": {
+          ...require('gts/.prettierrc.json')
+      }
+    }
+  ]
+};
diff --git a/plugins/BUILD b/plugins/BUILD
index dd0be66..8eed4e8 100644
--- a/plugins/BUILD
+++ b/plugins/BUILD
@@ -6,6 +6,17 @@
     "CORE_PLUGINS",
     "CUSTOM_PLUGINS",
 )
+load("@npm//@bazel/typescript:index.bzl", "ts_config")
+
+package(default_visibility = ["//visibility:public"])
+
+exports_files([
+    ".eslintrc.js",
+    ".eslintignore",
+    ".prettierrc.js",
+    "rollup.config.js",
+    "tsconfig-plugins-base.json",
+])
 
 genrule2(
     name = "core",
@@ -16,7 +27,6 @@
           "ln -s $$ROOT/$$s $$TMP/WEB-INF/plugins;done;" +
           "cd $$TMP;" +
           "zip -qr $$ROOT/$@ .",
-    visibility = ["//visibility:public"],
 )
 
 PLUGIN_API = [
@@ -63,6 +73,7 @@
     "//lib/commons:compress",
     "//lib/commons:dbcp",
     "//lib/commons:lang",
+    "//lib/commons:lang3",
     "//lib/dropwizard:dropwizard-core",
     "//lib/flogger:api",
     "//lib/guice:guice",
@@ -71,7 +82,6 @@
     "//lib/guice:javax_inject",
     "//lib/httpcomponents:httpclient",
     "//lib/httpcomponents:httpcore",
-    "//lib/jackson:jackson-core",
     "//lib:jgit-servlet",
     "//lib:jgit",
     "//lib:jgit-ssh-jsch",
@@ -100,6 +110,7 @@
 java_binary(
     name = "bouncycastle-deploy-env",
     main_class = "Dummy",
+    visibility = ["//visibility:private"],
     runtime_deps = [
         "//lib/bouncycastle:bcpg",
         "//lib/bouncycastle:bcpkix",
@@ -111,27 +122,23 @@
     name = "plugin-api",
     deploy_env = ["bouncycastle-deploy-env"],
     main_class = "Dummy",
-    visibility = ["//visibility:public"],
     runtime_deps = [":plugin-lib"],
 )
 
 java_library(
     name = "plugin-lib",
-    visibility = ["//visibility:public"],
     exports = PLUGIN_API + EXPORTS,
 )
 
 java_library(
     name = "plugin-lib-neverlink",
     neverlink = 1,
-    visibility = ["//visibility:public"],
     exports = PLUGIN_API + EXPORTS,
 )
 
 java_binary(
     name = "plugin-api-sources",
     main_class = "Dummy",
-    visibility = ["//visibility:public"],
     runtime_deps = [
         "//antlr3:libquery_parser-src.jar",
         "//java/com/google/gerrit/common:libannotations-src.jar",
@@ -163,5 +170,4 @@
     ],
     pkgs = ["com.google.gerrit"],
     title = "Gerrit Review Plugin API Documentation",
-    visibility = ["//visibility:public"],
 )
diff --git a/plugins/codemirror-editor b/plugins/codemirror-editor
index aa2edb4..c5bda5b 160000
--- a/plugins/codemirror-editor
+++ b/plugins/codemirror-editor
@@ -1 +1 @@
-Subproject commit aa2edb431337cc935659ab69d95ca7555cbc787e
+Subproject commit c5bda5b6b5fe91a2f7cd40c5a917dd2280b04814
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 556e427..c38e0a9 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 556e427fd737744ce8a6a37b89fd427ae59bc8ea
+Subproject commit c38e0a9d36767092b20558b28eff7f546c6d754c
diff --git a/plugins/delete-project b/plugins/delete-project
index 142875a..612f143 160000
--- a/plugins/delete-project
+++ b/plugins/delete-project
@@ -1 +1 @@
-Subproject commit 142875ae29b728e4fbad5bc22dc132df37cc4de7
+Subproject commit 612f143792652d571ecfcb19915ad5754a3ba1a7
diff --git a/plugins/download-commands b/plugins/download-commands
index 5bd359c..d2f0cae 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit 5bd359c08e10b93d2c08762f75cde01a14e45fc6
+Subproject commit d2f0cae51a269ca660f221172cf010e2d528b661
diff --git a/plugins/gitiles b/plugins/gitiles
index 5e8809c..9011b86 160000
--- a/plugins/gitiles
+++ b/plugins/gitiles
@@ -1 +1 @@
-Subproject commit 5e8809c34798022a81cabe1d2d682bb83245a3c7
+Subproject commit 9011b868d1fab8ae35d41cafed922b0b19899dde
diff --git a/plugins/hooks b/plugins/hooks
index ad4f877..4e07d16 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit ad4f877749928b69ef94b62176c5797f6648887d
+Subproject commit 4e07d16a644ea823f6538a176621acee466d865b
diff --git a/plugins/package.json b/plugins/package.json
index 9f5c649..e5d245c 100644
--- a/plugins/package.json
+++ b/plugins/package.json
@@ -1,8 +1,13 @@
 {
-    "name": "polygerrit-plugin-dependencies-placeholder",
-    "description": "Gerrit Code Review - Polygerrit plugin dependencies placeholder, expected to be overridden by plugins",
+    "name": "gerrit-plugin-dependencies",
+    "description": "Gerrit Code Review - frontend plugin dependencies, each plugin may depend on a subset of these",
     "browser": true,
-    "dependencies": {},
+    "dependencies": {
+      "@polymer/decorators": "^3.0.0",
+      "@polymer/polymer": "^3.4.1",
+      "@gerritcodereview/typescript-api": "3.4.4",
+      "lit": "^2.0.2"
+    },
     "license": "Apache-2.0",
     "private": true
-}
\ No newline at end of file
+}
diff --git a/plugins/plugin-manager b/plugins/plugin-manager
index 00e5794..e0664f6 160000
--- a/plugins/plugin-manager
+++ b/plugins/plugin-manager
@@ -1 +1 @@
-Subproject commit 00e57948f4f112c226028bc5c8d8fe60f770038f
+Subproject commit e0664f668ab5bac96a1e105b80d886de66743b1b
diff --git a/plugins/replication b/plugins/replication
index b4ebcfc..4f5de60 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit b4ebcfced53c1f4d4e9bcd885978c276fc55070f
+Subproject commit 4f5de60081d7c098d09c842e3ea9d5bc920df71b
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index fb0390a..a28ae59 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit fb0390a8b49f0d601e11f8a1ac0658c429727f21
+Subproject commit a28ae590486934690e4e0a95d7eb75f8b60644a6
diff --git a/plugins/rollup.config.js b/plugins/rollup.config.js
new file mode 100644
index 0000000..6fb3784
--- /dev/null
+++ b/plugins/rollup.config.js
@@ -0,0 +1,59 @@
+/**
+ * @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.
+ */
+const path = require('path');
+
+// In this file word "plugin" refers to rollup plugin, not Gerrit plugin.
+// By default, require(plugin_name) tries to find module plugin_name starting
+// from the folder where this file (rollup.config.js) is located
+// (see https://www.typescriptlang.org/docs/handbook/module-resolution.html#node
+// and https://nodejs.org/api/modules.html#modules_all_together).
+// So, rollup.config.js can't be in polygerrit-ui/app dir and it should be in
+// tools/node_tools directory (where all plugins are installed).
+// But rollup_bundle rule copy this .config.js file to another directory,
+// so require(plugin_name) can't find a plugin.
+// To fix it, requirePlugin tries:
+// 1. resolve module id using default behavior, i.e. it starts from __dirname
+// 2. if module not found - it tries to resolve module starting from rollupBin
+//    location.
+// This workaround also gives us additional power - we can place .config.js
+// file anywhere in a source tree and add all plugins in the same package.json
+// file as rollup node module.
+function requirePlugin(id) {
+  const rollupBinDir = path.dirname(process.argv[1]);
+  const pluginPath = require.resolve(id, {paths: [__dirname, rollupBinDir] });
+  return require(pluginPath);
+}
+
+const resolve = requirePlugin('rollup-plugin-node-resolve');
+
+export default {
+  treeshake: false,
+  onwarn: warning => {
+    // No warnings from rollupjs are allowed.
+    // Most of the warnings are real error in our code (for example,
+    // if some import couldn't be resolved we can't continue, but rollup
+    // reports it as a warning)
+    throw new Error(warning.message);
+  },
+  // Context must be set to window to correctly process global variables
+  context: 'window',
+  plugins: [resolve({
+    customResolveOptions: {
+      moduleDirectory: 'external/plugins_npm/node_modules',
+    },
+  })],
+};
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 09623b9..3239ce3 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 09623b9432d360060f88ae48fb3386e374ca29c0
+Subproject commit 3239ce3a471f5aa9edd8f6f702bee655ea81f77d
diff --git a/plugins/tsconfig-plugins-base.json b/plugins/tsconfig-plugins-base.json
new file mode 100644
index 0000000..b7e9d52
--- /dev/null
+++ b/plugins/tsconfig-plugins-base.json
@@ -0,0 +1,49 @@
+{
+  "compilerOptions": {
+    /* Basic Options */
+    "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+    "inlineSourceMap": true, /* Generates corresponding '.map' file. */
+    "rootDir": ".", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+    "removeComments": false, /* Emit comments to output */
+
+    /* Strict Type-Checking Options */
+    "strict": true, /* Enable all strict type-checking options. */
+    "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+    "strictNullChecks": true, /* Enable strict null checks. */
+    "strictFunctionTypes": true, /* Enable strict checking of function types. */
+    "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+    "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+    "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+
+    /* Additional Checks */
+    "noUnusedLocals": true, /* Report errors on unused locals. */
+    "noUnusedParameters": true, /* Report errors on unused parameters. */
+    "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+    "noImplicitOverride": true,
+    "noFallthroughCasesInSwitch": true,/* Report errors for fallthrough cases in switch statement. */
+
+    "skipLibCheck": true, /* Do not check node_modules */
+
+    /* Module Resolution Options */
+    "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+    "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+
+    /* Advanced Options */
+    "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
+    "incremental": true,
+    "experimentalDecorators": true,
+
+    "allowUmdGlobalAccess": true,
+
+    "typeRoots": [
+      /* typeRoots for Bazel */
+      "../external/ui_dev_npm/node_modules/@types",
+      "../external/plugins_npm/node_modules/@types",
+      /* typeRoots for IDE */
+      "../polygerrit-ui/node_modules/@types",
+      "../plugins/node_modules/@types"
+    ]
+  },
+}
diff --git a/plugins/webhooks b/plugins/webhooks
index dba493b..8df6303 160000
--- a/plugins/webhooks
+++ b/plugins/webhooks
@@ -1 +1 @@
-Subproject commit dba493b1679cc07b0f5e3fd9277b306c4693e08a
+Subproject commit 8df6303e652857c3d7cee3348d2cb7d40a7db1af
diff --git a/plugins/yarn.lock b/plugins/yarn.lock
index a63f96e..4cbe489 100644
--- a/plugins/yarn.lock
+++ b/plugins/yarn.lock
@@ -1,3 +1,61 @@
 # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
 # yarn lockfile v1
-# This is an empty placeholder
\ No newline at end of file
+
+
+"@gerritcodereview/typescript-api@3.4.4":
+  version "3.4.4"
+  resolved "https://registry.yarnpkg.com/@gerritcodereview/typescript-api/-/typescript-api-3.4.4.tgz#9f09687038088dd7edd3b4e30d249502eb21bfbc"
+  integrity sha512-MAiQwntcQ59b92yYDsVIXj3oBbAB4C7HELkLFFbYs4ZjzC43XqqtR9VF0dh5OUC8wzFZttgUiOmGehk9edpPuw==
+
+"@lit/reactive-element@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
+  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
+
+"@polymer/decorators@^3.0.0":
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
+  integrity sha512-qh+VID9nDV9q3ABvIfWgm7/+udl7v2HKsMLPXFm8tj1fI7qr7yWJMFwS3xWBkMmuNPtmkS8MDP0vqLAQIEOWzg==
+  dependencies:
+    "@polymer/polymer" "^3.0.5"
+
+"@polymer/polymer@^3.0.5", "@polymer/polymer@^3.4.1":
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/@polymer/polymer/-/polymer-3.4.1.tgz#333bef25711f8411bb5624fb3eba8212ef8bee96"
+  integrity sha512-KPWnhDZibtqKrUz7enIPOiO4ZQoJNOuLwqrhV2MXzIt3VVnUVJVG5ORz4Z2sgO+UZ+/UZnPD0jqY+jmw/+a9mQ==
+  dependencies:
+    "@webcomponents/shadycss" "^1.9.1"
+
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
+
+"@webcomponents/shadycss@^1.9.1":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
+  integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
+
+lit-element@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
+  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
+
+lit-html@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
+  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
+lit@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
index 7b33a59..3e1bb74 100644
--- a/polygerrit-ui/.gitignore
+++ b/polygerrit-ui/.gitignore
@@ -1,7 +1,8 @@
-node_modules
-npm-debug.log
-dist
-fonts
-bower_components
-.tmp
-.vscode
+/.tmp/
+/.vscode/
+/bower_components/
+/dist/
+/fonts/
+/node_modules/
+/npm-debug.log
+/package-lock.json
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 7bca96d..62d1d92 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,5 +1,6 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary")
 load("//tools/bzl:genrule2.bzl", "genrule2")
+load("//tools/bzl:js.bzl", "karma_test")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -33,8 +34,6 @@
     ],
 )
 
-# Define a karma+plugins binary to run karma-mocha tests.
-# Can be reused multiple time, if there are multiple karma test rules
 sh_binary(
     name = "karma_bin",
     srcs = ["@ui_dev_npm//:node_modules/karma/bin/karma"],
@@ -49,26 +48,8 @@
     ],
 )
 
-# Run all tests in one.
-# TODO(dmfilippov): allow parallel tests for karma - either on the bazel level
-# or on the karma level. For now single sh_test is enough.
-sh_test(
+karma_test(
     name = "karma_test",
-    size = "enormous",
     srcs = ["karma_test.sh"],
-    args = [
-        "$(location :karma_bin)",
-        "$(location karma.conf.js)",
-    ],
-    data = [
-        "karma.conf.js",
-        ":karma_bin",
-        "//polygerrit-ui/app:test-srcs-fg",
-    ],
-    # Should not run sandboxed.
-    tags = [
-        "karma",
-        "local",
-        "manual",
-    ],
+    data = ["//polygerrit-ui/app:test-srcs-fg"],
 )
diff --git a/polygerrit-ui/Polymer2.md b/polygerrit-ui/Polymer2.md
deleted file mode 100644
index e2a9124..0000000
--- a/polygerrit-ui/Polymer2.md
+++ /dev/null
@@ -1,19 +0,0 @@
-Note: Gerrit has moved to polymer 3 as of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
-
-The change is backward compatible, so no code change needed to support all plugins, but we would highly recommend to start moving to latest polymer 3 for all plugins, check out [Polymer3.md](./Polymer3.md) for more insights.
-
-## Polymer 2 upgrade
-
-Gerrit is updating to use polymer 2 from polymer 1 by following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade).
-
-Polymer 2 contains several breaking changes that may affect some of the UI features and plugins. One of the biggest change is to have the shadow DOM enabled. This will affect how you query elements inside of your component, how css style works within and across components, and several other usages.
-
-If you are owner of any plugins, please start following the [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade) to migrate your plugins to be polymer 2 ready.
-
-If you notice any issues or need help with anything, don't hesitate to report to us [here](https://bugs.chromium.org/p/gerrit/issues/list).
-
-
-### Related resources
-
-- [Polymer 2.0 upgrade guide](https://polymer-library.polymer-project.org/2.0/docs/upgrade)
-- [Polymer Shadow DOM](https://polymer-library.polymer-project.org/2.0/docs/devguide/shadow-dom)
diff --git a/polygerrit-ui/Polymer3.md b/polygerrit-ui/Polymer3.md
deleted file mode 100644
index 186f0f4..0000000
--- a/polygerrit-ui/Polymer3.md
+++ /dev/null
@@ -1,25 +0,0 @@
-## Gerrit in Polymer 3
-
-Gerrit has migrated to polymer 3 as of submitted of submitted of https://gerrit-review.googlesource.com/q/topic:%22bower+to+npm+packages+switch%22+(status:open%20OR%20status:merged).
-
-## Polymer 3 vs Polymer 2
-
-The biggest difference between 2 and 3 is the changing of package management from bower to npm and also replaced the html imports with es6 imports so we no longer need templates in separate `html` files for polymer components.
-
-### How that impact plugins
-
-As of now, we still support all syntax in Polymer 2 and most from Polymer 1 with the [legacy layer](https://polymer-library.polymer-project.org/3.0/docs/devguide/legacy-elements). But we do plan to remove those in the future.
-
-So we recommend all plugin owners to start migrating to Polymer 3 for your plugins. You can refer more about polymer 3 from the related resources section.
-
-To get inspirations, check out our [samples here](https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/app/samples).
-
-### Plugin dependencies
-
-Since most of Gerrit plugins are treated as sub modules and part of the Gerrit workspace when develop, dependencies of plugins are also defined and installed from Gerrit WORKSPACE, currently most of them are `bower_archives`. When moving to npm, if your plugin requires dependencies, you can have them added to your plugin's `package.json` and then link that file to `plugins/package.json` in gerrit.
-Then use `@plugins_npm//:node_modules` to make sure `rollup_bundle` knows the right place to look for. More examples from `image-diff` plugin, [change 271672](https://gerrit-review.googlesource.com/c/plugins/image-diff/+/271672).
-
-### Related resources
-
-- [Polymer 3.0 upgrade guide](https://polymer-library.polymer-project.org/3.0/docs/upgrade)
--[What's new in Polymer 3.0](https://polymer-library.polymer-project.org/3.0/docs/about_30)
\ No newline at end of file
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index c6e6cd9..cd88f52 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -4,12 +4,22 @@
 [setup instructions for Gerrit backend developers](https://gerrit-review.googlesource.com/Documentation/dev-readme.html)
 where applicable, the most important command is:
 
-```
+```sh
 git clone --recurse-submodules https://gerrit.googlesource.com/gerrit
 ```
 
 The --recurse-submodules option is needed on git clone to ensure that the core plugins, which are included as git submodules, are also cloned.
 
+Then make sure to install the commit-hook that will set up the `ChangeId` for
+each push to gerrit-reviews.
+
+```sh
+cd gerrit && (
+  cd .git/hooks
+  ln -s ../../resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+)
+```
+
 ## Installing [Bazel](https://bazel.build/)
 
 Follow the instructions
@@ -228,6 +238,56 @@
 the "Before launch" section for IntelliJ. This is a temporary problem until
 typescript migration is complete.
 
+## Running Templates Test
+The templates test validates polymer templates. The test convert polymer
+templates into a plain typescript code and then run TS compiler. The test fails
+if TS compiler reports errors; in this case you will see TS errors in
+the log/output. Gerrit-CI automatically runs templates test.
+
+**Note**: Files defined in `ignore_templates_list` (`polygerrit-ui/app/BUILD`)
+are excluded from code generation and checking. If you don't know how to fix
+a problem, you can add a problematic template in the list.
+
+* To run test locally, use npm command:
+``` sh
+npm run polytest
+```
+
+* Often, the output from the previous command is not clear (cryptic TS errors).
+In this case, run the command
+```sh
+npm run polytest:dev
+```
+This command (re)creates the `polygerrit-ui/app/tmpl_out` directory and put
+generated files into it. For each polygerrit .ts file there is a generated file
+in the `tmpl_out` directory. If an original file doesn't contain a polymer
+template, the generated file is empty.
+
+You can open a problematic file in IDE and fix the problem. Ensure, that IDE
+uses `polygerrit-ui/app/tsconfig.json` as a project (usually, this is default).
+
+### Generated file overview
+
+A generated file starts with imports followed by a static content with
+different type definitions. You can skip this part - it doesn't contains
+anything usefule.
+
+After the static content there is a class definition. Example:
+```typescript
+export class GrCreateGroupDialogCheck extends GrCreateGroupDialog {
+  templateCheck() {
+    // Converted template
+    // Each HTML element from the template is wrapped into own block.
+  }
+}
+```
+
+The converted template usually quite straightforward, but in some cases
+additional functions are added. For example, `<element x=[[y.a]]>` converts into
+`el.x = y!.a` if y is a simple type. However, if y has a union type, like - `y:A|B`,
+then the generated code looks like `el.x=__f(y)!.a` (`y!.a` may result in a TS error
+if `a` is defined only in one type of a union). 
+
 ## Style guide
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
diff --git a/polygerrit-ui/app/.eslintignore b/polygerrit-ui/app/.eslintignore
index 087a049..bb30f23 100644
--- a/polygerrit-ui/app/.eslintignore
+++ b/polygerrit-ui/app/.eslintignore
@@ -2,3 +2,4 @@
 **/rollup.config.js
 node_modules_licenses
 !.eslintrc-bazel.js
+tmpl_out
diff --git a/polygerrit-ui/app/.eslintrc.js b/polygerrit-ui/app/.eslintrc.js
index faf126c..14f9e8c 100644
--- a/polygerrit-ui/app/.eslintrc.js
+++ b/polygerrit-ui/app/.eslintrc.js
@@ -277,6 +277,18 @@
       },
     },
     {
+      files: ['**/api/*.ts'],
+      rules: {
+        'regex/invalid': [
+          'error', [{
+            regex: 'export interface',
+            message: 'All interfaces in the api/ dir must have "declare"',
+            replacement: 'export declare interface',
+          }],
+        ],
+      },
+    },
+    {
       files: ['**/*.ts'],
       extends: [require.resolve('gts/.eslintrc.json')],
       rules: {
@@ -400,12 +412,32 @@
         }],
       },
     },
+    {
+      files: ['*.ts'],
+      excludedFiles: '*_html.ts',
+      rules: {
+        'lit/attribute-value-entities': 'error',
+        'lit/binding-positions': 'error',
+        'lit/no-duplicate-template-bindings': 'error',
+        'lit/no-invalid-html': 'error',
+        'lit/no-legacy-template-syntax': 'error',
+        'lit/no-property-change-update': 'error',
+        'lit/no-invalid-escape-sequences': 'error',
+        'lit/no-legacy-imports': 'error',
+        'lit/no-private-properties': 'error',
+        'lit/no-useless-template-literals': 'error',
+        'lit/no-value-attribute': 'error',
+        'lit/prefer-static-styles': 'error',
+      },
+    },
   ],
   plugins: [
     'html',
     'jsdoc',
     'import',
+    'lit',
     'prettier',
+    'regex',
   ],
   settings: {
     'html/report-bad-indent': 'error',
diff --git a/polygerrit-ui/app/.gitignore b/polygerrit-ui/app/.gitignore
index c235144..c45bac3 100644
--- a/polygerrit-ui/app/.gitignore
+++ b/polygerrit-ui/app/.gitignore
@@ -1,2 +1,4 @@
-/plugins/
 /node_modules/
+/package-lock.json
+/plugins/
+/tmpl_out/
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 47521f6..56c5e62 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,5 +1,8 @@
-load(":rules.bzl", "compile_ts", "polygerrit_bundle")
+load(":rules.bzl", "polygerrit_bundle")
 load("//tools/js:eslint.bzl", "eslint")
+load("//tools/js:template_checker.bzl", "transform_polymer_templates")
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
 
 package(default_visibility = ["//visibility:public"])
 
@@ -20,7 +23,15 @@
     "utils",
 ]
 
-compiled_pg_srcs = compile_ts(
+ts_config(
+    name = "ts_config_bazel",
+    src = "tsconfig_bazel.json",
+    deps = [
+        "tsconfig.json",
+    ],
+)
+
+ts_project(
     name = "compile_pg",
     srcs = glob(
         [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
@@ -32,38 +43,151 @@
             "**/*_test.ts",
         ],
     ),
-    # The same outdir also appears in the following files:
-    # polylint_test.sh
-    ts_outdir = "_pg_ts_out",
+    allow_js = True,
+    incremental = True,
+    out_dir = "_pg_ts_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":ts_config_bazel",
+    deps = [
+        "@ui_npm//:node_modules",
+    ],
 )
 
-compiled_pg_srcs_with_tests = compile_ts(
+ts_config(
+    name = "ts_config_bazel_test",
+    src = "tsconfig_bazel_test.json",
+    deps = [
+        "tsconfig.json",
+        "tsconfig_bazel.json",
+    ],
+)
+
+ts_project(
     name = "compile_pg_with_tests",
     srcs = glob(
         [
             "**/*.js",
             "**/*.ts",
-            "test/@types/*.d.ts",
         ],
         exclude = [
             "node_modules/**",
             "node_modules_licenses/**",
-            "template_test_srcs/**",
+            "tmpl_out/**",  # This directory is created by template checker in dev-mode
             "rollup.config.js",
         ],
     ),
-    include_tests = True,
+    allow_js = True,
+    incremental = True,
     # The same outdir also appears in the following files:
     # wct_test.sh
     # karma.conf.js
-    ts_outdir = "_pg_with_tests_out",
+    out_dir = "_pg_with_tests_out",
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":ts_config_bazel_test",
+    deps = [
+        "@ui_dev_npm//:node_modules",
+        "@ui_npm//:node_modules",
+    ],
+)
+
+# Template checker reports problems in the following files. Ignore the files,
+# so template tests pass.
+# TODO: fix problems reported by template checker in these files.
+ignore_templates_list = [
+    "elements/admin/gr-admin-view/gr-admin-view_html.ts",
+    "elements/admin/gr-create-repo-dialog/gr-create-repo-dialog_html.ts",
+    "elements/admin/gr-group-members/gr-group-members_html.ts",
+    "elements/admin/gr-group/gr-group_html.ts",
+    "elements/admin/gr-permission/gr-permission_html.ts",
+    "elements/admin/gr-plugin-list/gr-plugin-list_html.ts",
+    "elements/admin/gr-repo-access/gr-repo-access_html.ts",
+    "elements/admin/gr-repo-commands/gr-repo-commands_html.ts",
+    "elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts",
+    "elements/admin/gr-repo/gr-repo_html.ts",
+    "elements/admin/gr-rule-editor/gr-rule-editor_html.ts",
+    "elements/change-list/gr-change-list-view/gr-change-list-view_html.ts",
+    "elements/change-list/gr-change-list/gr-change-list_html.ts",
+    "elements/change-list/gr-dashboard-view/gr-dashboard-view_html.ts",
+    "elements/change/gr-change-actions/gr-change-actions_html.ts",
+    "elements/change/gr-change-metadata/gr-change-metadata_html.ts",
+    "elements/change/gr-change-requirements/gr-change-requirements_html.ts",
+    "elements/change/gr-change-view/gr-change-view_html.ts",
+    "elements/change/gr-file-list-header/gr-file-list-header_html.ts",
+    "elements/change/gr-file-list/gr-file-list_html.ts",
+    "elements/change/gr-label-score-row/gr-label-score-row_html.ts",
+    "elements/change/gr-message/gr-message_html.ts",
+    "elements/change/gr-messages-list/gr-messages-list_html.ts",
+    "elements/change/gr-reply-dialog/gr-reply-dialog_html.ts",
+    "elements/change/gr-reviewer-list/gr-reviewer-list_html.ts",
+    "elements/change/gr-thread-list/gr-thread-list_html.ts",
+    "elements/diff/gr-diff-builder/gr-diff-builder-element_html.ts",
+    "elements/diff/gr-diff-host/gr-diff-host_html.ts",
+    "elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts",
+    "elements/diff/gr-diff-view/gr-diff-view_html.ts",
+    "elements/diff/gr-diff/gr-diff_html.ts",
+    "elements/diff/gr-patch-range-select/gr-patch-range-select_html.ts",
+    "elements/gr-app-element_html.ts",
+    "elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_html.ts",
+    "elements/shared/gr-account-list/gr-account-list_html.ts",
+]
+
+sources_for_template_checking = glob(
+    [src_dir + "/**/*" + ext for src_dir in src_dirs for ext in [
+        ".ts",
+    ]],
+    exclude = [
+        "**/*_test.ts",
+    ] + ignore_templates_list,
+)
+
+# Transform templates into a .ts files.
+templates_srcs = transform_polymer_templates(
+    name = "template_test",
+    srcs = sources_for_template_checking,
+    out_tsconfig = "tsconfig_template_test.json",
+    tsconfig = "tsconfig_bazel.json",
+    deps = [
+        "tsconfig.json",
+        "tsconfig_bazel.json",
+        "@ui_npm//:node_modules",
+    ],
+)
+
+# After templates are converted into a typescript code, the TS compiler should check that the
+# converted code doesn't have the error (i.e. templates don't have problems).
+# The input to the compiler is: the converted (i.e. autogenerated) code + original polygerrit code;
+# the output (i.e. js code) is not needed (we only care wheather the code has error or not).
+# The existing ts_project rule can't compile a mix of a generated and a non-generated code, so it
+# can't be used for the purpose of template checking.
+# Because the output of TS compiler is not needed, the simplest workaround is to run typescript
+# compiler from command line using the sh_test rule. The compiler exits with non-zero return code if
+# errors found and sh_test fails.
+sh_test(
+    name = "polylint_test",
+    srcs = [":compile_generated_templates.sh"],
+    args = [
+        "$(location //tools/node_tools:tsc-bin)",
+        "$(location tsconfig_template_test.json)",
+    ],
+    data = [
+        "tsconfig_template_test.json",
+        "tsconfig_bazel.json",
+        "tsconfig.json",
+        "//tools/node_tools:tsc-bin",
+        "@ui_npm//:node_modules",
+    ] + templates_srcs + sources_for_template_checking,
+    tags = [
+        "local",
+        "manual",
+    ],
 )
 
 polygerrit_bundle(
     name = "polygerrit_ui",
-    srcs = compiled_pg_srcs,
+    srcs = [":compile_pg"],
     outs = ["polygerrit_ui.zip"],
-    entry_point = "_pg_ts_out/elements/gr-app.js",
+    app_name = "gr-app",
+    entry_point = "_pg_ts_out/elements/gr-app-entry-point.js",
 )
 
 filegroup(
@@ -94,7 +218,7 @@
             "node_modules/**",
             "node_modules_licenses/**",
         ],
-    ) + compiled_pg_srcs_with_tests,
+    ) + [":compile_pg_with_tests"],
 )
 
 # Workaround for https://github.com/bazelbuild/bazel/issues/1305
@@ -135,37 +259,38 @@
         "@npm//eslint-plugin-html",
         "@npm//eslint-plugin-import",
         "@npm//eslint-plugin-jsdoc",
+        "@npm//eslint-plugin-lit",
         "@npm//eslint-plugin-prettier",
+        "@npm//eslint-plugin-regex",
         "@npm//gts",
     ],
 )
 
 filegroup(
-    name = "polylint-fg",
-    srcs = [
-        # Workaround for https://github.com/bazelbuild/bazel/issues/1305
+    name = "lit_analysis_src_code",
+    srcs = glob(
+        ["**/*.ts"],
+        exclude = [
+            "**/*_html.ts",
+            "**/*_test.ts",
+        ],
+    ) + [
+        "@ui_dev_npm//:node_modules",
         "@ui_npm//:node_modules",
-    ] +
-    # Polylinter can't check .ts files, run it on compiled srcs
-    compiled_pg_srcs,
+    ],
 )
 
-sh_test(
-    name = "polylint_test",
-    size = "large",
-    srcs = ["polylint_test.sh"],
-    args = [
-        "$(location @tools_npm//polymer-cli/bin:polymer)",
-        "$(location polymer.json)",
-    ],
+nodejs_binary(
+    name = "lit_analysis",
     data = [
-        "polymer.json",
-        ":polylint-fg",
-        "@tools_npm//polymer-cli/bin:polymer",
+        ":lit_analysis_src_code",
+        "@npm//lit-analyzer",
     ],
-    # Should not run sandboxed.
-    tags = [
-        "local",
-        "manual",
+    entry_point = "@npm//:node_modules/lit-analyzer/cli.js",
+    templated_args = [
+        "**/elements/**/*.ts",
+        "--strict",
+        "--rules.no-property-visibility-mismatch off",
+        "--rules.no-incompatible-property-type off",
     ],
 )
diff --git a/polygerrit-ui/app/api/BUILD_for_publishing_api_only b/polygerrit-ui/app/api/BUILD_for_publishing_api_only
new file mode 100644
index 0000000..9d3029b
--- /dev/null
+++ b/polygerrit-ui/app/api/BUILD_for_publishing_api_only
@@ -0,0 +1,50 @@
+# This BUILD file is only for publishing the
+# "Gerrit Frontend Plugin TypeScript API" as an npm package.
+#
+# Publishing procedure:
+# - Execute the `publish.sh` script from the Gerrit root dir.
+# - Verify that the contents look good.
+# - Increment the version in package.json.
+# - Execute `publish.sh --upload`.
+#
+# NB: Renaming to 'BUILD' breaks the app/BUILD, because then the api/ sources
+# are not visible anymore to the parent BUILD. And if ts_projects depend on each
+# other, then the api/ files would have to be imported with their full package
+# names.
+load("@build_bazel_rules_nodejs//:index.bzl", "pkg_npm")
+load("@npm//@bazel/typescript:index.bzl", "ts_config", "ts_project")
+
+filegroup(
+    name = "js_plugin_api_srcs",
+    srcs = glob(["**/*.ts"]),
+)
+
+ts_config(
+    name = "ts_config",
+    src = "tsconfig.json",
+    deps = [
+        "//plugins:tsconfig-plugins-base.json",
+    ],
+)
+
+ts_project(
+    name = "js_plugin_api_compiled",
+    srcs = glob(["**/*.ts"]),
+    incremental = True,
+    tsc = "//tools/node_tools:tsc-bin",
+    tsconfig = ":ts_config",
+)
+
+# Use this rule for publishing the js plugin api as a package to the npm repo.
+pkg_npm(
+    name = "js_plugin_api_npm_package",
+    srcs = glob(
+        ["**/*"],
+        exclude = [
+            "BUILD",
+            "tsconfig.json",
+            "publish.sh",
+        ],
+    ),
+    deps = [":js_plugin_api_compiled"],
+)
diff --git a/polygerrit-ui/app/api/README.md b/polygerrit-ui/app/api/README.md
index 550063f..b5710bf 100644
--- a/polygerrit-ui/app/api/README.md
+++ b/polygerrit-ui/app/api/README.md
@@ -1,23 +1,25 @@
-# API
+# Gerrit TypeScript Plugin API
 
-In this folder, we declare the API of various parts of the Gerrit webclient.
-There are two primary use cases for this:
+This package contains the types for developing browser plugins for the
+Gerrit Code Review web application. General documentation for plugin
+developers can be found at
+[gerrit-review.googlesource.com](https://gerrit-review.googlesource.com/Documentation/pg-plugin-dev.html).
 
-* apps that embed our diff viewer, gr-diff
-* Gerrit plugins that need to access some part of Gerrit to extend it
+The `.ts` files only contain types, interfaces and enums, and thus the compiled
+`.js` files only contain the enums. For JavaScript plugins this package is not
+really useful or necessary, but it also serves as the source of truth for
+what plugin APIs are actually supported.
 
-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.
+Versioning of this API matches the MAJOR and MINOR versions of the general
+Gerrit releases, but the PATCH version is independent. When you are building
+a plugin for Gerrit x.y.z, then you should use the API package x.y.n, where
+n is the highest available patch version of the API. Patch versions will only
+contain additions and fixes, minor versions may include API removals.
 
 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
+bundles. 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
+This API is also 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/api/admin.ts b/polygerrit-ui/app/api/admin.ts
index a7b549d..0606153 100644
--- a/polygerrit-ui/app/api/admin.ts
+++ b/polygerrit-ui/app/api/admin.ts
@@ -16,13 +16,13 @@
  */
 
 /** Interface for menu link */
-export interface MenuLink {
+export declare interface MenuLink {
   text: string;
   url: string;
   capability: string | null;
 }
 
-export interface AdminPluginApi {
+export declare 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
index bd4f399..5922e5e 100644
--- a/polygerrit-ui/app/api/annotation.ts
+++ b/polygerrit-ui/app/api/annotation.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import {CoverageRange, Side} from './diff';
+import {ChangeInfo} from './rest-api';
 
 /**
  * This is the callback object that Gerrit calls once for each diff. Gerrit
@@ -26,17 +27,10 @@
   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>>;
+  change?: ChangeInfo
+) => Promise<Array<CoverageRange> | undefined>;
 
-export interface AnnotationPluginApi {
+export declare interface AnnotationPluginApi {
   /**
    * The specified function will be called when a gr-diff component is built,
    * and feeds the returned coverage data into the diff. Optional.
diff --git a/polygerrit-ui/app/api/attribute-helper.ts b/polygerrit-ui/app/api/attribute-helper.ts
index cd52259..d813beb 100644
--- a/polygerrit-ui/app/api/attribute-helper.ts
+++ b/polygerrit-ui/app/api/attribute-helper.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-export interface AttributeHelperPluginApi {
+export declare interface AttributeHelperPluginApi {
   /**
    * Binds callback to property updates.
    *
diff --git a/polygerrit-ui/app/api/change-actions.ts b/polygerrit-ui/app/api/change-actions.ts
index 792f31e..4380195 100644
--- a/polygerrit-ui/app/api/change-actions.ts
+++ b/polygerrit-ui/app/api/change-actions.ts
@@ -16,7 +16,7 @@
  */
 import {HttpMethod} from './rest';
 
-export interface ActionInfo {
+export declare interface ActionInfo {
   method?: HttpMethod;
   label?: string;
   title?: string;
@@ -59,6 +59,7 @@
   UNIGNORE = 'unignore',
   UNREVIEWED = 'unreviewed',
   WIP = 'wip',
+  INCLUDED_IN = 'includedIn',
 }
 
 export enum RevisionActions {
@@ -70,7 +71,10 @@
 
 export type PrimaryActionKey = ChangeActions | RevisionActions;
 
-export interface ChangeActionsPluginApi {
+export declare interface ChangeActionsPluginApi {
+  // Deprecated. This API method will be removed.
+  ensureEl(): Element;
+
   addPrimaryActionKey(key: PrimaryActionKey): void;
 
   removePrimaryActionKey(key: string): void;
diff --git a/polygerrit-ui/app/api/change-reply.ts b/polygerrit-ui/app/api/change-reply.ts
index 6016004..0b00b10 100644
--- a/polygerrit-ui/app/api/change-reply.ts
+++ b/polygerrit-ui/app/api/change-reply.ts
@@ -14,17 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-export interface LabelsChangedDetail {
+import {ChangeInfo} from './rest-api';
+
+export declare interface LabelsChangedDetail {
   name: string;
   value: string;
 }
-export interface ValueChangedDetail {
+export declare interface ValueChangedDetail {
   value: string;
 }
-export type ReplyChangedCallback = (text: string) => void;
-export type LabelsChangedCallback = (detail: LabelsChangedDetail) => void;
+export type ReplyChangedCallback = (text: string, change?: ChangeInfo) => void;
+export type LabelsChangedCallback = (
+  detail: LabelsChangedDetail,
+  change?: ChangeInfo
+) => void;
 
-export interface ChangeReplyPluginApi {
+export declare interface ChangeReplyPluginApi {
   getLabelValue(label: string): string;
 
   setLabelValue(label: string, value: string): void;
diff --git a/polygerrit-ui/app/api/checks.ts b/polygerrit-ui/app/api/checks.ts
index ee4ea9e..d52a555 100644
--- a/polygerrit-ui/app/api/checks.ts
+++ b/polygerrit-ui/app/api/checks.ts
@@ -14,8 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {CommentRange} from './core';
+import {ChangeInfo} from './rest-api';
 
-export interface ChecksPluginApi {
+export declare interface ChecksPluginApi {
   /**
    * Must only be called once. You cannot register twice. You cannot unregister.
    */
@@ -27,9 +29,26 @@
    * polling interval to pass.
    */
   announceUpdate(): void;
+
+  /**
+   * Updates an individual result.
+   *
+   * This can be used for lazy loading detailed information. For example, if you
+   * are using the `check-result-expanded` endpoint, then you can load more
+   * result details when the user expands a result row.
+   *
+   * The parameter `run` is only used to *find* the correct run for updating the
+   * result. It will only be used for comparing `change`, `patchset`, `attempt`
+   * and `checkName`. Its properties other than `results` will not be updated.
+   *
+   * For us being able to identify the result that you want to update you have
+   * to set the `externalId` property. An undefined `externalId` will result in
+   * an error.
+   */
+  updateResult(run: CheckRun, result: CheckResult): void;
 }
 
-export interface ChecksApiConfig {
+export declare 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?
@@ -38,17 +57,16 @@
   fetchPollingIntervalSeconds: number;
 }
 
-export interface ChangeData {
+export declare interface ChangeData {
   changeNumber: number;
   patchsetNumber: number;
   patchsetSha: string;
   repo: string;
-  commmitMessage?: string;
-  /* TODO(brohlfs): Add dep to Rest API types and replace type by ChangeInfo. */
-  changeInfo: unknown;
+  commitMessage?: string;
+  changeInfo: ChangeInfo;
 }
 
-export interface ChecksProvider {
+export declare interface ChecksProvider {
   /**
    * Gerrit calls this method when ...
    * - ... the change or diff page is loaded.
@@ -59,7 +77,7 @@
   fetch(change: ChangeData): Promise<FetchResponse>;
 }
 
-export interface FetchResponse {
+export declare interface FetchResponse {
   responseCode: ResponseCode;
 
   /** Only relevant when the responseCode is ERROR. */
@@ -102,7 +120,7 @@
  * 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 {
+export declare 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
@@ -126,8 +144,8 @@
    * 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.
+   * The attempt number must be >=0. Only if you have just one RUNNABLE attempt,
+   * then you can leave it undefined.
    *
    * TBD: Optionally providing aggregate information about former attempts will
    * probably be a useful feature, but we are deferring the exact data modeling
@@ -166,7 +184,8 @@
 
   /**
    * RUNNABLE:  Not run (yet). Mostly useful for runs that the user can trigger
-   *            (see actions). Cannot contain results.
+   *            (see actions) and for indicating that a check was not run at a
+   *            later attempt. 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
@@ -221,17 +240,30 @@
   results?: CheckResult[];
 }
 
-export interface Action {
+export declare interface Action {
   name: string;
   tooltip?: string;
   /**
-   * TODO: Maybe drop this property? Do we really need it?
-   *
    * 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;
+  primary?: boolean;
+  /**
+   * Summary actions will get an even more prominent treatment in the UI. They
+   * will show up in the checks summary right below the commit message. This
+   * only affects top-level actions (i.e. actions in FetchResponse).
+   */
+  summary?: boolean;
+  /**
+   * Renders the action button in a disabled state. That can be useful for
+   * actions that are present most of the time, but sometimes don't apply. Then
+   * a grayed out button with a tooltip makes it easier for the user to
+   * understand why an expected action is not available. The tooltip should then
+   * contain a message about why the disabled state was set, not just about what
+   * the action would normally do.
+   */
+  disabled?: boolean;
   callback: ActionCallback;
 }
 
@@ -272,7 +304,7 @@
  * If `message` is empty or undefined, then the `Triggering ...` toast will just
  * be hidden and no further toast will be shown.
  */
-export interface ActionResult {
+export declare interface ActionResult {
   /** An empty errorMessage means success. */
   message?: string;
   /**
@@ -290,7 +322,7 @@
   COMPLETED = 'COMPLETED',
 }
 
-export interface CheckResult {
+export declare interface CheckResult {
   /**
    * An optional opaque identifier not used by Gerrit directly, but might be
    * used by plugin extensions and callbacks.
@@ -298,6 +330,11 @@
   externalId?: string;
 
   /**
+   * SUCCESS: Indicates that some build, test or check is passing. A COMPLETED
+   *          run without results will also be treated as "passing" and will get
+   *          an artificial SUCCESS result. But you can also make this explicit,
+   *          which also allows one run to have multiple "passing" results,
+   *          maybe along with results of other categories.
    * 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
@@ -370,6 +407,11 @@
   links?: Link[];
 
   /**
+   * Links to lines of code. The referenced path must be part of this patchset.
+   */
+  codePointers?: CodePointer[];
+
+  /**
    * 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
@@ -383,12 +425,13 @@
 }
 
 export enum Category {
+  SUCCESS = 'SUCCESS',
   INFO = 'INFO',
   WARNING = 'WARNING',
   ERROR = 'ERROR',
 }
 
-export interface Tag {
+export declare interface Tag {
   name: string;
   tooltip?: string;
   color?: TagColor;
@@ -404,13 +447,11 @@
   BROWN = 'brown',
 }
 
-export interface Link {
+export declare interface Link {
   /** Must begin with 'http'. */
   url: string;
   tooltip?: string;
   /**
-   * TODO: Maybe drop this property? Do we really need it?
-   *
    * 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.
@@ -419,6 +460,11 @@
   icon: LinkIcon;
 }
 
+export declare interface CodePointer {
+  path: string;
+  range: CommentRange;
+}
+
 export enum LinkIcon {
   EXTERNAL = 'external',
   IMAGE = 'image',
@@ -427,4 +473,6 @@
   DOWNLOAD_MOBILE = 'download_mobile',
   HELP_PAGE = 'help_page',
   REPORT_BUG = 'report_bug',
+  CODE = 'code',
+  FILE_PRESENT = 'file_present',
 }
diff --git a/polygerrit-ui/app/api/core.ts b/polygerrit-ui/app/api/core.ts
index 5820139..af7fc40 100644
--- a/polygerrit-ui/app/api/core.ts
+++ b/polygerrit-ui/app/api/core.ts
@@ -45,3 +45,25 @@
   /** The character position in the end line. (0-based) */
   end_character: number;
 }
+
+/**
+ * Return type for cursor moves, that indicate whether a move was possible.
+ */
+export enum CursorMoveResult {
+  /** The cursor was successfully moved. */
+  MOVED,
+  /** There were no stops - the cursor was reset. */
+  NO_STOPS,
+  /**
+   * There was no more matching stop to move to - the cursor was clipped to the
+   * end.
+   */
+  CLIPPED,
+  /** The abort condition would have been fulfilled for the new target. */
+  ABORTED,
+}
+
+/** A sentinel that can be inserted to disallow moving across. */
+export class AbortStop {}
+
+export type Stop = HTMLElement | AbortStop;
diff --git a/polygerrit-ui/app/api/diff.ts b/polygerrit-ui/app/api/diff.ts
index 9cd8cb3..905d6be 100644
--- a/polygerrit-ui/app/api/diff.ts
+++ b/polygerrit-ui/app/api/diff.ts
@@ -20,7 +20,7 @@
  * limitations under the License.
  */
 
-import {CommentRange} from './core';
+import {CommentRange, CursorMoveResult} from './core';
 
 /**
  * Diff type in preferences
@@ -53,6 +53,35 @@
 }
 
 /**
+ * Represents a "generic" text range in the code (e.g. text selection)
+ */
+export declare interface TextRange {
+  /** first line of the range (1-based inclusive). */
+  start_line: number;
+  /** first column of the range (in the first line) (1-based inclusive). */
+  start_column: number;
+  /** last line of the range (1-based inclusive). */
+  end_line: number;
+  /** last column of the range (in the end line) (1-based inclusive). */
+  end_column: number;
+}
+
+/**
+ * Represents a syntax block in a code (e.g. method, function, class, if-else).
+ */
+export declare interface SyntaxBlock {
+  /** Name of the block (e.g. name of the method/class)*/
+  name: string;
+  /**
+   * Where does this block syntatically starts and ends (line number and
+   * column).
+   */
+  range: TextRange;
+  /** Sub-blocks of the current syntax block (e.g. methods of a class) */
+  children: SyntaxBlock[];
+}
+
+/**
  * The DiffFileMetaInfo entity contains meta information about a file diff.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#diff-file-meta-info
  */
@@ -65,6 +94,12 @@
   lines: number;
   // TODO: Not documented.
   language?: string;
+  /**
+   * The first level of syntax blocks tree (outline) within the current file.
+   * It contains an hierarchical structure where each block contains its
+   * sub-blocks (children).
+   */
+  syntax_tree?: SyntaxBlock[];
 }
 
 export declare type ChangeType =
@@ -171,13 +206,45 @@
   font_size: number;
   // TODO: Missing documentation
   show_file_comment_button?: boolean;
+  line_wrapping?: boolean;
 }
 
+/**
+ * Event details when a token is highlighted.
+ */
+export declare interface TokenHighlightEventDetails {
+  token: string;
+  element: Element;
+  side: Side;
+  range: TextRange;
+}
+
+/**
+ * Listens to changes in token highlighting - when a new token starts or stopped
+ * being highlighted. undefined is sent if the event is about a clear in
+ * highlighting.
+ */
+export type TokenHighlightListener = (
+  tokenHighlightEvent?: TokenHighlightEventDetails
+) => void;
+
+export declare interface ImageDiffPreferences {
+  automatic_blink?: boolean;
+}
+
+export declare type DiffResponsiveMode =
+  | 'FULL_RESPONSIVE'
+  | 'SHRINK_ONLY'
+  | 'NONE';
 export declare interface RenderPreferences {
   hide_left_side?: boolean;
   disable_context_control_buttons?: boolean;
   show_file_comment_button?: boolean;
   hide_line_length_indicator?: boolean;
+  use_block_expansion?: boolean;
+  image_diff_prefs?: ImageDiffPreferences;
+  responsive_mode?: DiffResponsiveMode;
+  num_lines_rendered_at_once?: number;
 }
 
 /**
@@ -251,6 +318,51 @@
   lineNum: LineNumber;
 }
 
+export declare interface LineNumberEventDetail {
+  side: Side;
+  lineNum: LineNumber;
+}
+
+/** All types of button for expanding diff sections */
+export enum ContextButtonType {
+  ABOVE = 'above',
+  BELOW = 'below',
+  BLOCK_ABOVE = 'block-above',
+  BLOCK_BELOW = 'block-below',
+  ALL = 'all',
+}
+
+/** Details to be externally accessed when expanding diffs */
+export declare interface DiffContextExpandedExternalDetail {
+  expandedLines: number;
+  buttonType: ContextButtonType;
+}
+
+/**
+ * Details to be externally accessed when hovering context
+ * expansion buttons
+ */
+export declare interface DiffContextButtonHoveredDetail {
+  linesToExpand: number;
+  buttonType: ContextButtonType;
+}
+
+export declare type ImageDiffAction =
+  | {type: 'overview-image-clicked'}
+  | {type: 'overview-frame-dragged'}
+  | {type: 'magnifier-clicked'}
+  | {type: 'magnifier-dragged'}
+  | {type: 'version-switcher-clicked'; button: 'base' | 'revision' | 'switch'}
+  | {
+      type: 'highlight-changes-changed';
+      value: boolean;
+      source: 'controls' | 'magnifier';
+    }
+  | {type: 'zoom-level-changed'; scale: number | 'fit'}
+  | {type: 'follow-mouse-changed'; value: boolean}
+  | {type: 'background-color-changed'; value: string}
+  | {type: 'automatic-blink-changed'; value: boolean};
+
 export enum GrDiffLineType {
   ADD = 'add',
   BOTH = 'both',
@@ -281,10 +393,96 @@
    * @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.
+   * @param side Which side of the diff is being annotated.
    */
   annotate(
     textElement: HTMLElement,
     lineNumberElement: HTMLElement,
-    line: GrDiffLine
+    line: GrDiffLine,
+    side: Side
   ): void;
 }
+
+/** Data used by GrAnnotation to generate elements. */
+export declare interface ElementSpec {
+  tagName: string;
+  attributes?: {[key: string]: unknown};
+}
+
+/** Used to annotate segments of an HTMLElement with a class string. */
+export declare interface GrAnnotation {
+  /**
+   * Annotates the [offset, offset+length) text segment in the parent with the
+   * element definition provided as arguments.
+   *
+   * @param parent the node whose contents will be annotated.
+   * If parent is Text then parent.parentNode must not be null
+   * @param offset the 0-based offset from which the annotation will
+   * start.
+   * @param length of the annotated text.
+   * @param elementSpec the spec to create the
+   * annotating element.
+   */
+  annotateWithElement(
+    el: HTMLElement,
+    start: number,
+    length: number,
+    elementSpec: ElementSpec
+  ): void;
+
+  /**
+   * Surrounds the element's text at specified range in an ANNOTATION_TAG
+   * element. If the element has child elements, the range is split and
+   * applied as deeply as possible.
+   */
+  annotateElement(
+    el: HTMLElement,
+    start: number,
+    length: number,
+    className: string
+  ): void;
+}
+
+/** An instance of the GrDiff Webcomponent */
+export declare interface GrDiff extends HTMLElement {
+  /**
+   * Return line number element for reading only,
+   *
+   * This is useful e.g. to determine where on screen certain lines are,
+   * whether they are covered up etc.
+   */
+  getLineNumEls(side: Side): readonly HTMLElement[];
+}
+
+/** A service to interact with the line cursor in gr-diff instances. */
+export declare interface GrDiffCursor {
+  // The current setup requires API users to register GrDiff instances with the
+  // cursor, but we do not at this point want to expose the API that GrDiffCursor
+  // uses to the public as it is likely to change. So for now, we allow any type
+  // and cast. This works fine so long as API users do provide whatever the
+  // gr-diff tag creates.
+  replaceDiffs(diffs: unknown[]): void;
+  unregisterDiff(diff: unknown): void;
+
+  isAtStart(): boolean;
+  isAtEnd(): boolean;
+
+  moveLeft(): void;
+  moveRight(): void;
+
+  moveDown(): CursorMoveResult;
+  moveUp(): CursorMoveResult;
+
+  moveToFirstChunk(): void;
+  moveToLastChunk(): void;
+
+  moveToNextChunk(): CursorMoveResult;
+  moveToPreviousChunk(): CursorMoveResult;
+
+  moveToNextCommentThread(): CursorMoveResult;
+  moveToPreviousCommentThread(): CursorMoveResult;
+
+  createCommentInPlace(): void;
+  resetScrollMode(): void;
+  moveToLineNumber(lineNum: number, side: Side, path?: string): void;
+}
diff --git a/polygerrit-ui/app/api/embed.ts b/polygerrit-ui/app/api/embed.ts
new file mode 100644
index 0000000..520aeec
--- /dev/null
+++ b/polygerrit-ui/app/api/embed.ts
@@ -0,0 +1,43 @@
+/**
+ * @fileoverview The API of class exported globally in embed/gr-diff.ts
+ *
+ * This is a mechanism to make classes accessible to separately compiled
+ * bundles, which cannot directly import the classes from their modules.
+ *
+ * @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 {
+  DiffLayer,
+  GrAnnotation,
+  GrDiffCursor,
+  TokenHighlightListener,
+} from './diff';
+
+declare global {
+  interface Window {
+    grdiff: {
+      GrAnnotation: GrAnnotation;
+      GrDiffCursor: {new (): GrDiffCursor};
+      TokenHighlightLayer: {
+        new (
+          container?: HTMLElement,
+          listener?: TokenHighlightListener
+        ): DiffLayer;
+      };
+    };
+  }
+}
diff --git a/polygerrit-ui/app/api/event-helper.ts b/polygerrit-ui/app/api/event-helper.ts
index 16c327d..5dc15dc 100644
--- a/polygerrit-ui/app/api/event-helper.ts
+++ b/polygerrit-ui/app/api/event-helper.ts
@@ -16,7 +16,7 @@
  */
 export type UnsubscribeCallback = () => void;
 
-export interface EventHelperPluginApi {
+export declare interface EventHelperPluginApi {
   /**
    * Alias for @see onClick
    */
diff --git a/polygerrit-ui/app/api/gerrit.ts b/polygerrit-ui/app/api/gerrit.ts
new file mode 100644
index 0000000..2091eea
--- /dev/null
+++ b/polygerrit-ui/app/api/gerrit.ts
@@ -0,0 +1,35 @@
+/**
+ * @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 {PluginApi} from './plugin';
+import {Styles} from './styles';
+
+declare global {
+  interface Window {
+    Gerrit: Gerrit;
+    VERSION_INFO?: string;
+    ENABLED_EXPERIMENTS?: string[];
+  }
+}
+
+export declare interface Gerrit {
+  install(
+    callback: (plugin: PluginApi) => void,
+    opt_version?: string,
+    src?: string
+  ): void;
+  styles: Styles;
+}
diff --git a/polygerrit-ui/app/api/hook.ts b/polygerrit-ui/app/api/hook.ts
index 179b967..e75d83a 100644
--- a/polygerrit-ui/app/api/hook.ts
+++ b/polygerrit-ui/app/api/hook.ts
@@ -14,31 +14,34 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-interface GerritElementExtensions {
+import {ChangeInfo, ConfigInfo, RevisionInfo} from './rest-api';
+import {PluginApi} from './plugin';
+
+export declare interface GerritElementExtensions {
   content?: HTMLElement & {hidden?: boolean};
-  change?: unknown;
-  revision?: unknown;
+  change?: ChangeInfo;
+  revision?: RevisionInfo;
   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;
+  config?: ConfigInfo;
+  plugin?: PluginApi;
 }
 
-export type HookCallback = (el: HTMLElement & GerritElementExtensions) => void;
+export type PluginElement = HTMLElement & GerritElementExtensions;
 
-export interface RegisterOptions {
+export type HookCallback<T extends PluginElement> = (el: T) => void;
+
+export declare interface RegisterOptions {
+  /** Defaults to empty string. */
   slot?: string;
-  replace: unknown;
+  /** Defaults to false. */
+  replace?: boolean;
 }
 
-export interface HookApi {
-  onAttached(callback: HookCallback): HookApi;
+export declare interface HookApi<T extends PluginElement> {
+  onAttached(callback: HookCallback<T>): HookApi<T>;
 
-  onDetached(callback: HookCallback): HookApi;
+  onDetached(callback: HookCallback<T>): HookApi<T>;
 
   getAllAttached(): HTMLElement[];
 
diff --git a/polygerrit-ui/app/api/package.json b/polygerrit-ui/app/api/package.json
new file mode 100644
index 0000000..8af6832
--- /dev/null
+++ b/polygerrit-ui/app/api/package.json
@@ -0,0 +1,9 @@
+{
+  "name": "@gerritcodereview/typescript-api",
+  "version": "3.4.4",
+  "description": "Gerrit Code Review - TypeScript API",
+  "homepage": "https://www.gerritcodereview.com/",
+  "browser": true,
+  "dependencies": {},
+  "license": "Apache-2.0"
+}
diff --git a/polygerrit-ui/app/api/plugin.ts b/polygerrit-ui/app/api/plugin.ts
index 0aadb38..7a56ff7 100644
--- a/polygerrit-ui/app/api/plugin.ts
+++ b/polygerrit-ui/app/api/plugin.ts
@@ -48,7 +48,7 @@
   HIGHLIGHTJS_LOADED = 'highlightjs-loaded',
 }
 
-export interface PluginApi {
+export declare interface PluginApi {
   _url?: URL;
   admin(): AdminPluginApi;
   annotationApi(): AnnotationPluginApi;
@@ -58,22 +58,25 @@
   checks(): ChecksPluginApi;
   eventHelper(element: Node): EventHelperPluginApi;
   getPluginName(): string;
-  hook(endpointName: string, opt_options?: RegisterOptions): HookApi;
+  hook<T extends HTMLElement>(
+    endpointName: string,
+    opt_options?: RegisterOptions
+  ): HookApi<T>;
   // 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>;
-  registerCustomComponent(
+  registerCustomComponent<T extends HTMLElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi;
-  registerDynamicCustomComponent(
+  ): HookApi<T>;
+  registerDynamicCustomComponent<T extends HTMLElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi;
+  ): HookApi<T>;
   registerStyleModule(endpoint: string, moduleName: string): void;
   reporting(): ReportingPluginApi;
   restApi(): RestPluginApi;
diff --git a/polygerrit-ui/app/api/popup.ts b/polygerrit-ui/app/api/popup.ts
index 60772cc..d265ee6 100644
--- a/polygerrit-ui/app/api/popup.ts
+++ b/polygerrit-ui/app/api/popup.ts
@@ -15,11 +15,12 @@
  * limitations under the License.
  */
 
-export interface PopupPluginApi {
+export declare 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.
+   * Opens the popup, inserts it into the DOM over current UI.
+   * Creates the popup if not previously created. Creates and inserts the popup
+   * content element, if a `moduleName` was provided in the constructor.
+   * Otherwise you have to call `appendContent()` when the promise resolves.
    */
   open(): Promise<PopupPluginApi>;
 
@@ -27,4 +28,10 @@
    * Hides the popup.
    */
   close(): void;
+
+  /**
+   * Appends the given element as a child to the popup. Only call this method
+   * when you have called `popup()` without a `moduleName`.
+   */
+  appendContent(el: HTMLElement): void;
 }
diff --git a/polygerrit-ui/app/api/publish.sh b/polygerrit-ui/app/api/publish.sh
new file mode 100755
index 0000000..16de4c9
--- /dev/null
+++ b/polygerrit-ui/app/api/publish.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+# Should be executed from the root of the Gerrit repo:
+# polygerrit-ui/app/api/publish.sh
+#
+# Builds the npm package @gerritcodereview/typescript-api
+#
+# Adding the `--upload` argument will also publish the package.
+
+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
+api_path=polygerrit-ui/app/api
+
+function cleanup() {
+  echo "Cleaning up ..."
+  rm -f ${api_path}/BUILD
+}
+trap cleanup EXIT
+cp ${api_path}/BUILD_for_publishing_api_only ${api_path}/BUILD
+
+${bazel_bin} build //${api_path}:js_plugin_api_npm_package
+
+if [ "$1" == "--upload" ]; then
+  echo 'Uploading npm package @gerritcodereview/typescript-api'
+  ${bazel_bin} run //${api_path}:js_plugin_api_npm_package.publish
+fi
diff --git a/polygerrit-ui/app/api/reporting.ts b/polygerrit-ui/app/api/reporting.ts
index 65bdc3f..c3655bb 100644
--- a/polygerrit-ui/app/api/reporting.ts
+++ b/polygerrit-ui/app/api/reporting.ts
@@ -18,7 +18,7 @@
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export type EventDetails = any;
 
-export interface ReportingPluginApi {
+export declare interface ReportingPluginApi {
   reportInteraction(eventName: string, details?: EventDetails): void;
 
   reportLifeCycle(eventName: string, details?: EventDetails): void;
diff --git a/polygerrit-ui/app/api/rest-api.ts b/polygerrit-ui/app/api/rest-api.ts
new file mode 100644
index 0000000..39b40b6
--- /dev/null
+++ b/polygerrit-ui/app/api/rest-api.ts
@@ -0,0 +1,1110 @@
+/**
+ * @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.
+ */
+
+/**
+ * rest-api.ts contains all entities from the Gerrit REST API that are also
+ * relevant to plugins and gr-diff users. These entities should be exactly what
+ * the backend defines and returns and should eventually be generated.
+ *
+ * Sorting order:
+ * - enums in alphabetical order
+ * - types and interfaces in alphabetical order
+ *   - type checking functions after their corresponding type
+ */
+
+/**
+ * enums =======================================================================
+ */
+
+/**
+ * The authentication type that is configured on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum AuthType {
+  OPENID = 'OPENID',
+  OPENID_SSO = 'OPENID_SSO',
+  OAUTH = 'OAUTH',
+  HTTP = 'HTTP',
+  HTTP_LDAP = 'HTTP_LDAP',
+  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
+  LDAP = 'LDAP',
+  LDAP_BIND = 'LDAP_BIND',
+  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
+  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
+}
+
+/**
+ * @desc Specifies status for a change
+ */
+export enum ChangeStatus {
+  ABANDONED = 'ABANDONED',
+  MERGED = 'MERGED',
+  NEW = 'NEW',
+}
+
+/**
+ * The type in ConfigParameterInfo entity.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export enum ConfigParameterInfoType {
+  // Should be kept in sync with
+  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
+  STRING = 'STRING',
+  INT = 'INT',
+  LONG = 'LONG',
+  BOOLEAN = 'BOOLEAN',
+  LIST = 'LIST',
+  ARRAY = 'ARRAY',
+}
+
+/**
+ * @desc Used for server config of accounts
+ */
+export enum DefaultDisplayNameConfig {
+  USERNAME = 'USERNAME',
+  FIRST_NAME = 'FIRST_NAME',
+  FULL_NAME = 'FULL_NAME',
+}
+
+/**
+ * Account fields that are editable
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export enum EditableAccountField {
+  FULL_NAME = 'FULL_NAME',
+  USER_NAME = 'USER_NAME',
+  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum FileInfoStatus {
+  ADDED = 'A',
+  DELETED = 'D',
+  RENAMED = 'R',
+  COPIED = 'C',
+  REWRITTEN = 'W',
+  // Modifed = 'M', // but API not set it if the file was modified
+  UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
+}
+
+/**
+ * @desc The status of the file
+ */
+export enum GpgKeyInfoStatus {
+  BAD = 'BAD',
+  OK = 'OK',
+  TRUSTED = 'TRUSTED',
+}
+
+/**
+ * Enum for all http methods used in Gerrit.
+ */
+export enum HttpMethod {
+  HEAD = 'HEAD',
+  POST = 'POST',
+  GET = 'GET',
+  DELETE = 'DELETE',
+  PUT = 'PUT',
+}
+
+/**
+ * Enum for possible configured value in InheritedBooleanInfo.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export enum InheritedBooleanInfoConfiguredValue {
+  TRUE = 'TRUE',
+  FALSE = 'FALSE',
+  INHERITED = 'INHERITED',
+}
+
+/**
+ * This setting determines when Gerrit computes if a change is mergeable or not.
+ * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
+ */
+export enum MergeabilityComputationBehavior {
+  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
+  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
+  NEVER = 'NEVER',
+}
+
+/**
+ * @desc The status of fixing the problem
+ */
+export enum ProblemInfoStatus {
+  FIXED = 'FIXED',
+  FIX_FAILED = 'FIX_FAILED',
+}
+
+/**
+ * @desc The state of the projects
+ */
+export enum ProjectState {
+  ACTIVE = 'ACTIVE',
+  READ_ONLY = 'READ_ONLY',
+  HIDDEN = 'HIDDEN',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum RequirementStatus {
+  OK = 'OK',
+  NOT_READY = 'NOT_READY',
+  RULE_ERROR = 'RULE_ERROR',
+}
+
+/**
+ * @desc The reviewer state
+ */
+export enum ReviewerState {
+  REVIEWER = 'REVIEWER',
+  CC = 'CC',
+  REMOVED = 'REMOVED',
+}
+
+/**
+ * @desc The patchset kind
+ */
+export enum RevisionKind {
+  REWORK = 'REWORK',
+  TRIVIAL_REBASE = 'TRIVIAL_REBASE',
+  MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
+  NO_CODE_CHANGE = 'NO_CODE_CHANGE',
+  NO_CHANGE = 'NO_CHANGE',
+}
+
+/**
+ * All supported submit types.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export enum SubmitType {
+  MERGE_IF_NECESSARY = 'MERGE_IF_NECESSARY',
+  FAST_FORWARD_ONLY = 'FAST_FORWARD_ONLY',
+  REBASE_IF_NECESSARY = 'REBASE_IF_NECESSARY',
+  REBASE_ALWAYS = 'REBASE_ALWAYS',
+  MERGE_ALWAYS = 'MERGE_ALWAYS ',
+  CHERRY_PICK = 'CHERRY_PICK',
+  INHERIT = 'INHERIT',
+}
+
+/**
+ * types and interfaces ========================================================
+ */
+
+// This is a "meta type", so it comes first and is not sored alphabetically with
+// the other types.
+export type BrandType<T, BrandName extends string> = T &
+  {[__brand in BrandName]: never};
+
+export type AccountId = BrandType<number, '_accountId'>;
+
+/**
+ * The AccountInfo entity contains information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
+ */
+export declare interface AccountInfo {
+  // Normally _account_id is defined (for known Gerrit users), but users can
+  // also be CCed just with their email address. So you have to be prepared that
+  // _account_id is undefined, but then email must be set.
+  _account_id?: AccountId;
+  name?: string;
+  display_name?: string;
+  // Must be set, if _account_id is undefined.
+  email?: EmailAddress;
+  secondary_emails?: string[];
+  username?: string;
+  avatars?: AvatarInfo[];
+  _more_accounts?: boolean; // not set if false
+  status?: string; // status message of the account
+  inactive?: boolean; // not set if false
+  tags?: string[];
+}
+
+/**
+ * The AccountDetailInfo entity contains detailed information about an account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-detail-info
+ */
+export declare interface AccountDetailInfo extends AccountInfo {
+  registered_on: Timestamp;
+}
+
+/**
+ * The AccountsConfigInfo entity contains information about Gerrit configuration
+ * from the accounts section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
+ */
+export declare interface AccountsConfigInfo {
+  visibility: string;
+  default_display_name: DefaultDisplayNameConfig;
+}
+
+/**
+ * The ActionInfo entity describes a REST API call the client can make to
+ * manipulate a resource. These are frequently implemented by plugins and may
+ * be discovered at runtime.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
+ */
+export declare interface ActionInfo {
+  method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
+  label?: string; // Short title to display to a user describing the action
+  title?: string; // Longer text to display describing the action
+  enabled?: boolean; // not set if false
+}
+
+export declare interface ActionNameToActionInfoMap {
+  [actionType: string]: ActionInfo | undefined;
+  // List of actions explicitly used in code:
+  wip?: ActionInfo;
+  publishEdit?: ActionInfo;
+  rebaseEdit?: ActionInfo;
+  deleteEdit?: ActionInfo;
+  edit?: ActionInfo;
+  stopEdit?: ActionInfo;
+  download?: ActionInfo;
+  rebase?: ActionInfo;
+  cherrypick?: ActionInfo;
+  move?: ActionInfo;
+  revert?: ActionInfo;
+  revert_submission?: ActionInfo;
+  abandon?: ActionInfo;
+  submit?: ActionInfo;
+  topic?: ActionInfo;
+  hashtags?: ActionInfo;
+  assignee?: ActionInfo;
+  ready?: ActionInfo;
+  includedIn?: ActionInfo;
+}
+
+/**
+ * The ApprovalInfo entity contains information about an approval from auser
+ * for a label on a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
+ */
+export declare interface ApprovalInfo extends AccountInfo {
+  value?: number;
+  permitted_voting_range?: VotingRangeInfo;
+  date?: Timestamp;
+  tag?: ReviewInputTag;
+  post_submit?: boolean; // not set if false
+}
+
+/**
+ * The AttentionSetInfo entity contains details of users that are in the attention set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
+ */
+export declare interface AttentionSetInfo {
+  account: AccountInfo;
+  last_update?: Timestamp;
+  reason?: string;
+  reason_account?: AccountInfo;
+}
+
+/**
+ * The AuthInfo entity contains information about the authentication
+ * configuration of the Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
+ */
+export declare interface AuthInfo {
+  auth_type: AuthType; // docs incorrectly names it 'type'
+  use_contributor_agreements?: boolean;
+  contributor_agreements?: ContributorAgreementInfo[];
+  editable_account_fields: EditableAccountField[];
+  login_url?: string;
+  login_text?: string;
+  switch_account_url?: string;
+  register_url?: string;
+  register_text?: string;
+  edit_full_name_url?: string;
+  http_password_url?: string;
+  git_basic_auth_policy?: string;
+}
+
+/**
+ * The AvartarInfo entity contains information about an avatar image ofan
+ * account.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
+ */
+export declare interface AvatarInfo {
+  url: string;
+  height: number;
+  width: number;
+}
+
+export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
+// The refs/heads/ prefix is omitted in Branch name
+
+export type BranchName = BrandType<string, '_branchName'>;
+
+/**
+ * The ChangeConfigInfo entity contains information about Gerrit configuration
+ * from the change section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
+ */
+export declare interface ChangeConfigInfo {
+  allow_blame?: boolean;
+  large_change: number;
+  update_delay: number;
+  submit_whole_topic?: boolean;
+  disable_private_changes?: boolean;
+  mergeability_computation_behavior: MergeabilityComputationBehavior;
+  enable_assignee: boolean;
+}
+
+export type ChangeId = BrandType<string, '_changeId'>;
+
+/**
+ * The ChangeInfo entity contains information about a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
+ */
+export declare interface ChangeInfo {
+  id: ChangeInfoId;
+  project: RepoName;
+  branch: BranchName;
+  topic?: TopicName;
+  attention_set?: IdToAttentionSetMap;
+  assignee?: AccountInfo;
+  hashtags?: Hashtag[];
+  change_id: ChangeId;
+  subject: string;
+  status: ChangeStatus;
+  created: Timestamp;
+  updated: Timestamp;
+  submitted?: Timestamp;
+  submitter?: AccountInfo;
+  starred?: boolean; // not set if false
+  stars?: StarLabel[];
+  reviewed?: boolean; // not set if false
+  submit_type?: SubmitType;
+  mergeable?: boolean;
+  submittable?: boolean;
+  insertions: number; // Number of inserted lines
+  deletions: number; // Number of deleted lines
+  total_comment_count?: number;
+  unresolved_comment_count?: number;
+  _number: NumericChangeId;
+  owner: AccountInfo;
+  actions?: ActionNameToActionInfoMap;
+  requirements?: Requirement[];
+  labels?: LabelNameToInfoMap;
+  permitted_labels?: LabelNameToValueMap;
+  removable_reviewers?: AccountInfo[];
+  // This is documented as optional, but actually always set.
+  reviewers: Reviewers;
+  pending_reviewers?: AccountInfo[];
+  reviewer_updates?: ReviewerUpdateInfo[];
+  messages?: ChangeMessageInfo[];
+  current_revision?: CommitId;
+  revisions?: {[revisionId: string]: RevisionInfo};
+  tracking_ids?: TrackingIdInfo[];
+  _more_changes?: boolean; // not set if false
+  problems?: ProblemInfo[];
+  is_private?: boolean; // not set if false
+  work_in_progress?: boolean; // not set if false
+  has_review_started?: boolean; // not set if false
+  revert_of?: NumericChangeId;
+  submission_id?: ChangeSubmissionId;
+  cherry_pick_of_change?: NumericChangeId;
+  cherry_pick_of_patch_set?: PatchSetNum;
+  contains_git_conflicts?: boolean;
+  internalHost?: string; // TODO(TS): provide an explanation what is its
+  submit_requirements?: SubmitRequirementResultInfo[];
+}
+
+// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
+export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
+
+export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
+
+/**
+ * The ChangeMessageInfo entity contains information about a message attached
+ * to a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
+ */
+export declare interface ChangeMessageInfo {
+  id: ChangeMessageId;
+  author?: AccountInfo;
+  reviewer?: AccountInfo;
+  updated_by?: AccountInfo;
+  real_author?: AccountInfo;
+  date: Timestamp;
+  message: string;
+  accounts_in_message?: AccountInfo[];
+  tag?: ReviewInputTag;
+  _revision_number?: PatchSetNum;
+}
+
+// This ID is equal to the numeric ID of the change that triggered the
+// submission. If the change that triggered the submission also has a topic, it
+// will be "<id>-<topic>" of the change that triggered the submission
+// The callers must not rely on the format of the submission ID.
+export type ChangeSubmissionId = BrandType<
+  string | number,
+  '_changeSubmissionId'
+>;
+
+export type CloneCommandMap = {[name: string]: string};
+
+/**
+ * The CommentLinkInfo entity describes acommentlink.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
+ */
+export declare interface CommentLinkInfo {
+  match: string;
+  link?: string;
+  enabled?: boolean;
+  html?: string;
+}
+
+export declare interface CommentLinks {
+  [name: string]: CommentLinkInfo;
+}
+
+export type CommitId = BrandType<string, '_commitId'>;
+
+/**
+ * The CommitInfo entity contains information about a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export declare interface CommitInfo {
+  commit?: CommitId;
+  parents: ParentCommitInfo[];
+  author: GitPersonInfo;
+  committer: GitPersonInfo;
+  subject: string;
+  message: string;
+  web_links?: WebLinkInfo[];
+  resolve_conflicts_web_links?: WebLinkInfo[];
+}
+
+export declare interface ConfigArrayParameterInfo
+  extends ConfigParameterInfoBase {
+  type: ConfigParameterInfoType.ARRAY;
+  values: string[];
+}
+
+/**
+ * The ConfigInfo entity contains information about the effective
+ * project configuration.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
+ */
+export declare interface ConfigInfo {
+  description?: string;
+  use_contributor_agreements?: InheritedBooleanInfo;
+  use_content_merge?: InheritedBooleanInfo;
+  use_signed_off_by?: InheritedBooleanInfo;
+  create_new_change_for_all_not_in_target?: InheritedBooleanInfo;
+  require_change_id?: InheritedBooleanInfo;
+  enable_signed_push?: InheritedBooleanInfo;
+  require_signed_push?: InheritedBooleanInfo;
+  reject_implicit_merges?: InheritedBooleanInfo;
+  private_by_default: InheritedBooleanInfo;
+  work_in_progress_by_default: InheritedBooleanInfo;
+  max_object_size_limit: MaxObjectSizeLimitInfo;
+  default_submit_type: SubmitTypeInfo;
+  submit_type: SubmitType;
+  match_author_to_committer_date?: InheritedBooleanInfo;
+  state?: ProjectState;
+  commentlinks: CommentLinks;
+  plugin_config?: PluginNameToPluginParametersMap;
+  actions?: {[viewName: string]: ActionInfo};
+  reject_empty_commit?: InheritedBooleanInfo;
+}
+
+export declare interface ConfigListParameterInfo
+  extends ConfigParameterInfoBase {
+  type: ConfigParameterInfoType.LIST;
+  permitted_values?: string[];
+}
+
+export type ConfigParameterInfo =
+  | ConfigParameterInfoBase
+  | ConfigArrayParameterInfo
+  | ConfigListParameterInfo;
+
+/**
+ * The ConfigParameterInfo entity describes a project configurationparameter.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
+ */
+export declare interface ConfigParameterInfoBase {
+  display_name?: string;
+  description?: string;
+  warning?: string;
+  type: ConfigParameterInfoType;
+  value?: string;
+  values?: string[];
+  editable?: boolean;
+  permitted_values?: string[];
+  inheritable?: boolean;
+  configured_value?: string;
+  inherited_value?: string;
+}
+
+// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
+export declare interface ContributorAgreementInfo {
+  name: string;
+  description: string;
+  url: string;
+  auto_verify_group?: GroupInfo;
+}
+
+/**
+ * LabelInfo when DETAILED_LABELS are requested.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
+ */
+export declare interface DetailedLabelInfo extends LabelCommonInfo {
+  // 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
+  default_value?: number;
+}
+
+export function isDetailedLabelInfo(
+  label: LabelInfo
+): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  return !!(label as DetailedLabelInfo).values;
+}
+
+/**
+ * The DownloadInfo entity contains information about supported download
+ * options.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
+ */
+export declare interface DownloadInfo {
+  schemes: SchemesInfoMap;
+  archives: string[];
+}
+
+/**
+ * The DownloadSchemeInfo entity contains information about a supported download
+ * scheme and its commands.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
+ */
+export declare interface DownloadSchemeInfo {
+  url: string;
+  is_auth_required: boolean;
+  is_auth_supported: boolean;
+  commands: string;
+  clone_commands: CloneCommandMap;
+}
+
+export type EmailAddress = BrandType<string, '_emailAddress'>;
+
+/**
+ * The FetchInfo entity contains information about how to fetch a patchset via
+ * a certain protocol.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
+ */
+export declare interface FetchInfo {
+  url: string;
+  ref: string;
+  commands?: {[commandName: string]: string};
+}
+
+/**
+ * The FileInfo entity contains information about a file in a patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
+ */
+export declare interface FileInfo {
+  status?: FileInfoStatus;
+  binary?: boolean; // not set if false
+  old_path?: string;
+  lines_inserted?: number;
+  lines_deleted?: number;
+  size_delta: number; // in bytes
+  size: number; // in bytes
+}
+
+/**
+ * The GerritInfo entity contains information about Gerrit configuration from
+ * the gerrit section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
+ */
+export declare interface GerritInfo {
+  all_projects: string; // Doc contains incorrect name
+  all_users: string; // Doc contains incorrect name
+  doc_search: boolean;
+  doc_url?: string;
+  edit_gpg_keys?: boolean;
+  report_bug_url?: string;
+  // The following property is missed in doc
+  primary_weblink_name?: string;
+}
+
+export type GitRef = BrandType<string, '_gitRef'>;
+// The 40-char (plus spaces) hex GPG key fingerprint
+
+export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
+// The 8-char hex GPG key ID.
+
+export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
+
+/**
+ * The GpgKeyInfo entity contains information about a GPG public key.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
+ */
+export declare interface GpgKeyInfo {
+  id?: GpgKeyId;
+  fingerprint?: GpgKeyFingerprint;
+  user_ids?: OpenPgpUserIds[];
+  key?: string; // ASCII armored public key material
+  status?: GpgKeyInfoStatus;
+  problems?: string[];
+}
+
+/**
+ * The GitPersonInfo entity contains information about theauthor/committer of
+ * a commit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
+ */
+export declare interface GitPersonInfo {
+  name: string;
+  email: EmailAddress;
+  date: Timestamp;
+  tz: TimezoneOffset;
+}
+
+export type GroupId = BrandType<string, '_groupId'>;
+
+/**
+ * The GroupInfo entity contains information about a group. This can be a
+ * Gerrit internal group, or an external group that is known to Gerrit.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
+ */
+export declare interface GroupInfo {
+  id: GroupId;
+  name?: GroupName;
+  url?: string;
+  options?: GroupOptionsInfo;
+  description?: string;
+  group_id?: number;
+  owner?: string;
+  owner_id?: string;
+  created_on?: string;
+  _more_groups?: boolean;
+  members?: AccountInfo[];
+  includes?: GroupInfo[];
+}
+
+export type GroupName = BrandType<string, '_groupName'>;
+
+/**
+ * Options of the group.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ */
+export declare interface GroupOptionsInfo {
+  visible_to_all: boolean;
+}
+
+export type Hashtag = BrandType<string, '_hashtag'>;
+
+export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
+
+/**
+ * A boolean value that can also be inherited.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
+ */
+export declare interface InheritedBooleanInfo {
+  value: boolean;
+  configured_value: InheritedBooleanInfoConfiguredValue;
+  inherited_value?: boolean;
+}
+
+export declare interface LabelCommonInfo {
+  optional?: boolean; // not set if false
+}
+
+export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
+
+// {Verified: ["-1", " 0", "+1"]}
+export type LabelNameToValueMap = {[labelName: string]: string[]};
+
+/**
+ * The LabelInfo entity contains information about a label on a change, always
+ * corresponding to the current patch set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
+ */
+export type LabelInfo =
+  | QuickLabelInfo
+  | DetailedLabelInfo
+  | (QuickLabelInfo & DetailedLabelInfo);
+
+export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
+
+/**
+ * The LabelTypeInfo entity contains metadata about the labels that a project
+ * has.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
+ */
+export declare interface LabelTypeInfo {
+  values: LabelTypeInfoValues;
+  default_value: number;
+}
+
+export type LabelTypeInfoValues = {[value: string]: string};
+
+// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
+export type LabelValueToDescriptionMap = {[labelValue: string]: string};
+
+/**
+ * The MaxObjectSizeLimitInfo entity contains information about the max object
+ * size limit of a project.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
+ */
+export declare interface MaxObjectSizeLimitInfo {
+  value?: string;
+  configured_value?: string;
+  summary?: string;
+}
+
+export type NumericChangeId = BrandType<number, '_numericChangeId'>;
+// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
+
+export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
+
+/**
+ * The parent commits of this commit as a list of CommitInfo entities.
+ * In each parent only the commit and subject fields are populated.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
+ */
+export declare interface ParentCommitInfo {
+  commit: CommitId;
+  subject: string;
+}
+
+export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
+
+/**
+ * The PluginConfigInfo entity contains information about Gerrit extensions by
+ * plugins.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
+ */
+export declare interface PluginConfigInfo {
+  has_avatars: boolean;
+  // Exists in Java class, but not mentioned in docs.
+  js_resource_paths: string[];
+}
+
+/**
+ * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
+ */
+export type PluginNameToPluginParametersMap = {
+  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
+};
+
+export type PluginParameterToConfigParameterInfoMap = {
+  [parameterName: string]: ConfigParameterInfo;
+};
+
+/**
+ * The ProblemInfo entity contains a description of a potential consistency
+ * problem with a change. These are not related to the code review process,
+ * but rather indicate some inconsistency in Gerrit’s database or repository
+ * metadata related to the enclosing change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
+ */
+export declare interface ProblemInfo {
+  message: string;
+  status?: ProblemInfoStatus; // Only set if a fix was attempted
+  outcome?: string;
+}
+
+/**
+ * The ProjectInfo entity contains information about a project
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
+ */
+export declare interface ProjectInfo {
+  id: UrlEncodedRepoName;
+  // name is not set if returned in a map where the project name is used as
+  // map key
+  name?: RepoName;
+  // ?-<n> if the parent project is not visible (<n> is a number which
+  // is increased for each non-visible project).
+  parent?: RepoName;
+  description?: string;
+  state?: ProjectState;
+  branches?: {[branchName: string]: CommitId};
+  // labels is filled for Create Project and Get Project calls.
+  labels?: LabelNameToLabelTypeInfoMap;
+  // Links to the project in external sites
+  web_links?: WebLinkInfo[];
+}
+
+export declare interface ProjectInfoWithName extends ProjectInfo {
+  name: RepoName;
+}
+
+/**
+ * The PushCertificateInfo entity contains information about a pushcertificate
+ * provided when the user pushed for review with git push
+ * --signed HEAD:refs/for/<branch>. Only used when signed push is
+ * enabled on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
+ */
+export declare interface PushCertificateInfo {
+  certificate: string;
+  key: GpgKeyInfo;
+}
+
+export declare interface QuickLabelInfo extends LabelCommonInfo {
+  approved?: AccountInfo;
+  rejected?: AccountInfo;
+  recommended?: AccountInfo;
+  disliked?: AccountInfo;
+  blocking?: boolean; // not set if false
+  value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
+  default_value?: number;
+}
+
+export function isQuickLabelInfo(
+  l: LabelInfo
+): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
+  const quickLabelInfo = l as QuickLabelInfo;
+  return (
+    quickLabelInfo.approved !== undefined ||
+    quickLabelInfo.rejected !== undefined ||
+    quickLabelInfo.recommended !== undefined ||
+    quickLabelInfo.disliked !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.blocking !== undefined ||
+    quickLabelInfo.value !== undefined
+  );
+}
+
+/**
+ * The ReceiveInfo entity contains information about the configuration of
+ * git-receive-pack behavior on the server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
+ */
+export declare interface ReceiveInfo {
+  enable_signed_push?: string;
+}
+
+export type RepoName = BrandType<string, '_repoName'>;
+
+/**
+ * The Requirement entity contains information about a requirement relative to
+ * a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
+ */
+export declare interface Requirement {
+  status: RequirementStatus;
+  fallbackText: string; // A human readable reason
+  type: RequirementType;
+}
+
+export type RequirementType = BrandType<string, '_requirementType'>;
+
+/**
+ * The reviewers as a map that maps a reviewer state to a list of AccountInfo
+ * entities. Possible reviewer states are REVIEWER, CC and REMOVED.
+ * REVIEWER: Users with at least one non-zero vote on the change.
+ * CC: Users that were added to the change, but have not voted.
+ * REMOVED: Users that were previously reviewers on the change, but have been removed.
+ */
+export type Reviewers = Partial<Record<ReviewerState, AccountInfo[]>>;
+
+/**
+ * The ReviewerUpdateInfo entity contains information about updates to change’s
+ * reviewers set.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
+ */
+export declare interface ReviewerUpdateInfo {
+  updated: Timestamp;
+  updated_by: AccountInfo;
+  reviewer: AccountInfo;
+  state: ReviewerState;
+}
+
+export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
+
+/**
+ * The RevisionInfo entity contains information about a patch set.Not all
+ * fields are returned by default.  Additional fields can be obtained by
+ * adding o parameters as described in Query Changes.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
+ * basePatchNum is present in case RevisionInfo is of type 'edit'
+ */
+export declare interface RevisionInfo {
+  kind: RevisionKind;
+  _number: PatchSetNum;
+  created: Timestamp;
+  uploader: AccountInfo;
+  ref: GitRef;
+  fetch?: {[protocol: string]: FetchInfo};
+  commit?: CommitInfo;
+  files?: {[filename: string]: FileInfo};
+  actions?: ActionNameToActionInfoMap;
+  reviewed?: boolean;
+  commit_with_footers?: boolean;
+  push_certificate?: PushCertificateInfo;
+  description?: string;
+  basePatchNum?: BasePatchSetNum;
+}
+
+export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
+
+/**
+ * The ServerInfo entity contains information about the configuration of the
+ * Gerrit server.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
+ */
+export declare interface ServerInfo {
+  accounts: AccountsConfigInfo;
+  auth: AuthInfo;
+  change: ChangeConfigInfo;
+  download: DownloadInfo;
+  gerrit: GerritInfo;
+  // docs mentions index property, but it doesn't exists in Java class
+  // index: IndexConfigInfo;
+  note_db_enabled?: boolean;
+  plugin: PluginConfigInfo;
+  receive?: ReceiveInfo;
+  sshd?: SshdInfo;
+  suggest: SuggestInfo;
+  user: UserConfigInfo;
+  default_theme?: string;
+}
+
+/**
+ * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
+ * This entity doesn’t contain any data, but the presence of this (empty) entity
+ * in the ServerInfo entity means that SSHD is enabled on the server.
+ */
+export type SshdInfo = {};
+
+export type StarLabel = BrandType<string, '_startLabel'>;
+// Timestamps are given in UTC and have the format
+// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
+// where "'ffffffffff'" represents nanoseconds.
+
+/**
+ * Information about the default submittype of a project, taking into account
+ * project inheritance.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
+ */
+export declare interface SubmitTypeInfo {
+  value: Exclude<SubmitType, SubmitType.INHERIT>;
+  configured_value: SubmitType;
+  inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
+}
+
+/**
+ * The SuggestInfo entity contains information about Gerritconfiguration from
+ * the suggest section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
+ */
+export declare interface SuggestInfo {
+  from: number;
+}
+
+export type Timestamp = BrandType<string, '_timestamp'>;
+// The timezone offset from UTC in minutes
+
+export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
+
+export type TopicName = BrandType<string, '_topicName'>;
+
+export type TrackingId = BrandType<string, '_trackingId'>;
+
+/**
+ * The TrackingIdInfo entity describes a reference to an external tracking
+ * system.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
+ */
+export declare interface TrackingIdInfo {
+  system: string;
+  id: TrackingId;
+}
+
+/**
+ * The UserConfigInfo entity contains information about Gerrit configuration
+ * from the user section.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
+ */
+export declare interface UserConfigInfo {
+  anonymous_coward_name: 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
+ */
+export declare interface VotingRangeInfo {
+  min: number;
+  max: number;
+}
+
+/**
+ * The WebLinkInfo entity describes a link to an external site.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#web-link-info
+ */
+export declare interface WebLinkInfo {
+  /** The link name. */
+  name: string;
+  /** The link URL. */
+  url: string;
+  /** URL to the icon of the link. */
+  image_url?: string;
+  /* The links target. */
+  target?: string;
+}
+
+/**
+ * The SubmitRequirementResultInfo describes the result of evaluating
+ * a submit requirement on a change.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-requirement-result-info
+ */
+export declare interface SubmitRequirementResultInfo {
+  name: string;
+  description?: string;
+  status: SubmitRequirementStatus;
+  applicability_expression_result?: SubmitRequirementExpressionInfo;
+  submittability_expression_result: SubmitRequirementExpressionInfo;
+  override_expression_result?: SubmitRequirementExpressionInfo;
+  is_legacy?: boolean;
+}
+
+/**
+ * The SubmitRequirementExpressionInfo describes the result of evaluating
+ * a single submit requirement expression, for example label:code-review=+2.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-requirement-expression-info
+ */
+export declare interface SubmitRequirementExpressionInfo {
+  expression: string;
+  fulfilled: boolean;
+  passing_atoms: string[];
+  failing_atoms: string[];
+}
+
+/**
+ * Status describing the result of evaluating the submit requirement.
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#submit-requirement-result-info
+ */
+export enum SubmitRequirementStatus {
+  SATISFIED = 'SATISFIED',
+  UNSATISFIED = 'UNSATISFIED',
+  OVERRIDDEN = 'OVERRIDDEN',
+  NOT_APPLICABLE = 'NOT_APPLICABLE',
+}
+
+export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
diff --git a/polygerrit-ui/app/api/rest.ts b/polygerrit-ui/app/api/rest.ts
index fd9cada..86f33a9 100644
--- a/polygerrit-ui/app/api/rest.ts
+++ b/polygerrit-ui/app/api/rest.ts
@@ -14,6 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {AccountDetailInfo, ProjectInfoWithName, ServerInfo} from './rest-api';
+
 export type RequestPayload = string | object;
 
 export enum HttpMethod {
@@ -26,20 +28,23 @@
 
 export type ErrorCallback = (response?: Response | null, err?: Error) => void;
 
-export interface RestPluginApi {
+export declare 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>;
+  getConfig(): Promise<ServerInfo | undefined>;
 
   invalidateReposCache(): void;
 
+  getAccount(): Promise<AccountDetailInfo | undefined>;
+
+  getRepos(
+    filter: string,
+    reposPerPage: number,
+    offset?: number
+  ): Promise<ProjectInfoWithName[] | undefined>;
+
   fetch(
     method: HttpMethod,
     url: string,
@@ -78,29 +83,29 @@
   /**
    * Fetch and parse REST API response, if request succeeds.
    */
-  send(
+  send<T>(
     method: HttpMethod,
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
-  get(url: string): Promise<unknown>;
+  get<T>(url: string): Promise<T>;
 
-  post(
+  post<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
-  put(
+  put<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
-  ): Promise<unknown>;
+  ): Promise<T>;
 
   delete(url: string): Promise<Response>;
 }
diff --git a/polygerrit-ui/app/api/styles.ts b/polygerrit-ui/app/api/styles.ts
new file mode 100644
index 0000000..55ac2cc
--- /dev/null
+++ b/polygerrit-ui/app/api/styles.ts
@@ -0,0 +1,43 @@
+/**
+ * @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.
+ */
+
+/**
+ * We are sharing a couple of sets of CSS rules with plugins such that they can
+ * adopt Gerrit's styling beyond just using CSS properties from the theme.
+ *
+ * This is a bit tricky, because plugin elements have their own shadow DOM, and
+ * unfortunately Firefox has not adopted "constructable stylesheets" yet. So we
+ * are basically just exposing CSS strings here.
+ *
+ * Plugins that use Lit can cast `Style` to `CSSResult`.
+ *
+ * Non-Lit plugins can call call `toString()` on `Style`.
+ */
+
+/** Lit plugins can cast Style to CSSResult. */
+export declare interface Style {
+  toString(): string;
+}
+
+export declare interface Styles {
+  font: Style;
+  form: Style;
+  menuPage: Style;
+  spinner: Style;
+  subPage: Style;
+  table: Style;
+}
diff --git a/polygerrit-ui/app/api/tsconfig.json b/polygerrit-ui/app/api/tsconfig.json
new file mode 100644
index 0000000..037e4f2
--- /dev/null
+++ b/polygerrit-ui/app/api/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../../plugins/tsconfig-plugins-base.json",
+  "compilerOptions": {
+    "rootDir": ".",
+  },
+  "include": [
+    "**/*",
+  ],
+}
diff --git a/polygerrit-ui/app/compile_generated_templates.sh b/polygerrit-ui/app/compile_generated_templates.sh
new file mode 100755
index 0000000..68bf485
--- /dev/null
+++ b/polygerrit-ui/app/compile_generated_templates.sh
@@ -0,0 +1 @@
+$1 --project $2 --baseUrl ./external/ui_npm/node_modules/ --rootDir null
diff --git a/polygerrit-ui/app/constants/constants.ts b/polygerrit-ui/app/constants/constants.ts
index be502f7..645e770 100644
--- a/polygerrit-ui/app/constants/constants.ts
+++ b/polygerrit-ui/app/constants/constants.ts
@@ -21,6 +21,47 @@
 import {DiffViewMode} from '../api/diff';
 import {DiffPreferencesInfo} from '../types/diff';
 import {EditPreferencesInfo, PreferencesInfo} from '../types/common';
+import {
+  AuthType,
+  ChangeStatus,
+  ConfigParameterInfoType,
+  DefaultDisplayNameConfig,
+  EditableAccountField,
+  FileInfoStatus,
+  GpgKeyInfoStatus,
+  HttpMethod,
+  InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
+  ProblemInfoStatus,
+  ProjectState,
+  RequirementStatus,
+  ReviewerState,
+  RevisionKind,
+  SubmitType,
+} from '../api/rest-api';
+
+export {
+  AuthType,
+  ChangeStatus,
+  ConfigParameterInfoType,
+  DefaultDisplayNameConfig,
+  EditableAccountField,
+  FileInfoStatus,
+  GpgKeyInfoStatus,
+  HttpMethod,
+  InheritedBooleanInfoConfiguredValue,
+  MergeabilityComputationBehavior,
+  ProblemInfoStatus,
+  ProjectState,
+  RequirementStatus,
+  ReviewerState,
+  RevisionKind,
+  SubmitType,
+};
+
+export enum AccountTag {
+  SERVICE_USER = 'SERVICE_USER',
+}
 
 export enum PrimaryTab {
   FILES = 'files',
@@ -54,6 +95,7 @@
   TAG_SET_ASSIGNEE = 'autogenerated:gerrit:setAssignee',
   TAG_UNSET_ASSIGNEE = 'autogenerated:gerrit:deleteAssignee',
   TAG_MERGED = 'autogenerated:gerrit:merged',
+  TAG_REVERT = 'autogenerated:gerrit:revert',
 }
 
 /**
@@ -68,15 +110,6 @@
 }
 
 /**
- * @desc Specifies status for a change
- */
-export enum ChangeStatus {
-  ABANDONED = 'ABANDONED',
-  MERGED = 'MERGED',
-  NEW = 'NEW',
-}
-
-/**
  * @desc Special file paths
  */
 export enum SpecialFilePath {
@@ -85,115 +118,9 @@
   MERGE_LIST = '/MERGE_LIST',
 }
 
-/**
- * @desc The reviewer state
- */
-export enum RequirementStatus {
-  OK = 'OK',
-  NOT_READY = 'NOT_READY',
-  RULE_ERROR = 'RULE_ERROR',
-}
-
-/**
- * @desc The reviewer state
- */
-export enum ReviewerState {
-  REVIEWER = 'REVIEWER',
-  CC = 'CC',
-  REMOVED = 'REMOVED',
-}
-
-/**
- * @desc The patchset kind
- */
-export enum RevisionKind {
-  REWORK = 'REWORK',
-  TRIVIAL_REBASE = 'TRIVIAL_REBASE',
-  MERGE_FIRST_PARENT_UPDATE = 'MERGE_FIRST_PARENT_UPDATE',
-  NO_CODE_CHANGE = 'NO_CODE_CHANGE',
-  NO_CHANGE = 'NO_CHANGE',
-}
-
-/**
- * @desc The status of fixing the problem
- */
-export enum ProblemInfoStatus {
-  FIXED = 'FIXED',
-  FIX_FAILED = 'FIX_FAILED',
-}
-
-/**
- * @desc The status of the file
- */
-export enum FileInfoStatus {
-  ADDED = 'A',
-  DELETED = 'D',
-  RENAMED = 'R',
-  COPIED = 'C',
-  REWRITTEN = 'W',
-  // Modifed = 'M', // but API not set it if the file was modified
-  UNMODIFIED = 'U', // Not returned by BE, but added by UI for certain files
-}
-
-/**
- * @desc The status of the file
- */
-export enum GpgKeyInfoStatus {
-  BAD = 'BAD',
-  OK = 'OK',
-  TRUSTED = 'TRUSTED',
-}
-
-/**
- * @desc Used for server config of accounts
- */
-export enum DefaultDisplayNameConfig {
-  USERNAME = 'USERNAME',
-  FIRST_NAME = 'FIRST_NAME',
-  FULL_NAME = 'FULL_NAME',
-}
-
-/**
- * @desc The state of the projects
- */
-export enum ProjectState {
-  ACTIVE = 'ACTIVE',
-  READ_ONLY = 'READ_ONLY',
-  HIDDEN = 'HIDDEN',
-}
-
 export {Side} from '../api/diff';
 
 /**
- * The type in ConfigParameterInfo entity.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
- */
-export enum ConfigParameterInfoType {
-  // Should be kept in sync with
-  // gerrit/java/com/google/gerrit/extensions/api/projects/ProjectConfigEntryType.java.
-  STRING = 'STRING',
-  INT = 'INT',
-  LONG = 'LONG',
-  BOOLEAN = 'BOOLEAN',
-  LIST = 'LIST',
-  ARRAY = 'ARRAY',
-}
-
-/**
- * All supported submit types.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
- */
-export enum SubmitType {
-  MERGE_IF_NECESSARY = 'MERGE_IF_NECESSARY',
-  FAST_FORWARD_ONLY = 'FAST_FORWARD_ONLY',
-  REBASE_IF_NECESSARY = 'REBASE_IF_NECESSARY',
-  REBASE_ALWAYS = 'REBASE_ALWAYS',
-  MERGE_ALWAYS = 'MERGE_ALWAYS ',
-  CHERRY_PICK = 'CHERRY_PICK',
-  INHERIT = 'INHERIT',
-}
-
-/**
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#mergeable-info
  */
 export enum MergeStrategy {
@@ -204,20 +131,6 @@
   THEIRS = 'theirs',
 }
 
-/*
- * Enum for possible configured value in InheritedBooleanInfo.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
- */
-export enum InheritedBooleanInfoConfiguredValue {
-  TRUE = 'TRUE',
-  FALSE = 'FALSE',
-  INHERITED = 'INHERITED',
-}
-
-export enum AccountTag {
-  SERVICE_USER = 'SERVICE_USER',
-}
-
 /**
  * Enum for possible PermissionRuleInfo actions
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#permission-info
@@ -241,17 +154,6 @@
 }
 
 /**
- * Enum for all http methods used in Gerrit.
- */
-export enum HttpMethod {
-  HEAD = 'HEAD',
-  POST = 'POST',
-  GET = 'GET',
-  DELETE = 'DELETE',
-  PUT = 'PUT',
-}
-
-/**
  * The side on which the comment was added
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
@@ -339,23 +241,6 @@
 }
 
 /**
- * The authentication type that is configured on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export enum AuthType {
-  OPENID = 'OPENID',
-  OPENID_SSO = 'OPENID_SSO',
-  OAUTH = 'OAUTH',
-  HTTP = 'HTTP',
-  HTTP_LDAP = 'HTTP_LDAP',
-  CLIENT_SSL_CERT_LDAP = 'CLIENT_SSL_CERT_LDAP',
-  LDAP = 'LDAP',
-  LDAP_BIND = 'LDAP_BIND',
-  CUSTOM_EXTENSION = 'CUSTOM_EXTENSION',
-  DEVELOPMENT_BECOME_ANY_ACCOUNT = 'DEVELOPMENT_BECOME_ANY_ACCOUNT',
-}
-
-/**
  * Controls visibility of other users' dashboard pages and completion suggestions to web users
  * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#accounts.visibility
  */
@@ -366,26 +251,6 @@
   NONE = 'NONE',
 }
 
-/**
- * Account fields that are editable
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export enum EditableAccountField {
-  FULL_NAME = 'FULL_NAME',
-  USER_NAME = 'USER_NAME',
-  REGISTER_NEW_EMAIL = 'REGISTER_NEW_EMAIL',
-}
-
-/**
- * This setting determines when Gerrit computes if a change is mergeable or not.
- * https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#change.mergeabilityComputationBehavior
- */
-export enum MergeabilityComputationBehavior {
-  API_REF_UPDATED_AND_CHANGE_REINDEX = 'API_REF_UPDATED_AND_CHANGE_REINDEX',
-  REF_UPDATED_AND_CHANGE_REINDEX = 'REF_UPDATED_AND_CHANGE_REINDEX',
-  NEVER = 'NEVER',
-}
-
 // TODO(TS): Many properties are omitted here, but they are required.
 // Add default values for missing properties.
 export function createDefaultPreferences() {
@@ -439,3 +304,7 @@
     theme: 'DEFAULT',
   };
 }
+
+export const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
+
+export const SHOWN_ITEMS_COUNT = 25;
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 0738825..a10bdda 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -23,6 +23,7 @@
   VISIBILILITY_VISIBLE = 'Visibility changed to visible',
   EXTENSION_DETECTED = 'Extension detected',
   PLUGINS_INSTALLED = 'Plugins installed',
+  PLUGINS_FAILED = 'Some plugins failed to load',
   USER_REFERRED_FROM = 'User referred from',
 }
 
@@ -30,6 +31,8 @@
   PLUGIN_API = 'plugin-api',
   REACHABLE_CODE = 'reachable code',
   METHOD_USED = 'method used',
+  CHECKS_API_NOT_LOGGED_IN = 'checks-api not-logged-in',
+  CHECKS_API_ERROR = 'checks-api error',
 }
 
 export enum Timing {
@@ -90,3 +93,9 @@
   // This measures the same interval as ExpandAllDiffs, but the result is divided by the number of diffs expanded.
   FILE_EXPAND_ALL_AVG = 'ExpandAllPerDiff',
 }
+
+export enum Interaction {
+  TOGGLE_SHOW_ALL_BUTTON = 'toggle show all button',
+  SHOW_TAB = 'show-tab',
+  ATTENTION_SET_CHIP = 'attention-set-chip',
+}
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 a793d13..48f42a4 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
@@ -118,7 +119,7 @@
 
   _updateSection(section: PermissionAccessSection) {
     this._permissions = toSortedPermissionsArray(section.value.permissions);
-    this._originalId = section.id as GitRef;
+    this._originalId = section.id;
   }
 
   _handleAccessSaved() {
@@ -173,7 +174,9 @@
   _computePermissions(
     name: string,
     capabilities?: CapabilityInfoMap,
-    labels?: LabelNameToLabelTypeInfoMap
+    labels?: LabelNameToLabelTypeInfoMap,
+    // This is just for triggering re-computation. We don't use the value.
+    _?: unknown
   ) {
     let allPermissions;
     const section = this.section;
@@ -193,10 +196,6 @@
     );
   }
 
-  _computeHideEditClass(section: PermissionAccessSection) {
-    return section.id === 'GLOBAL_CAPABILITIES' ? 'hide' : '';
-  }
-
   _handleAddedPermissionRemoved(e: PolymerDomRepeatEvent) {
     if (!this._permissions) {
       return;
@@ -234,10 +233,10 @@
   _computePermissionName(
     name: string,
     permission: PermissionArrayItem<EditablePermissionInfo>,
-    capabilities: CapabilityInfoMap
-  ) {
+    capabilities?: CapabilityInfoMap
+  ): string | undefined {
     if (name === GLOBAL_NAME) {
-      return capabilities[permission.id].name;
+      return capabilities?.[permission.id].name;
     } else if (AccessPermissions[permission.id]) {
       return AccessPermissions[permission.id].name;
     } else if (permission.value.label) {
@@ -320,7 +319,7 @@
     if (
       editing &&
       this.section &&
-      this._isEditEnabled(canUpload, ownerOf, this.section.id as GitRef)
+      this._isEditEnabled(canUpload, ownerOf, this.section.id)
     ) {
       classList.push('editing');
     }
@@ -338,7 +337,7 @@
   }
 
   _handleAddPermission() {
-    const value = this.$.permissionSelect.value;
+    const value = this.$.permissionSelect.value as GitRef;
     const permission: PermissionArrayItem<EditablePermissionInfo> = {
       id: value,
       value: {rules: {}, added: true},
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 2821632..65a3199 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
index 97a7fa3..7182ede 100644
--- a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.js
@@ -423,7 +423,7 @@
             1);
       });
 
-      test('edit section reference', done => {
+      test('edit section reference', async () => {
         element.canUpload = true;
         element.ownerOf = [];
         element.section = {id: 'refs/for/bar', value: {permissions: {}}};
@@ -433,15 +433,13 @@
         assert.isFalse(element._editingRef);
         MockInteractions.tap(element.$.editBtn);
         element.editRefInput().bindValue='new/ref';
-        setTimeout(() => {
-          assert.equal(element.section.id, 'new/ref');
-          assert.isTrue(element._editingRef);
-          assert.isTrue(element.$.section.classList.contains('editingRef'));
-          element.editing = false;
-          assert.isFalse(element._editingRef);
-          assert.equal(element.section.id, 'refs/for/bar');
-          done();
-        });
+        await flush();
+        assert.equal(element.section.id, 'new/ref');
+        assert.isTrue(element._editingRef);
+        assert.isTrue(element.$.section.classList.contains('editingRef'));
+        element.editing = false;
+        assert.isFalse(element._editingRef);
+        assert.equal(element.section.id, 'refs/for/bar');
       });
 
       test('_handleValueChange', () => {
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 37a2f3e..ea70d7e 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
@@ -23,7 +23,6 @@
 import '../gr-create-group-dialog/gr-create-group-dialog';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-admin-group-list_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
@@ -32,6 +31,7 @@
 import {GrCreateGroupDialog} from '../gr-create-group-dialog/gr-create-group-dialog';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -47,7 +47,7 @@
 }
 
 @customElement('gr-admin-group-list')
-export class GrAdminGroupList extends ListViewMixin(PolymerElement) {
+export class GrAdminGroupList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -59,13 +59,13 @@
    * Offset of currently visible query results.
    */
   @property({type: Number})
-  _offset?: number;
+  _offset = 0;
 
   @property({type: String})
   readonly _path = '/admin/groups';
 
   @property({type: Boolean})
-  _hasNewGroupName?: boolean;
+  _hasNewGroupName = false;
 
   @property({type: Boolean})
   _createNewCapability = false;
@@ -79,7 +79,7 @@
    * */
   @computed('_groups')
   get _shownGroups() {
-    return this.computeShownItems(this._groups);
+    return this._groups.slice(0, SHOWN_ITEMS_COUNT);
   }
 
   @property({type: Number})
@@ -93,8 +93,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._getCreateGroupCapability();
     fireTitleChange(this, 'Groups');
@@ -104,8 +103,8 @@
   @observe('params')
   _paramsChanged(params: AppElementAdminParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
 
     return this._getGroups(this._filter, this._groupsPerPage, this._offset);
   }
@@ -182,4 +181,8 @@
   _visibleToAll(item: GroupInfo) {
     return item.options?.visible_to_all === true ? 'Y' : 'N';
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
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 2df1ac6..7b7b959 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
@@ -104,10 +104,11 @@
   });
 
   suite('test with less then 25 groups', () => {
-    setup(done => {
+    setup(async () => {
       groups = _.times(25, groupGenerator);
       stubRestApi('getGroups').returns(Promise.resolve(groups));
-      element._paramsChanged(value).then(() => { flush(done); });
+      await element._paramsChanged(value);
+      await flush();
     });
 
     test('_shownGroups', () => {
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 f8ab519..1c9c6f3 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
@@ -69,7 +69,7 @@
   text: string;
   value: string;
   view: GerritView;
-  url: string;
+  url?: string;
   detailType?: GroupDetailView | RepoDetailView;
   parent?: GroupId | RepoName;
 }
@@ -173,8 +173,7 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.reload();
   }
@@ -262,6 +261,7 @@
 
     // This is when it gets set initially.
     if (this._selectedIsCurrentPage(selected)) return;
+    if (selected.url === undefined) return;
     GerritNav.navigateToRelativeUrl(selected.url);
   }
 
@@ -306,9 +306,9 @@
     );
     this.set(
       '_showRepoMain',
-      params.view === GerritView.REPO && !params.detail
+      params.view === GerritView.REPO &&
+        (!params.detail || params.detail === RepoDetailView.GENERAL)
     );
-
     this.set(
       '_showRepoList',
       params.view === GerritView.ADMIN && params.adminView === 'gr-repo-list'
@@ -399,8 +399,8 @@
     // TODO(TS): The following condition seems always false, because params
     // never has detailType property. Remove it.
     if (
-      ((params as unknown) as AdminSubsectionLink).detailType &&
-      ((params as unknown) as AdminSubsectionLink).detailType !== detailType
+      (params as unknown as AdminSubsectionLink).detailType &&
+      (params as unknown as AdminSubsectionLink).detailType !== detailType
     ) {
       return '';
     }
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 f073a9f..a3afc5c 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
@@ -24,11 +24,6 @@
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <style include="gr-page-nav-styles">
-    gr-dropdown-list {
-      --trigger-style: {
-        text-transform: none;
-      }
-    }
     .breadcrumbText {
       /* Same as dropdown trigger so chevron spacing is consistent. */
       padding: 5px 4px;
@@ -73,13 +68,18 @@
         <template is="dom-if" if="[[item.subsection]]">
           <!--If a section has a subsection, render that.-->
           <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
-            <a
-              class="title"
-              href$="[[_computeLinkURL(item.subsection)]]"
-              rel="noopener"
-            >
-              [[item.subsection.name]]</a
-            >
+            <template is="dom-if" if="[[item.subsection.url]]" as="child">
+              <a
+                class="title"
+                href$="[[_computeLinkURL(item.subsection)]]"
+                rel="noopener"
+              >
+                [[item.subsection.name]]</a
+              >
+            </template>
+            <template is="dom-if" if="[[!item.subsection.url]]" as="child">
+              [[item.subsection.name]]
+            </template>
           </li>
           <!--Loop through the links in the sub-section.-->
           <template
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 69c218c..cf0fdd4 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
@@ -20,7 +20,7 @@
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
 import {GerritView} from '../../../services/router/router-model.js';
 
 const basicFixture = fixtureFromElement('gr-admin-view');
@@ -36,12 +36,13 @@
 suite('gr-admin-view tests', () => {
   let element;
 
-  setup(done => {
+  setup(async () => {
     element = basicFixture.instantiate();
     stubRestApi('getProjectConfig').returns(Promise.resolve({}));
     const pluginsLoaded = Promise.resolve();
     sinon.stub(getPluginLoader(), 'awaitPluginsLoaded').returns(pluginsLoaded);
-    pluginsLoaded.then(() => flush(done));
+    await pluginsLoaded;
+    await flush();
   });
 
   test('_computeURLHelper', () => {
@@ -87,25 +88,23 @@
         .querySelector('gr-admin-create-repo'));
   });
 
-  test('_filteredLinks admin', done => {
+  test('_filteredLinks admin', async () => {
     stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
     stubRestApi('getAccountCapabilities').returns(
         Promise.resolve(createAdminCapabilities()));
-    element.reload().then(() => {
-      assert.equal(element._filteredLinks.length, 3);
+    await element.reload();
+    assert.equal(element._filteredLinks.length, 3);
 
-      // Repos
-      assert.isNotOk(element._filteredLinks[0].subsection);
+    // Repos
+    assert.isNotOk(element._filteredLinks[0].subsection);
 
-      // Groups
-      assert.isNotOk(element._filteredLinks[0].subsection);
+    // Groups
+    assert.isNotOk(element._filteredLinks[0].subsection);
 
-      // Plugins
-      assert.isNotOk(element._filteredLinks[0].subsection);
-      done();
-    });
+    // Plugins
+    assert.isNotOk(element._filteredLinks[0].subsection);
   });
 
   test('_filteredLinks non admin authenticated', async () => {
@@ -154,25 +153,23 @@
     });
   });
 
-  test('Repo shows up in nav', done => {
+  test('Repo shows up in nav', async () => {
     element._repoName = 'Test Repo';
     stubRestApi('getAccount').returns(Promise.resolve({
       name: 'test-user',
     }));
     stubRestApi('getAccountCapabilities').returns(
         Promise.resolve(createAdminCapabilities()));
-    element.reload().then(() => {
-      flush();
-      assert.equal(dom(element.root)
-          .querySelectorAll('.sectionTitle').length, 3);
-      assert.equal(element.shadowRoot
-          .querySelector('.breadcrumbText').innerText, 'Test Repo');
-      assert.equal(
-          element.shadowRoot.querySelector('#pageSelect').items.length,
-          6
-      );
-      done();
-    });
+    await element.reload();
+    await flush();
+    assert.equal(dom(element.root)
+        .querySelectorAll('.sectionTitle').length, 3);
+    assert.equal(element.shadowRoot
+        .querySelector('.breadcrumbText').innerText, 'Test Repo');
+    assert.equal(
+        element.shadowRoot.querySelector('#pageSelect').items.length,
+        7
+    );
   });
 
   test('Group shows up in nav', async () => {
@@ -218,23 +215,24 @@
     assert.equal(element.reload.callCount, 1);
   });
 
-  test('Nav is reloaded when group name changes', done => {
+  test('Nav is reloaded when group name changes', async () => {
     const newName = 'newName';
+    const reloadCalled = mockPromise();
     sinon.stub(element, '_computeGroupName');
     sinon.stub(element, 'reload').callsFake(() => {
       assert.equal(element._groupName, newName);
-      assert.isTrue(element.reload.called);
-      done();
+      reloadCalled.resolve();
     });
     element.params = {group: 1, view: GerritNav.View.GROUP};
     element._groupName = 'oldName';
-    flush();
+    await flush();
     element.shadowRoot
         .querySelector('gr-group').dispatchEvent(
             new CustomEvent('name-changed', {
               detail: {name: newName},
               composed: true, bubbles: true,
             }));
+    await reloadCalled;
   });
 
   test('dropdown displays if there is a subsection', () => {
@@ -245,7 +243,6 @@
         text: 'Home',
         value: 'repo',
         view: 'repo',
-        url: '',
         parent: 'my-repo',
         detailType: undefined,
       },
@@ -261,7 +258,7 @@
         'none');
   });
 
-  test('Dropdown only triggers navigation on explicit select', done => {
+  test('Dropdown only triggers navigation on explicit select', async () => {
     element._repoName = 'my-repo';
     element.params = {
       repo: 'my-repo',
@@ -271,7 +268,7 @@
     stubRestApi('getAccountCapabilities').returns(
         Promise.resolve(createAdminCapabilities()));
     stubRestApi('getAccount').returns(Promise.resolve({_id: 1}));
-    flush();
+    await flush();
     const expectedFilteredLinks = [
       {
         name: 'Repositories',
@@ -282,9 +279,14 @@
         subsection: {
           name: 'my-repo',
           view: 'repo',
-          url: '',
           children: [
             {
+              name: 'General',
+              view: 'repo',
+              url: '',
+              detailType: 'general',
+            },
+            {
               name: 'Access',
               view: 'repo',
               detailType: 'access',
@@ -338,11 +340,19 @@
         text: 'Home',
         value: 'repo',
         view: 'repo',
-        url: '',
+        url: undefined,
         parent: 'my-repo',
         detailType: undefined,
       },
       {
+        text: 'General',
+        value: 'repogeneral',
+        view: 'repo',
+        url: '',
+        detailType: 'general',
+        parent: 'my-repo',
+      },
+      {
         text: 'Access',
         value: 'repoaccess',
         view: 'repo',
@@ -386,23 +396,21 @@
     sinon.stub(GerritNav, 'navigateToRelativeUrl');
     sinon.spy(element, '_selectedIsCurrentPage');
     sinon.spy(element, '_handleSubsectionChange');
-    element.reload().then(() => {
-      assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
-      assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
-      assert.equal(
-          element.shadowRoot.querySelector('#pageSelect').value,
-          'repoaccess'
-      );
-      assert.isTrue(element._selectedIsCurrentPage.calledOnce);
-      // Doesn't trigger navigation from the page select menu.
-      assert.isFalse(GerritNav.navigateToRelativeUrl.called);
+    await element.reload();
+    assert.deepEqual(element._filteredLinks, expectedFilteredLinks);
+    assert.deepEqual(element._subsectionLinks, expectedSubsectionLinks);
+    assert.equal(
+        element.shadowRoot.querySelector('#pageSelect').value,
+        'repoaccess'
+    );
+    assert.isTrue(element._selectedIsCurrentPage.calledOnce);
+    // Doesn't trigger navigation from the page select menu.
+    assert.isFalse(GerritNav.navigateToRelativeUrl.called);
 
-      // When explicitly changed, navigation is called
-      element.shadowRoot.querySelector('#pageSelect').value = 'repo';
-      assert.isTrue(element._selectedIsCurrentPage.calledTwice);
-      assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
-      done();
-    });
+    // When explicitly changed, navigation is called
+    element.shadowRoot.querySelector('#pageSelect').value = 'repogeneral';
+    assert.isTrue(element._selectedIsCurrentPage.calledTwice);
+    assert.isTrue(GerritNav.navigateToRelativeUrl.calledOnce);
   });
 
   test('_selectedIsCurrentPage', () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
index d545b4c..04d3198 100644
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.ts
@@ -15,17 +15,9 @@
  * limitations under the License.
  */
 import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-delete-item-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-
-// TODO(TS): add description for this
-export enum DetailType {
-  BRANCHES = 'branches',
-  ID = 'id',
-  TAGS = 'tags',
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -34,11 +26,7 @@
 }
 
 @customElement('gr-confirm-delete-item-dialog')
-export class GrConfirmDeleteItemDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrConfirmDeleteItemDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -55,7 +43,38 @@
   item?: string;
 
   @property({type: String})
-  itemType?: DetailType;
+  itemTypeName?: string;
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          width: 30em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const item = this.item ?? 'UNKNOWN ITEM';
+    const itemTypeName = this.itemTypeName ?? 'UNKNOWN ITEM TYPE';
+    return html` <gr-dialog
+      confirm-label="Delete ${itemTypeName}"
+      confirm-on-enter=""
+      @confirm=${this._handleConfirmTap}
+      @cancel=${this._handleCancelTap}
+    >
+      <div class="header" slot="header">${itemTypeName} Deletion</div>
+      <div class="main" slot="main">
+        <label for="branchInput">
+          Do you really want to delete the following ${itemTypeName}?
+        </label>
+        <div>${item}</div>
+      </div>
+    </gr-dialog>`;
+  }
 
   _handleConfirmTap(e: Event) {
     e.preventDefault();
@@ -78,17 +97,4 @@
       })
     );
   }
-
-  _computeItemName(detailType: DetailType) {
-    if (detailType === DetailType.BRANCHES) {
-      return 'Branch';
-    } else if (detailType === DetailType.TAGS) {
-      return 'Tag';
-    } else if (detailType === DetailType.ID) {
-      return 'ID';
-    }
-    // TODO(TS): should never happen, this is to pass:
-    // not all code returns value
-    return '';
-  }
 }
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
deleted file mode 100644
index 890d345..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_html.ts
+++ /dev/null
@@ -1,43 +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">
-    :host {
-      display: block;
-      width: 30em;
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Delete [[_computeItemName(itemType)]]"
-    confirm-on-enter=""
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">
-      [[_computeItemName(itemType)]] Deletion
-    </div>
-    <div class="main" slot="main">
-      <label for="branchInput">
-        Do you really want to delete the following
-        [[_computeItemName(itemType)]]?
-      </label>
-      <div>[[item]]</div>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
deleted file mode 100644
index 485a48b..0000000
--- a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.js
+++ /dev/null
@@ -1,70 +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 './gr-confirm-delete-item-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
-
-suite('gr-confirm-delete-item-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sinon.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sinon.spy(element, '_handleConfirmTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._handleConfirmTap.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sinon.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sinon.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-
-  test('_computeItemName function for branches', () => {
-    assert.deepEqual(element._computeItemName('branches'), 'Branch');
-    assert.notEqual(element._computeItemName('branches'), 'Tag');
-  });
-
-  test('_computeItemName function for tags', () => {
-    assert.deepEqual(element._computeItemName('tags'), 'Tag');
-    assert.notEqual(element._computeItemName('tags'), 'Branch');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
new file mode 100644
index 0000000..e286883
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.ts
@@ -0,0 +1,57 @@
+/**
+ * @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';
+import './gr-confirm-delete-item-dialog';
+import {GrConfirmDeleteItemDialog} from './gr-confirm-delete-item-dialog';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-delete-item-dialog');
+
+suite('gr-confirm-delete-item-dialog tests', () => {
+  let element: GrConfirmDeleteItemDialog;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+    assert.equal(confirmHandler.callCount, 1);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: false,
+      })
+    );
+    assert.equal(cancelHandler.callCount, 1);
+  });
+});
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.ts
index f37e6a3..15f6f4b 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
@@ -32,9 +32,16 @@
   InheritedBooleanInfo,
 } from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import {appContext} from '../../../services/app-context';
+import {Subject} from 'rxjs';
+import {
+  repoConfig$,
+  serverConfig$,
+} from '../../../services/config/config-model';
+import {takeUntil} from 'rxjs/operators';
+import {IronInputElement} from '@polymer/iron-input/iron-input';
 
 const SUGGESTIONS_LIMIT = 15;
 const REF_PREFIX = 'refs/heads/';
@@ -42,8 +49,8 @@
 export interface GrCreateChangeDialog {
   $: {
     privateChangeCheckBox: HTMLInputElement;
-    branchInput: GrAutocomplete;
-    tagNameInput: HTMLInputElement;
+    branchInput: GrTypedAutocomplete<BranchName>;
+    tagNameInput: IronInputElement;
     messageInput: IronAutogrowTextareaElement;
   };
 }
@@ -57,19 +64,19 @@
   repoName?: RepoName;
 
   @property({type: String})
-  branch?: BranchName;
+  branch = '' as BranchName;
 
   @property({type: Object})
   _repoConfig?: ConfigInfo;
 
   @property({type: String})
-  subject?: string;
+  subject = '';
 
   @property({type: String})
   topic?: string;
 
   @property({type: Object})
-  _query?: (input: string) => Promise<{name: string}[]>;
+  _query?: (input: string) => Promise<{name: BranchName}[]>;
 
   @property({type: String})
   baseChange?: ChangeId;
@@ -84,46 +91,37 @@
   canCreate = false;
 
   @property({type: Boolean})
-  _privateChangesEnabled?: boolean;
+  _privateChangesEnabled = false;
 
   restApiService = appContext.restApiService;
 
+  disconnected$ = new Subject();
+
   constructor() {
     super();
     this._query = (input: string) => this._getRepoBranchesSuggestions(input);
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
-    if (!this.repoName) {
-      return Promise.resolve();
-    }
+    if (!this.repoName) return;
 
-    const promises = [];
+    repoConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
+      this.privateByDefault = config?.private_by_default;
+    });
 
-    promises.push(
-      this.restApiService.getProjectConfig(this.repoName).then(config => {
-        if (!config) return;
-        this.privateByDefault = config.private_by_default;
-      })
-    );
-
-    promises.push(
-      this.restApiService.getConfig().then(config => {
-        if (!config) {
-          return;
-        }
-
-        this._privateChangesEnabled =
-          config && config.change && !config.change.disable_private_changes;
-      })
-    );
-
-    return Promise.all(promises);
+    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
+      this._privateChangesEnabled =
+        config?.change?.disable_private_changes ?? false;
+    });
   }
 
-  _computeBranchClass(baseChange: boolean) {
+  override disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+
+  _computeBranchClass(baseChange?: ChangeId) {
     return baseChange ? 'hide' : '';
   }
 
@@ -168,19 +166,19 @@
       .getRepoBranches(input, this.repoName, SUGGESTIONS_LIMIT)
       .then(response => {
         if (!response) return [];
-        const branches = [];
+        const branches: Array<{name: BranchName}> = [];
         for (const branchInfo of response) {
           let name: string = branchInfo.ref;
           if (name.startsWith('refs/heads/')) {
             name = name.substring('refs/heads/'.length);
           }
-          branches.push({name});
+          branches.push({name: name as BranchName});
         }
         return branches;
       });
   }
 
-  _formatBooleanString(config: InheritedBooleanInfo) {
+  _formatBooleanString(config?: InheritedBooleanInfo) {
     if (
       config &&
       config.configured_value === InheritedBooleanInfoConfiguredValue.TRUE
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 8a4cbbe..9ed5d81 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
@@ -20,7 +20,11 @@
 import {GrCreateChangeDialog} from './gr-create-change-dialog';
 import {BranchName, GitRef, RepoName} from '../../../types/common';
 import {InheritedBooleanInfoConfiguredValue} from '../../../constants/constants';
-import {createChange, createConfig} from '../../../test/test-data-generators';
+import {
+  createChange,
+  createConfig,
+  TEST_CHANGE_ID,
+} from '../../../test/test-data-generators';
 import {stubRestApi} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-create-change-dialog');
@@ -118,24 +122,20 @@
     assert.isTrue(saveStub.called);
   });
 
-  test('_getRepoBranchesSuggestions empty', done => {
-    element._getRepoBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
+  test('_getRepoBranchesSuggestions empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('nonexistent');
+    assert.equal(branches.length, 0);
   });
 
-  test('_getRepoBranchesSuggestions non-empty', done => {
-    element._getRepoBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
+  test('_getRepoBranchesSuggestions non-empty', async () => {
+    const branches = await element._getRepoBranchesSuggestions('test-branch');
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
   });
 
   test('_computeBranchClass', () => {
-    assert.equal(element._computeBranchClass(true), 'hide');
-    assert.equal(element._computeBranchClass(false), '');
+    assert.equal(element._computeBranchClass(TEST_CHANGE_ID), 'hide');
+    assert.equal(element._computeBranchClass(undefined), '');
   });
 
   test('_computePrivateSectionClass', () => {
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 b4f07cb..180e60a 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
@@ -51,7 +51,7 @@
     this.hasNewGroupName = !!name;
   }
 
-  focus() {
+  override focus() {
     this.shadowRoot?.querySelector('input')?.focus();
   }
 
@@ -64,7 +64,7 @@
       this._groupCreated = true;
       return this.restApiService.getGroupConfig(name).then(group => {
         // TODO(TS): should group always defined ?
-        page.show(this._computeGroupUrl(group!.group_id!));
+        page.show(this._computeGroupUrl(String(group!.group_id!)));
       });
     });
   }
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 af33691..321f069 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
@@ -31,41 +31,33 @@
     element = basicFixture.instantiate();
   });
 
-  test('name is updated correctly', done => {
+  test('name is updated correctly', async () => {
     assert.isFalse(element.hasNewGroupName);
 
     const inputEl = element.root.querySelector('iron-input');
     inputEl.bindValue = GROUP_NAME;
 
-    setTimeout(() => {
-      assert.isTrue(element.hasNewGroupName);
-      assert.deepEqual(element._name, GROUP_NAME);
-      done();
-    });
+    await new Promise(resolve => setTimeout(resolve));
+    assert.isTrue(element.hasNewGroupName);
+    assert.deepEqual(element._name, GROUP_NAME);
   });
 
-  test('test for redirecting to group on successful creation', done => {
+  test('test for redirecting to group on successful creation', async () => {
     stubRestApi('createGroup').returns(Promise.resolve({status: 201}));
     stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
-    element.handleCreateGroup()
-        .then(() => {
-          assert.isTrue(showStub.calledWith('/admin/groups/551'));
-          done();
-        });
+    await element.handleCreateGroup();
+    assert.isTrue(showStub.calledWith('/admin/groups/551'));
   });
 
-  test('test for unsuccessful group creation', done => {
+  test('test for unsuccessful group creation', async () => {
     stubRestApi('createGroup').returns(Promise.resolve({status: 409}));
     stubRestApi('getGroupConfig').returns(Promise.resolve({group_id: 551}));
 
     const showStub = sinon.stub(page, 'show');
-    element.handleCreateGroup()
-        .then(() => {
-          assert.isFalse(showStub.called);
-          done();
-        });
+    await element.handleCreateGroup();
+    assert.isFalse(showStub.called);
   });
 });
 
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 8875ad4..7fce8e5 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
@@ -26,11 +26,7 @@
 import {customElement, property, observe} from '@polymer/decorators';
 import {BranchName, RepoName} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
-
-enum DetailType {
-  branches = 'branches',
-  tags = 'tags',
-}
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
 
 @customElement('gr-create-pointer-dialog')
 export class GrCreatePointerDialog extends PolymerElement {
@@ -48,7 +44,7 @@
   hasNewItemName = false;
 
   @property({type: String})
-  itemDetail?: DetailType;
+  itemDetail?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
   @property({type: String})
   _itemName?: BranchName;
@@ -75,7 +71,7 @@
     }
     const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
     const url = `${getBaseUrl()}/admin/repos/${encodeURL(this.repoName, true)}`;
-    if (this.itemDetail === DetailType.branches) {
+    if (this.itemDetail === RepoDetailView.BRANCHES) {
       return this.restApiService
         .createRepoBranch(this.repoName, this._itemName, {revision: USE_HEAD})
         .then(itemRegistered => {
@@ -83,7 +79,7 @@
             page.show(`${url},branches`);
           }
         });
-    } else if (this.itemDetail === DetailType.tags) {
+    } else if (this.itemDetail === RepoDetailView.TAGS) {
       return this.restApiService
         .createRepoTag(this.repoName, this._itemName, {
           revision: USE_HEAD,
@@ -98,8 +94,8 @@
     throw new Error(`Invalid itemDetail: ${this.itemDetail}`);
   }
 
-  _computeHideItemClass(type: DetailType) {
-    return type === DetailType.branches ? 'hideItem' : '';
+  _computeHideItemClass(type?: RepoDetailView.BRANCHES | RepoDetailView.TAGS) {
+    return type === RepoDetailView.BRANCHES ? 'hideItem' : '';
   }
 }
 
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 452aab7..0e2b157 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
@@ -38,28 +38,14 @@
     <div id="form">
       <section id="itemNameSection">
         <span class="title">[[detailType]] name</span>
-        <iron-input
-          placeholder="[[detailType]] Name"
-          bind-value="{{_itemName}}"
-        >
-          <input
-            is="iron-input"
-            placeholder="[[detailType]] Name"
-            bind-value="{{_itemName}}"
-          />
+        <iron-input bind-value="{{_itemName}}">
+          <input placeholder="[[detailType]] Name" />
         </iron-input>
       </section>
       <section id="itemRevisionSection">
         <span class="title">Initial Revision</span>
-        <iron-input
-          placeholder="Revision (Branch or SHA-1)"
-          bind-value="{{_itemRevision}}"
-        >
-          <input
-            is="iron-input"
-            placeholder="Revision (Branch or SHA-1)"
-            bind-value="{{_itemRevision}}"
-          />
+        <iron-input bind-value="{{_itemRevision}}">
+          <input placeholder="Revision (Branch or SHA-1)" />
         </iron-input>
       </section>
       <section
@@ -67,15 +53,8 @@
         class$="[[_computeHideItemClass(itemDetail)]]"
       >
         <span class="title">Annotation</span>
-        <iron-input
-          placeholder="Annotation (Optional)"
-          bind-value="{{_itemAnnotation}}"
-        >
-          <input
-            is="iron-input"
-            placeholder="Annotation (Optional)"
-            bind-value="{{_itemAnnotation}}"
-          />
+        <iron-input bind-value="{{_itemAnnotation}}">
+          <input placeholder="Annotation (Optional)" />
         </iron-input>
       </section>
     </div>
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
deleted file mode 100644
index 60af4d5..0000000
--- a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.js
+++ /dev/null
@@ -1,103 +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 './gr-create-pointer-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
-
-suite('gr-create-pointer-dialog tests', () => {
-  let element;
-
-  const ironInput = function(element) {
-    return element.querySelector('iron-input');
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('branch created', done => {
-    stubRestApi('createRepoBranch').returns(Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-branch';
-    element.itemDetail = 'branches';
-
-    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewItemName);
-      assert.equal(element._itemName, 'test-branch2');
-      assert.equal(element._itemRevision, 'HEAD');
-      done();
-    });
-  });
-
-  test('tag created', done => {
-    stubRestApi('createRepoTag').returns(Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-tag';
-    element.itemDetail = 'tags';
-
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewItemName);
-      assert.equal(element._itemName, 'test-tag2');
-      assert.equal(element._itemRevision, 'HEAD');
-      done();
-    });
-  });
-
-  test('tag created with annotations', done => {
-    stubRestApi('createRepoTag').returns(() => Promise.resolve({}));
-
-    assert.isFalse(element.hasNewItemName);
-
-    element._itemName = 'test-tag';
-    element._itemAnnotation = 'test-message';
-    element.itemDetail = 'tags';
-
-    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
-    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
-    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
-
-    setTimeout(() => {
-      assert.isTrue(element.hasNewItemName);
-      assert.equal(element._itemName, 'test-tag2');
-      assert.equal(element._itemAnnotation, 'test-message2');
-      assert.equal(element._itemRevision, 'HEAD');
-      done();
-    });
-  });
-
-  test('_computeHideItemClass returns hideItem if type is branches', () => {
-    assert.equal(element._computeHideItemClass('branches'), 'hideItem');
-  });
-
-  test('_computeHideItemClass returns strings if not branches', () => {
-    assert.equal(element._computeHideItemClass('tags'), '');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
new file mode 100644
index 0000000..ea0919c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.ts
@@ -0,0 +1,103 @@
+/**
+ * @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';
+import './gr-create-pointer-dialog';
+import {GrCreatePointerDialog} from './gr-create-pointer-dialog';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {BranchName} from '../../../types/common';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation';
+import {IronInputElement} from '@polymer/iron-input';
+
+const basicFixture = fixtureFromElement('gr-create-pointer-dialog');
+
+suite('gr-create-pointer-dialog tests', () => {
+  let element: GrCreatePointerDialog;
+
+  const ironInput = (element: Element) =>
+    queryAndAssert<IronInputElement>(element, 'iron-input');
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('branch created', async () => {
+    stubRestApi('createRepoBranch').returns(Promise.resolve(new Response()));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-branch' as BranchName;
+    element.itemDetail = 'branches' as RepoDetailView.BRANCHES;
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-branch2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    await flush();
+
+    assert.isTrue(element.hasNewItemName);
+    assert.equal(element._itemName, 'test-branch2' as BranchName);
+    assert.equal(element._itemRevision, 'HEAD');
+  });
+
+  test('tag created', async () => {
+    stubRestApi('createRepoTag').returns(Promise.resolve(new Response()));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag' as BranchName;
+    element.itemDetail = 'tags' as RepoDetailView.TAGS;
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    await flush();
+    assert.isTrue(element.hasNewItemName);
+    assert.equal(element._itemName, 'test-tag2' as BranchName);
+    assert.equal(element._itemRevision, 'HEAD');
+  });
+
+  test('tag created with annotations', async () => {
+    stubRestApi('createRepoTag').returns(Promise.resolve(new Response()));
+
+    assert.isFalse(element.hasNewItemName);
+
+    element._itemName = 'test-tag' as BranchName;
+    element._itemAnnotation = 'test-message';
+    element.itemDetail = 'tags' as RepoDetailView.TAGS;
+
+    ironInput(element.$.itemNameSection).bindValue = 'test-tag2';
+    ironInput(element.$.itemAnnotationSection).bindValue = 'test-message2';
+    ironInput(element.$.itemRevisionSection).bindValue = 'HEAD';
+
+    await flush();
+    assert.isTrue(element.hasNewItemName);
+    assert.equal(element._itemName, 'test-tag2' as BranchName);
+    assert.equal(element._itemAnnotation, 'test-message2');
+    assert.equal(element._itemRevision, 'HEAD');
+  });
+
+  test('_computeHideItemClass returns hideItem if type is branches', () => {
+    assert.equal(
+      element._computeHideItemClass(RepoDetailView.BRANCHES),
+      'hideItem'
+    );
+  });
+
+  test('_computeHideItemClass returns strings if not branches', () => {
+    assert.equal(element._computeHideItemClass(RepoDetailView.TAGS), '');
+  });
+});
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 94a4b0a..a493747 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
@@ -87,7 +87,7 @@
     return getBaseUrl() + '/admin/repos/' + encodeURL(repoName, true);
   }
 
-  focus() {
+  override focus() {
     this.shadowRoot?.querySelector('input')?.focus();
   }
 
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 e6f9bbe..f1babee 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
@@ -33,7 +33,7 @@
     assert.isFalse(element.$.parentRepo.bindValue);
   });
 
-  test('repo created', done => {
+  test('repo created', async () => {
     const configInputObj = {
       name: 'test-repo',
       create_empty_commit: true,
@@ -67,16 +67,14 @@
 
     assert.deepEqual(element._repoConfig, configInputObj);
 
-    element.handleCreateRepo().then(() => {
-      assert.isTrue(saveStub.lastCall.calledWithExactly(
-          {
-            ...configInputObj,
-            owners: ['testId'],
-            branches: ['main'],
-          }
-      ));
-      done();
-    });
+    await element.handleCreateRepo();
+    assert.isTrue(saveStub.lastCall.calledWithExactly(
+        {
+          ...configInputObj,
+          owners: ['testId'],
+          branches: ['main'],
+        }
+    ));
   });
 });
 
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 0bf292d..6605350 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
@@ -17,11 +17,9 @@
 
 import '../../../styles/gr-table-styles';
 import '../../../styles/shared-styles';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-account-link/gr-account-link';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group-audit-log_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property} from '@polymer/decorators';
 import {
@@ -29,15 +27,15 @@
   AccountInfo,
   EncodedGroupId,
   GroupAuditEventInfo,
+  GroupAuditGroupEventInfo,
+  isGroupAuditGroupEventInfo,
 } 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'];
-
 @customElement('gr-group-audit-log')
-export class GrGroupAuditLog extends ListViewMixin(PolymerElement) {
+export class GrGroupAuditLog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -53,14 +51,12 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Audit Log');
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._getAuditLogs();
   }
@@ -103,8 +99,8 @@
     return item;
   }
 
-  _isGroupEvent(type: string) {
-    return GROUP_EVENTS.indexOf(type) !== -1;
+  _isGroupEvent(event: GroupAuditEventInfo): event is GroupAuditGroupEventInfo {
+    return isGroupAuditGroupEventInfo(event);
   }
 
   _computeGroupUrl(group: GroupInfo) {
@@ -129,6 +125,10 @@
 
     return '';
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
 
 declare global {
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 32db13f..828aa55 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
@@ -43,17 +43,17 @@
       <template is="dom-repeat" items="[[_auditLog]]">
         <tr class="table">
           <td class="date">
-            <gr-date-formatter has-tooltip="" date-str="[[item.date]]">
+            <gr-date-formatter withTooltip date-str="[[item.date]]">
             </gr-date-formatter>
           </td>
           <td class="type">[[itemType(item.type)]]</td>
           <td class="member">
-            <template is="dom-if" if="[[_isGroupEvent(item.type)]]">
+            <template is="dom-if" if="[[_isGroupEvent(item)]]">
               <a href$="[[_computeGroupUrl(item.member)]]">
                 [[_getNameForGroup(item.member)]]
               </a>
             </template>
-            <template is="dom-if" if="[[!_isGroupEvent(item.type)]]">
+            <template is="dom-if" if="[[!_isGroupEvent(item)]]">
               <gr-account-link account="[[item.member]]"></gr-account-link>
               [[_getIdForUser(item.member)]]
             </template>
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
deleted file mode 100644
index fdd5d15..0000000
--- a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.js
+++ /dev/null
@@ -1,96 +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 './gr-group-audit-log.js';
-import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-group-audit-log');
-
-suite('gr-group-audit-log tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('members', () => {
-    test('test _getNameForGroup', () => {
-      let group = {
-        member: {
-          name: 'test-name',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-name');
-
-      group = {
-        member: {
-          id: 'test-id',
-        },
-      };
-      assert.equal(element._getNameForGroup(group.member), 'test-id');
-    });
-
-    test('test _isGroupEvent', () => {
-      assert.isTrue(element._isGroupEvent('ADD_GROUP'));
-      assert.isTrue(element._isGroupEvent('REMOVE_GROUP'));
-
-      assert.isFalse(element._isGroupEvent('ADD_USER'));
-      assert.isFalse(element._isGroupEvent('REMOVE_USER'));
-    });
-  });
-
-  suite('users', () => {
-    test('test _getIdForUser', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-          _account_id: 12,
-        },
-      };
-      assert.equal(element._getIdForUser(account.user), ' (12)');
-    });
-
-    test('test _account_id not present', () => {
-      const account = {
-        user: {
-          username: 'test-user',
-        },
-      };
-      assert.equal(element._getIdForUser(account.user), '');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      element.groupId = 1;
-
-      const response = {status: 404};
-      stubRestApi('getGroupAuditLog').callsFake((group, errFn) => {
-        errFn(response);
-      });
-
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element._getAuditLogs();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
new file mode 100644
index 0000000..dc09390
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.ts
@@ -0,0 +1,125 @@
+/**
+ * @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 './gr-group-audit-log.js';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi,
+} from '../../../test/test-utils.js';
+import {GrGroupAuditLog} from './gr-group-audit-log.js';
+import {
+  EncodedGroupId,
+  GroupAuditEventType,
+  GroupInfo,
+  GroupName,
+} from '../../../types/common.js';
+import {
+  createAccountWithId,
+  createGroupAuditEventInfo,
+  createGroupInfo,
+} from '../../../test/test-data-generators.js';
+import {PageErrorEvent} from '../../../types/events.js';
+
+const basicFixture = fixtureFromElement('gr-group-audit-log');
+
+suite('gr-group-audit-log tests', () => {
+  let element: GrGroupAuditLog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('members', () => {
+    test('test _getNameForGroup', () => {
+      let member: GroupInfo = {
+        ...createGroupInfo(),
+        name: 'test-name' as GroupName,
+      };
+      assert.equal(element._getNameForGroup(member), 'test-name');
+
+      member = createGroupInfo('test-id');
+      assert.equal(element._getNameForGroup(member), 'test-id');
+    });
+
+    test('test _isGroupEvent', () => {
+      assert.isTrue(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.ADD_GROUP)
+        )
+      );
+      assert.isTrue(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.REMOVE_GROUP)
+        )
+      );
+
+      assert.isFalse(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.ADD_USER)
+        )
+      );
+      assert.isFalse(
+        element._isGroupEvent(
+          createGroupAuditEventInfo(GroupAuditEventType.REMOVE_USER)
+        )
+      );
+    });
+  });
+
+  suite('users', () => {
+    test('test _getIdForUser', () => {
+      const user = {
+        ...createAccountWithId(12),
+        username: 'test-user',
+      };
+      assert.equal(element._getIdForUser(user), ' (12)');
+    });
+
+    test('test _account_id not present', () => {
+      const account = {
+        user: {
+          username: 'test-user',
+        },
+      };
+      assert.equal(element._getIdForUser(account.user), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      element.groupId = '1' as EncodedGroupId;
+      await flush();
+
+      const response = {...new Response(), status: 404};
+      stubRestApi('getGroupAuditLog').callsFake((_group, errFn) => {
+        if (errFn) errFn(response);
+        return Promise.resolve(undefined);
+      });
+
+      const pageErrorCalled = mockPromise();
+      addListenerForTest(document, 'page-error', e => {
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
+        pageErrorCalled.resolve();
+      });
+
+      element._getAuditLogs();
+      await pageErrorCalled;
+    });
+  });
+});
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 bc793fa..c38f8be 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/gr-table-styles';
@@ -48,6 +49,7 @@
 } from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {assertNever} from '../../../utils/common-util';
 
 const SUGGESTIONS_LIMIT = 15;
 const SAVING_ERROR_TEXT =
@@ -55,6 +57,11 @@
 
 const URL_REGEX = '^(?:[a-z]+:)?//';
 
+export enum ItemType {
+  MEMBER = 'member',
+  INCLUDED_GROUP = 'includedGroup',
+}
+
 export interface GrGroupMembers {
   $: {
     overlay: GrOverlay;
@@ -94,10 +101,10 @@
   _includedGroups?: GroupInfo[];
 
   @property({type: String})
-  _itemName?: GroupInfo | AccountInfo;
+  _itemName?: string;
 
   @property({type: String})
-  _itemType?: string;
+  _itemType?: ItemType;
 
   @property({type: Object})
   _queryMembers: AutocompleteQuery;
@@ -121,8 +128,7 @@
     this._queryIncludedGroup = input => this._getGroupSuggestions(input);
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._loadGroupDetails();
 
@@ -229,7 +235,7 @@
       return Promise.reject(new Error('group name undefined'));
     }
     this.$.overlay.close();
-    if (this._itemType === 'member') {
+    if (this._itemType === ItemType.MEMBER) {
       return this.restApiService
         .deleteGroupMember(this._groupName, this._itemId! as AccountId)
         .then(itemDeleted => {
@@ -241,7 +247,7 @@
               });
           }
         });
-    } else if (this._itemType === 'includedGroup') {
+    } else if (this._itemType === ItemType.INCLUDED_GROUP) {
       return this.restApiService
         .deleteIncludedGroup(this._groupName, this._itemId! as GroupId)
         .then(itemDeleted => {
@@ -260,22 +266,34 @@
     return Promise.reject(new Error('Unrecognized item type'));
   }
 
+  _computeItemTypeName(itemType?: ItemType): string {
+    if (itemType === undefined) return '';
+    switch (itemType) {
+      case ItemType.INCLUDED_GROUP:
+        return 'Included Group';
+      case ItemType.MEMBER:
+        return 'Member';
+      default:
+        assertNever(itemType, 'unknown item type: ${itemType}');
+    }
+  }
+
   _handleConfirmDialogCancel() {
     this.$.overlay.close();
   }
 
   _handleDeleteMember(e: PolymerDomRepeatEvent<AccountInfo>) {
-    const id = (e.model.get('item._account_id') as unknown) as AccountId;
+    const id = e.model.get('item._account_id');
     const name = e.model.get('item.name');
     const username = e.model.get('item.username');
     const email = e.model.get('item.email');
-    const item = username || name || email || id;
+    const item = username || name || email || id?.toString();
     if (!item) {
       return;
     }
     this._itemName = item;
     this._itemId = id;
-    this._itemType = 'member';
+    this._itemType = ItemType.MEMBER;
     this.$.overlay.open();
   }
 
@@ -326,7 +344,7 @@
     }
     this._itemName = item;
     this._itemId = id;
-    this._itemType = 'includedGroup';
+    this._itemType = ItemType.INCLUDED_GROUP;
     this.$.overlay.open();
   }
 
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 47da237..518abac 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
@@ -175,7 +178,7 @@
       on-confirm="_handleDeleteConfirm"
       on-cancel="_handleConfirmDialogCancel"
       item="[[_itemName]]"
-      item-type="[[_itemType]]"
+      item-type-name="[[_computeItemTypeName(_itemType)]]"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
 `;
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 672ac07..b91b04b 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,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-group-members.js';
 import {dom, flush} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {addListenerForTest, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise, stubBaseUrl, stubRestApi} from '../../../test/test-utils.js';
+import {ItemType} from './gr-group-members.js';
 
 const basicFixture = fixtureFromElement('gr-group-members');
 
@@ -139,7 +140,7 @@
     'https://test/site/group/url');
   });
 
-  test('save members correctly', () => {
+  test('save members correctly', async () => {
     element._groupOwner = true;
 
     const memberName = 'test-admin';
@@ -154,6 +155,7 @@
     element.$.groupMemberSearchInput.text = memberName;
     element.$.groupMemberSearchInput.value = 1234;
 
+    await flush();
     assert.isFalse(button.hasAttribute('disabled'));
 
     return element._handleSavingGroupMember().then(() => {
@@ -164,7 +166,7 @@
     });
   });
 
-  test('save included groups correctly', () => {
+  test('save included groups correctly', async () => {
     element._groupOwner = true;
 
     const includedGroupName = 'testName';
@@ -178,7 +180,7 @@
 
     element.$.includedGroupSearchInput.text = includedGroupName;
     element.$.includedGroupSearchInput.value = 'testId';
-
+    await flush();
     assert.isFalse(button.hasAttribute('disabled'));
 
     return element._handleSavingIncludedGroups().then(() => {
@@ -234,42 +236,32 @@
     assert.isTrue(exceptionThrown);
   });
 
-  test('_getAccountSuggestions empty', done => {
-    element
-        ._getAccountSuggestions('nonexistent').then(accounts => {
-          assert.equal(accounts.length, 0);
-          done();
-        });
+  test('_getAccountSuggestions empty', async () => {
+    const accounts = await element._getAccountSuggestions('nonexistent');
+    assert.equal(accounts.length, 0);
   });
 
-  test('_getAccountSuggestions non-empty', done => {
-    element
-        ._getAccountSuggestions('test-').then(accounts => {
-          assert.equal(accounts.length, 3);
-          assert.equal(accounts[0].name,
-              'test-account <test.account@example.com>');
-          assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
-          assert.equal(accounts[2].name, 'test-git');
-          done();
-        });
+  test('_getAccountSuggestions non-empty', async () => {
+    const accounts = await element._getAccountSuggestions('test-');
+    assert.equal(accounts.length, 3);
+    assert.equal(accounts[0].name,
+        'test-account <test.account@example.com>');
+    assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+    assert.equal(accounts[2].name, 'test-git');
   });
 
-  test('_getGroupSuggestions empty', done => {
-    element
-        ._getGroupSuggestions('nonexistent').then(groups => {
-          assert.equal(groups.length, 0);
-          done();
-        });
+  test('_getGroupSuggestions empty', async () => {
+    const groups = await element._getGroupSuggestions('nonexistent');
+
+    assert.equal(groups.length, 0);
   });
 
-  test('_getGroupSuggestions non-empty', done => {
-    element
-        ._getGroupSuggestions('test').then(groups => {
-          assert.equal(groups.length, 2);
-          assert.equal(groups[0].name, 'test-admin');
-          assert.equal(groups[1].name, 'test/Administrator (admin)');
-          done();
-        });
+  test('_getGroupSuggestions non-empty', async () => {
+    const groups = await element._getGroupSuggestions('test');
+
+    assert.equal(groups.length, 2);
+    assert.equal(groups[0].name, 'test-admin');
+    assert.equal(groups[1].name, 'test/Administrator (admin)');
   });
 
   test('_computeHideItemClass returns string for admin', () => {
@@ -342,22 +334,30 @@
     assert.equal(element._computeGroupUrl(url), url);
   });
 
-  test('fires page-error', done => {
+  test('fires page-error', async () => {
     groupStub.restore();
 
     element.groupId = 1;
 
     const response = {status: 404};
-    stubRestApi('getGroupConfig')
-        .callsFake((group, errFn) => {
-          errFn(response);
-        });
+    stubRestApi('getGroupConfig').callsFake((group, errFn) => {
+      errFn(response);
+      return Promise.resolve();
+    });
+    const promise = mockPromise();
     addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
-      done();
+      promise.resolve();
     });
 
     element._loadGroupDetails();
+    await promise;
+  });
+
+  test('_computeItemName', () => {
+    assert.equal(element._computeItemTypeName(ItemType.MEMBER), 'Member');
+    assert.equal(element._computeItemTypeName(ItemType.INCLUDED_GROUP),
+        'Included Group');
   });
 });
 
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 0bb13ba..442f454 100644
--- a/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.ts
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-select/gr-select';
+import '../../shared/gr-textarea/gr-textarea';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-group_html';
 import {customElement, property, observe} from '@polymer/decorators';
@@ -129,8 +130,7 @@
     this._query = (input: string) => this._getGroupSuggestions(input);
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._loadGroup();
   }
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 ba089f6..c08908d 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
@@ -25,9 +28,6 @@
       color: var(--deemphasized-text-color);
       content: ' *';
     }
-    .inputUpdateBtn {
-      margin-top: var(--spacing-s);
-    }
   </style>
   <style include="gr-form-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
@@ -110,12 +110,14 @@
           </h3>
           <fieldset>
             <div>
-              <iron-autogrow-textarea
+              <gr-textarea
                 class="description"
                 autocomplete="on"
-                bind-value="{{_groupConfig.description}}"
+                rows="4"
+                monospace=""
+                text="{{_groupConfig.description}}"
                 disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin, _groupIsInternal)]]"
-              ></iron-autogrow-textarea>
+              ></gr-textarea>
             </div>
             <span
               class="value"
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 0668330..e390ac5 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,7 +17,11 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-group.js';
-import {stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi,
+} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-group');
 
@@ -49,17 +53,15 @@
         .display === 'none');
   });
 
-  test('default values are populated with internal group', done => {
+  test('default values are populated with internal group', async () => {
     stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
-    element._loadGroup().then(() => {
-      assert.isTrue(element._groupIsInternal);
-      assert.isFalse(element.$.visibleToAll.bindValue);
-      done();
-    });
+    await element._loadGroup();
+    assert.isTrue(element._groupIsInternal);
+    assert.isFalse(element.$.visibleToAll.bindValue);
   });
 
-  test('default values with external group', done => {
+  test('default values with external group', async () => {
     const groupExternal = {...group};
     groupExternal.id = 'external-group-id';
     groupStub.restore();
@@ -67,14 +69,12 @@
         Promise.resolve(groupExternal));
     stubRestApi('getIsGroupOwner').returns(Promise.resolve(true));
     element.groupId = 1;
-    element._loadGroup().then(() => {
-      assert.isFalse(element._groupIsInternal);
-      assert.isFalse(element.$.visibleToAll.bindValue);
-      done();
-    });
+    await element._loadGroup();
+    assert.isFalse(element._groupIsInternal);
+    assert.isFalse(element.$.visibleToAll.bindValue);
   });
 
-  test('rename group', done => {
+  test('rename group', async () => {
     const groupName = 'test-group';
     const groupName2 = 'test-group2';
     element.groupId = 1;
@@ -88,25 +88,23 @@
 
     const button = element.$.inputUpdateNameBtn;
 
-    element._loadGroup().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
+    await element._loadGroup();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(element.$.Title.classList.contains('edited'));
 
-      element.$.groupNameInput.text = groupName2;
+    element.$.groupNameInput.text = groupName2;
 
-      assert.isFalse(button.hasAttribute('disabled'));
-      assert.isTrue(element.$.groupName.classList.contains('edited'));
+    await flush();
+    assert.isFalse(button.hasAttribute('disabled'));
+    assert.isTrue(element.$.groupName.classList.contains('edited'));
 
-      element._handleSaveName().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        assert.equal(element._groupName, groupName2);
-        done();
-      });
-    });
+    await element._handleSaveName();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(element.$.Title.classList.contains('edited'));
+    assert.equal(element._groupName, groupName2);
   });
 
-  test('rename group owner', done => {
+  test('rename group owner', async () => {
     const groupName = 'test-group';
     element.groupId = 1;
     element._groupConfig = {
@@ -119,24 +117,22 @@
 
     const button = element.$.inputUpdateOwnerBtn;
 
-    element._loadGroup().then(() => {
-      assert.isTrue(button.hasAttribute('disabled'));
-      assert.isFalse(element.$.Title.classList.contains('edited'));
+    await element._loadGroup();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(element.$.Title.classList.contains('edited'));
 
-      element.$.groupOwnerInput.text = 'testId2';
+    element.$.groupOwnerInput.text = 'testId2';
 
-      assert.isFalse(button.hasAttribute('disabled'));
-      assert.isTrue(element.$.groupOwner.classList.contains('edited'));
+    await flush();
+    assert.isFalse(button.hasAttribute('disabled'));
+    assert.isTrue(element.$.groupOwner.classList.contains('edited'));
 
-      element._handleSaveOwner().then(() => {
-        assert.isTrue(button.hasAttribute('disabled'));
-        assert.isFalse(element.$.Title.classList.contains('edited'));
-        done();
-      });
-    });
+    await element._handleSaveOwner();
+    assert.isTrue(button.hasAttribute('disabled'));
+    assert.isFalse(element.$.Title.classList.contains('edited'));
   });
 
-  test('test for undefined group name', done => {
+  test('test for undefined group name', async () => {
     groupStub.restore();
 
     stubRestApi('getGroupConfig').returns(Promise.resolve({}));
@@ -149,16 +145,13 @@
 
     // Test that loading shows instead of filling
     // in group details
-    element._loadGroup().then(() => {
-      assert.isTrue(element.$.loading.classList.contains('loading'));
+    await element._loadGroup();
+    assert.isTrue(element.$.loading.classList.contains('loading'));
 
-      assert.isTrue(element._loading);
-
-      done();
-    });
+    assert.isTrue(element._loading);
   });
 
-  test('test fire event', done => {
+  test('test fire event', async () => {
     element._groupConfig = {
       name: 'test-group',
     };
@@ -166,11 +159,8 @@
     stubRestApi('saveGroupName').returns(Promise.resolve({status: 200}));
 
     const showStub = sinon.stub(element, 'dispatchEvent');
-    element._handleSaveName()
-        .then(() => {
-          assert.isTrue(showStub.called);
-          done();
-        });
+    await element._handleSaveName();
+    assert.isTrue(showStub.called);
   });
 
   test('_computeGroupDisabled', () => {
@@ -206,7 +196,7 @@
     assert.equal(element._computeLoadingClass(false), '');
   });
 
-  test('fires page-error', done => {
+  test('fires page-error', async () => {
     groupStub.restore();
 
     element.groupId = 1;
@@ -214,14 +204,17 @@
     const response = {status: 404};
     stubRestApi('getGroupConfig').callsFake((group, errFn) => {
       errFn(response);
+      return Promise.resolve(undefined);
     });
 
+    const promise = mockPromise();
     addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
-      done();
+      promise.resolve();
     });
 
     element._loadGroup();
+    await promise;
   });
 
   test('uuid', () => {
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 4b17476..f2b84c2 100644
--- a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.ts
@@ -148,8 +148,7 @@
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._setupValues();
   }
@@ -382,6 +381,10 @@
       this.groups[groupId] = {name: this.$.groupAutocomplete.text};
     }
 
+    // Clear the text of the auto-complete box, so that the user can add the
+    // next group.
+    this.$.groupAutocomplete.text = '';
+
     // Wait for new rule to get value populated via gr-rule-editor, and then
     // add to permission values as well, so that the change gets propagated
     // back to the section. Since the rule is inside a dom-repeat, a flush
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 d5668d8..8f25db5 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
@@ -187,35 +187,31 @@
           groupsWithRules);
     });
 
-    test('_getGroupSuggestions without existing rules', done => {
+    test('_getGroupSuggestions without existing rules', async () => {
       element._groupsWithRules = {};
 
-      element._getGroupSuggestions().then(groups => {
-        assert.deepEqual(groups, [
-          {
-            name: 'Administrators',
-            value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
-          }, {
-            name: 'Anonymous Users',
-            value: 'global%3AAnonymous-Users',
-          },
-        ]);
-        done();
-      });
+      const groups = await element._getGroupSuggestions();
+      assert.deepEqual(groups, [
+        {
+          name: 'Administrators',
+          value: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+        }, {
+          name: 'Anonymous Users',
+          value: 'global%3AAnonymous-Users',
+        },
+      ]);
     });
 
-    test('_getGroupSuggestions with existing rules filters them', done => {
+    test('_getGroupSuggestions with existing rules filters them', async () => {
       element._groupsWithRules = {
         '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
       };
 
-      element._getGroupSuggestions().then(groups => {
-        assert.deepEqual(groups, [{
-          name: 'Anonymous Users',
-          value: 'global%3AAnonymous-Users',
-        }]);
-        done();
-      });
+      const groups = await element._getGroupSuggestions();
+      assert.deepEqual(groups, [{
+        name: 'Anonymous Users',
+        value: 'global%3AAnonymous-Users',
+      }]);
     });
 
     test('_handleRemovePermission', () => {
@@ -308,6 +304,7 @@
       assert.equal(Object.keys(element._groupsWithRules).length, 3);
       assert.deepEqual(element.permission.value.rules['ldap:CN=test te.st'],
           {action: 'ALLOW', min: -2, max: 2, added: true});
+      assert.equal(element.$.groupAutocomplete.text, '');
       // New rule should be removed if cancel from editing.
       element.editing = false;
       assert.equal(element._rules.length, 2);
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
index 0a0259a..55f7567 100644
--- a/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-config-array-editor/gr-plugin-config-array-editor.ts
@@ -35,7 +35,7 @@
 }
 
 @customElement('gr-plugin-config-array-editor')
-class GrPluginConfigArrayEditor extends PolymerElement {
+export class GrPluginConfigArrayEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
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 96cae0e..9f51688 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
@@ -19,21 +19,21 @@
 import '../../shared/gr-list-view/gr-list-view';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-plugin-list_html';
-import {
-  ListViewMixin,
-  ListViewParams,
-} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {PluginInfo} from '../../../types/common';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
+import {ListViewParams} from '../../gr-app-types';
 
 interface PluginInfoWithName extends PluginInfo {
   name: string;
 }
+
 @customElement('gr-plugin-list')
-export class GrPluginList extends ListViewMixin(PolymerElement) {
+export class GrPluginList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -74,16 +74,15 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Plugins');
   }
 
   _paramsChanged(params: ListViewParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
 
     return this._getPlugins(this._filter, this._pluginsPerPage, this._offset);
   }
@@ -111,7 +110,15 @@
   }
 
   _computePluginUrl(id: string) {
-    return this.getUrl('/', id);
+    return getBaseUrl() + '/' + encodeURL(id, true);
+  }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  computeShownItems(plugins: PluginInfoWithName[]) {
+    return plugins.slice(0, SHOWN_ITEMS_COUNT);
   }
 }
 
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 a9281e4..62c89b2 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,7 +18,10 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-plugin-list.js';
 import 'lodash/lodash.js';
-import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-plugin-list');
 
@@ -52,52 +55,45 @@
     counter = 0;
   });
 
-  suite('list with plugins', () => {
-    setup(done => {
+  suite('list with plugins', async () => {
+    setup(async () => {
       plugins = _.times(26, pluginGenerator);
       stubRestApi('getPlugins').returns(Promise.resolve(plugins));
-      element._paramsChanged(value).then(() => { flush(done); });
+      await element._paramsChanged(value);
+      await flush();
     });
 
-    test('plugin in the list is formatted correctly', done => {
-      flush(() => {
-        assert.equal(element._plugins[4].id, 'test5');
-        assert.equal(element._plugins[4].index_url, 'plugins/test5/');
-        assert.equal(element._plugins[4].version, 'version-5');
-        assert.equal(element._plugins[4].api_version, 'api-version-5');
-        assert.equal(element._plugins[4].disabled, false);
-        done();
-      });
+    test('plugin in the list is formatted correctly', async () => {
+      await flush();
+      assert.equal(element._plugins[4].id, 'test5');
+      assert.equal(element._plugins[4].index_url, 'plugins/test5/');
+      assert.equal(element._plugins[4].version, 'version-5');
+      assert.equal(element._plugins[4].api_version, 'api-version-5');
+      assert.equal(element._plugins[4].disabled, false);
     });
 
-    test('with and without urls', done => {
-      flush(() => {
-        const names = element.root.querySelectorAll('.name');
-        assert.isOk(names[1].querySelector('a'));
-        assert.equal(names[1].querySelector('a').innerText, 'test1');
-        assert.isNotOk(names[2].querySelector('a'));
-        assert.equal(names[2].innerText, 'test2');
-        done();
-      });
+    test('with and without urls', async () => {
+      await flush();
+      const names = element.root.querySelectorAll('.name');
+      assert.isOk(names[1].querySelector('a'));
+      assert.equal(names[1].querySelector('a').innerText, 'test1');
+      assert.isNotOk(names[2].querySelector('a'));
+      assert.equal(names[2].innerText, 'test2');
     });
 
-    test('versions', done => {
-      flush(() => {
-        const versions = element.root.querySelectorAll('.version');
-        assert.equal(versions[2].innerText, 'version-2');
-        assert.equal(versions[3].innerText, '--');
-        done();
-      });
+    test('versions', async () => {
+      await flush();
+      const versions = element.root.querySelectorAll('.version');
+      assert.equal(versions[2].innerText, 'version-2');
+      assert.equal(versions[3].innerText, '--');
     });
 
-    test('api versions', done => {
-      flush(() => {
-        const apiVersions = element.root.querySelectorAll(
-            '.apiVersion');
-        assert.equal(apiVersions[3].innerText, 'api-version-3');
-        assert.equal(apiVersions[4].innerText, '--');
-        done();
-      });
+    test('api versions', async () => {
+      await flush();
+      const apiVersions = element.root.querySelectorAll(
+          '.apiVersion');
+      assert.equal(apiVersions[3].innerText, 'api-version-3');
+      assert.equal(apiVersions[4].innerText, '--');
     });
 
     test('_shownPlugins', () => {
@@ -106,10 +102,11 @@
   });
 
   suite('list with less then 26 plugins', () => {
-    setup(done => {
+    setup(async () => {
       plugins = _.times(25, pluginGenerator);
       stubRestApi('getPlugins').returns(Promise.resolve(plugins));
-      element._paramsChanged(value).then(() => { flush(done); });
+      await element._paramsChanged(value);
+      await flush();
     });
 
     test('_shownPlugins', () => {
@@ -133,7 +130,7 @@
   });
 
   suite('loading', () => {
-    test('correct contents are displayed', () => {
+    test('correct contents are displayed', async () => {
       assert.isTrue(element._loading);
       assert.equal(element.computeLoadingClass(element._loading), 'loading');
       assert.equal(getComputedStyle(element.$.loading).display, 'block');
@@ -141,30 +138,33 @@
       element._loading = false;
       element._plugins = _.times(25, pluginGenerator);
 
-      flush();
+      await flush();
       assert.equal(element.computeLoadingClass(element._loading), '');
       assert.equal(getComputedStyle(element.$.loading).display, 'none');
     });
   });
 
   suite('404', () => {
-    test('fires page-error', done => {
+    test('fires page-error', async () => {
       const response = {status: 404};
       stubRestApi('getPlugins').callsFake(
           (filter, pluginsPerPage, opt_offset, errFn) => {
             errFn(response);
+            return Promise.resolve(undefined);
           });
 
+      const promise = mockPromise();
       addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
-        done();
+        promise.resolve();
       });
 
       const value = {
         filter: 'test',
         offset: 25,
       };
-      element._paramsChanged(value);
+      await element._paramsChanged(value);
+      await promise;
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
index 869a416..6136f1e1 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-access/gr-repo-access-interfaces.ts
@@ -14,9 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 /**
- * @fileOverview This file contains interfaces shared between gr-repo-access
+ * @fileoverview This file contains interfaces shared between gr-repo-access
  * and nested elements (gr-access-section, gr-permission)
  */
 
@@ -72,7 +71,8 @@
   extends PermissionRuleInfo,
     PropertyTreeNode {}
 
-export type PermissionAccessSection = PermissionArrayItem<EditableAccessSectionInfo>;
+export type PermissionAccessSection =
+  PermissionArrayItem<EditableAccessSectionInfo>;
 
 export interface NewlyAddedGroupInfo {
   name: string;
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 80c58ca..56e981a 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-menu-page-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
@@ -91,7 +92,7 @@
   _groups?: ProjectAccessGroups;
 
   @property({type: Object})
-  _inheritsFrom?: ProjectInfo | null | {};
+  _inheritsFrom?: ProjectInfo;
 
   @property({type: Object})
   _labels?: LabelNameToLabelTypeInfoMap;
@@ -114,7 +115,7 @@
   @property({type: Boolean})
   _loading = true;
 
-  private originalInheritsFrom?: ProjectInfo | null;
+  private originalInheritsFrom?: ProjectInfo;
 
   private readonly restApiService = appContext.restApiService;
 
@@ -159,16 +160,13 @@
         // Keep a copy of the original inherit from values separate from
         // the ones data bound to gr-autocomplete, so the original value
         // can be restored if the user cancels.
-        this._inheritsFrom = res.inherits_from
-          ? {
-              ...res.inherits_from,
-            }
-          : null;
-        this.originalInheritsFrom = res.inherits_from
-          ? {
-              ...res.inherits_from,
-            }
-          : null;
+        if (res.inherits_from) {
+          this._inheritsFrom = {...res.inherits_from};
+          this.originalInheritsFrom = {...res.inherits_from};
+        } else {
+          this._inheritsFrom = undefined;
+          this.originalInheritsFrom = undefined;
+        }
         // Initialize the filter value so when the user clicks edit, the
         // current value appears. If there is no parent repo, it is
         // initialized as an empty string.
@@ -218,19 +216,11 @@
   }
 
   _handleUpdateInheritFrom(e: CustomEvent<{value: string}>) {
-    const parentProject: ProjectInfo = {
+    this._inheritsFrom = {
+      ...(this._inheritsFrom ?? {}),
       id: e.detail.value as UrlEncodedRepoName,
       name: this._inheritFromFilter,
     };
-    if (!this._inheritsFrom) {
-      this._inheritsFrom = parentProject;
-    } else {
-      // TODO(TS): replace with
-      // this._inheritsFrom = {...this._inheritsFrom, ...parentProject};
-      const projectInfo = this._inheritsFrom as ProjectInfo;
-      projectInfo.id = parentProject.id;
-      projectInfo.name = parentProject.name;
-    }
     this._handleAccessModified();
   }
 
@@ -268,8 +258,8 @@
     return weblinks && weblinks.length ? 'show' : '';
   }
 
-  _computeShowInherit(inheritsFrom?: RepoName) {
-    return inheritsFrom ? 'show' : '';
+  _computeShowInherit(inheritsFrom?: ProjectInfo) {
+    return inheritsFrom?.id?.length ? 'show' : '';
   }
 
   // TODO(TS): Unclear what is model here, provide a better explanation
@@ -297,18 +287,10 @@
     }
     // Restore inheritFrom.
     if (this._inheritsFrom) {
-      // Can't assign this._inheritsFrom = {...this.originalInheritsFrom}
-      // directly, because this._inheritsFrom is declared as
-      // '...|null|undefined` and typescript reports error when trying
-      // to access .name property (because 'name' in null and 'name' in undefined
-      // lead to runtime error)
-      // After migrating to Typescript v4.2 the code below can be rewritten as
-      // const copy = {...this.originalInheritsFrom};
-      const copy: ProjectInfo | {} = this.originalInheritsFrom
+      this._inheritsFrom = this.originalInheritsFrom
         ? {...this.originalInheritsFrom}
-        : {};
-      this._inheritsFrom = copy;
-      this._inheritFromFilter = 'name' in copy ? copy.name : undefined;
+        : undefined;
+      this._inheritFromFilter = this.originalInheritsFrom?.name;
     }
     if (!this._local) {
       return;
@@ -448,12 +430,10 @@
 
     const originalInheritsFromId = this.originalInheritsFrom
       ? singleDecodeURL(this.originalInheritsFrom.id)
-      : null;
-    // TODO(TS): this._inheritsFrom as ProjectInfo might be a mistake.
-    // _inheritsFrom can be {}
+      : undefined;
     const inheritsFromId = this._inheritsFrom
-      ? singleDecodeURL((this._inheritsFrom as ProjectInfo).id)
-      : null;
+      ? singleDecodeURL(this._inheritsFrom.id)
+      : undefined;
 
     const inheritFromChanged =
       // Inherit from changed
@@ -466,7 +446,7 @@
     }
 
     this._recursivelyUpdateAddRemoveObj(
-      (this._local as unknown) as PropertyTreeNode,
+      this._local as unknown as PropertyTreeNode,
       addRemoveObj
     );
 
@@ -491,9 +471,11 @@
     this.set(['_local', newRef], section);
     flush();
     // Template already instantiated at this point
-    (this.root!.querySelector(
-      'gr-access-section:last-of-type'
-    ) as GrAccessSection).editReference();
+    (
+      this.root!.querySelector(
+        'gr-access-section:last-of-type'
+      ) as GrAccessSection
+    ).editReference();
   }
 
   _getObjforSave(): ProjectAccessInput | undefined {
@@ -507,10 +489,10 @@
       fireAlert(this, NOTHING_TO_SAVE);
       return;
     }
-    const obj: ProjectAccessInput = ({
+    const obj: ProjectAccessInput = {
       add: addRemoveObj.add,
       remove: addRemoveObj.remove,
-    } as unknown) as ProjectAccessInput;
+    } as unknown as ProjectAccessInput;
     if (addRemoveObj.parent) {
       obj.parent = addRemoveObj.parent;
     }
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 1eba2e7..8f88619 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
@@ -82,6 +85,7 @@
           text="{{_inheritFromFilter}}"
           query="[[_query]]"
           on-commit="_handleUpdateInheritFrom"
+          on-bind-value-changed="_handleUpdateInheritFrom"
         ></gr-autocomplete>
       </h3>
       <div class$="weblinks [[_computeWebLinkClass(_weblinks)]]">
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 a4e019e..1ccfd5e 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,7 +20,11 @@
 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';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi,
+} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-access');
 
@@ -100,22 +104,24 @@
       name: 'Create Account',
     },
   };
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
     stubRestApi('getAccount').returns(Promise.resolve(null));
     repoStub = stubRestApi('getRepo').returns(Promise.resolve(repoRes));
     element._loading = false;
     element._ownerOf = [];
     element._canUpload = false;
+    await flush();
   });
 
-  test('_repoChanged called when repo name changes', () => {
+  test('_repoChanged called when repo name changes', async () => {
     sinon.stub(element, '_repoChanged');
     element.repo = 'New Repo';
+    await flush();
     assert.isTrue(element._repoChanged.called);
   });
 
-  test('_repoChanged', done => {
+  test('_repoChanged', async () => {
     const accessStub = stubRestApi(
         'getRepoAccessRights');
 
@@ -127,31 +133,28 @@
         'getCapabilities');
     capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
 
-    element._repoChanged('New Repo').then(() => {
-      assert.isTrue(accessStub.called);
-      assert.isTrue(capabilitiesStub.called);
-      assert.isTrue(repoStub.called);
-      assert.isNotOk(element._inheritsFrom);
-      assert.deepEqual(element._local, accessRes.local);
-      assert.deepEqual(element._sections,
-          toSortedPermissionsArray(accessRes.local));
-      assert.deepEqual(element._labels, repoRes.labels);
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('.weblinks')).display,
-      'block');
-      return element._repoChanged('Another New Repo');
-    })
-        .then(() => {
-          assert.deepEqual(element._sections,
-              toSortedPermissionsArray(accessRes2.local));
-          assert.equal(getComputedStyle(element.shadowRoot
-              .querySelector('.weblinks')).display,
-          'none');
-          done();
-        });
+    await element._repoChanged('New Repo');
+    assert.isTrue(accessStub.called);
+    assert.isTrue(capabilitiesStub.called);
+    assert.isTrue(repoStub.called);
+    assert.isNotOk(element._inheritsFrom);
+    assert.deepEqual(element._local, accessRes.local);
+    assert.deepEqual(element._sections,
+        toSortedPermissionsArray(accessRes.local));
+    assert.deepEqual(element._labels, repoRes.labels);
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.weblinks')).display,
+    'block');
+
+    await element._repoChanged('Another New Repo');
+    assert.deepEqual(element._sections,
+        toSortedPermissionsArray(accessRes2.local));
+    assert.equal(getComputedStyle(element.shadowRoot
+        .querySelector('.weblinks')).display,
+    'none');
   });
 
-  test('_repoChanged when repo changes to undefined returns', done => {
+  test('_repoChanged when repo changes to undefined returns', async () => {
     const capabilitiesRes = {
       accessDatabase: {
         id: 'accessDatabase',
@@ -163,12 +166,10 @@
     const capabilitiesStub = stubRestApi(
         'getCapabilities').returns(Promise.resolve(capabilitiesRes));
 
-    element._repoChanged().then(() => {
-      assert.isFalse(accessStub.called);
-      assert.isFalse(capabilitiesStub.called);
-      assert.isFalse(repoStub.called);
-      done();
-    });
+    await element._repoChanged();
+    assert.isFalse(accessStub.called);
+    assert.isFalse(capabilitiesStub.called);
+    assert.isFalse(repoStub.called);
   });
 
   test('_computeParentHref', () => {
@@ -190,24 +191,29 @@
         'editing');
   });
 
-  test('inherit section', () => {
+  test('inherit section', async () => {
     element._local = {};
     element._ownerOf = [];
     sinon.stub(element, '_computeParentHref');
+    await flush();
+
     // Nothing should appear when no inherit from and not in edit mode.
     assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
     // The autocomplete should be hidden, and the link should be  displayed.
     assert.isFalse(element._computeParentHref.called);
-    // When it edit mode, the autocomplete should appear.
+    // When in edit mode, the autocomplete should appear.
     element._editing = true;
     // When editing, the autocomplete should still not be shown.
     assert.equal(getComputedStyle(element.$.inheritsFrom).display, 'none');
+
     element._editing = false;
     element._inheritsFrom = {
+      id: '1234',
       name: 'another-repo',
     };
+    await flush();
+
     // When there is a parent project, the link should be displayed.
-    flush();
     assert.notEqual(getComputedStyle(element.$.inheritsFrom).display, 'none');
     assert.notEqual(getComputedStyle(element.$.inheritFromName).display,
         'none');
@@ -222,9 +228,10 @@
         'none');
   });
 
-  test('_handleUpdateInheritFrom', () => {
+  test('_handleUpdateInheritFrom', async () => {
     element._inheritFromFilter = 'foo bar baz';
     element._handleUpdateInheritFrom({detail: {value: 'abc+123'}});
+    await flush();
     assert.isOk(element._inheritsFrom);
     assert.equal(element._inheritsFrom.id, 'abc+123');
     assert.equal(element._inheritsFrom.name, 'foo bar baz');
@@ -235,62 +242,80 @@
     assert.equal(element._computeLoadingClass(false), '');
   });
 
-  test('fires page-error', done => {
+  test('fires page-error', async () => {
     const response = {status: 404};
 
     stubRestApi('getRepoAccessRights').callsFake((repoName, errFn) => {
       errFn(response);
+      return Promise.resolve(undefined);
     });
 
+    const promise = mockPromise();
     addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
-      done();
+      promise.resolve();
     });
 
     element.repo = 'test';
+    await promise;
   });
 
   suite('with defined sections', () => {
-    const testEditSaveCancelBtns = (shouldShowSave, shouldShowSaveReview) => {
+    const testEditSaveCancelBtns = async (
+        shouldShowSave,
+        shouldShowSaveReview
+    ) => {
       // Edit button is visible and Save button is hidden.
       assert.equal(getComputedStyle(element.$.saveReviewBtn).display, 'none');
       assert.equal(getComputedStyle(element.$.saveBtn).display, 'none');
       assert.notEqual(getComputedStyle(element.$.editBtn).display, 'none');
       assert.equal(element.$.editBtn.innerText, 'EDIT');
-      assert.equal(getComputedStyle(element.$.editInheritFromInput).display,
-          'none');
+      assert.equal(
+          getComputedStyle(element.$.editInheritFromInput).display,
+          'none'
+      );
       element._inheritsFrom = {
         id: 'test-project',
       };
-      flush();
-      assert.equal(getComputedStyle(element.shadowRoot
-          .querySelector('#editInheritFromInput'))
-          .display, 'none');
+      await flush();
+      assert.equal(
+          getComputedStyle(
+              element.shadowRoot.querySelector('#editInheritFromInput')
+          ).display,
+          'none'
+      );
 
       MockInteractions.tap(element.$.editBtn);
-      flush();
+      await flush();
 
       // Edit button changes to Cancel button, and Save button is visible but
       // disabled.
       assert.equal(element.$.editBtn.innerText, 'CANCEL');
       if (shouldShowSaveReview) {
-        assert.notEqual(getComputedStyle(element.$.saveReviewBtn).display,
-            'none');
+        assert.notEqual(
+            getComputedStyle(element.$.saveReviewBtn).display,
+            'none'
+        );
         assert.isTrue(element.$.saveReviewBtn.disabled);
       }
       if (shouldShowSave) {
         assert.notEqual(getComputedStyle(element.$.saveBtn).display, 'none');
         assert.isTrue(element.$.saveBtn.disabled);
       }
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('#editInheritFromInput'))
-          .display, 'none');
+      assert.notEqual(
+          getComputedStyle(
+              element.shadowRoot.querySelector('#editInheritFromInput')
+          ).display,
+          'none'
+      );
 
       // Save button should be enabled after access is modified
       element.dispatchEvent(
           new CustomEvent('access-modified', {
-            composed: true, bubbles: true,
-          }));
+            composed: true,
+            bubbles: true,
+          })
+      );
       if (shouldShowSaveReview) {
         assert.isFalse(element.$.saveReviewBtn.disabled);
       }
@@ -299,7 +324,7 @@
       }
     };
 
-    setup(() => {
+    setup(async () => {
       // Create deep copies of these objects so the originals are not modified
       // by any tests.
       element._local = JSON.parse(JSON.stringify(accessRes.local));
@@ -308,18 +333,19 @@
       element._groups = JSON.parse(JSON.stringify(accessRes.groups));
       element._capabilities = JSON.parse(JSON.stringify(capabilitiesRes));
       element._labels = JSON.parse(JSON.stringify(repoRes.labels));
-      flush();
+      await flush();
     });
 
-    test('removing an added section', () => {
+    test('removing an added section', async () => {
       element.editing = true;
+      await flush();
       assert.equal(element._sections.length, 1);
       element.shadowRoot
           .querySelector('gr-access-section').dispatchEvent(
               new CustomEvent('added-section-removed', {
                 composed: true, bubbles: true,
               }));
-      flush();
+      await flush();
       assert.equal(element._sections.length, 0);
     });
 
@@ -328,36 +354,41 @@
       assert.equal(getComputedStyle(element.$.editBtn).display, 'none');
     });
 
-    test('button visibility for non ref owner with upload privilege', () => {
-      element._canUpload = true;
-      testEditSaveCancelBtns(false, true);
-    });
+    test('button visibility for non ref owner with upload privilege',
+        async () => {
+          element._canUpload = true;
+          await flush();
+          testEditSaveCancelBtns(false, true);
+        });
 
-    test('button visibility for ref owner', () => {
+    test('button visibility for ref owner', async () => {
       element._ownerOf = ['refs/for/*'];
+      await flush();
       testEditSaveCancelBtns(true, false);
     });
 
-    test('button visibility for ref owner and upload', () => {
+    test('button visibility for ref owner and upload', async () => {
       element._ownerOf = ['refs/for/*'];
       element._canUpload = true;
+      await flush();
       testEditSaveCancelBtns(true, false);
     });
 
-    test('_handleAccessModified called with event fired', () => {
+    test('_handleAccessModified called with event fired', async () => {
       sinon.spy(element, '_handleAccessModified');
       element.dispatchEvent(
           new CustomEvent('access-modified', {
             composed: true, bubbles: true,
           }));
+      await flush();
       assert.isTrue(element._handleAccessModified.called);
     });
 
-    test('_handleAccessModified called when parent changes', () => {
+    test('_handleAccessModified called when parent changes', async () => {
       element._inheritsFrom = {
         id: 'test-project',
       };
-      flush();
+      await flush();
       element.shadowRoot.querySelector('#editInheritFromInput').dispatchEvent(
           new CustomEvent('commit', {
             detail: {},
@@ -369,10 +400,11 @@
             detail: {},
             composed: true, bubbles: true,
           }));
+      await flush();
       assert.isTrue(element._handleAccessModified.called);
     });
 
-    test('_handleSaveForReview', () => {
+    test('_handleSaveForReview', async () => {
       const saveStub =
           stubRestApi('setRepoAccessRightsForReview');
       sinon.stub(element, '_computeAddAndRemove').returns({
@@ -380,6 +412,7 @@
         remove: {},
       });
       element._handleSaveForReview();
+      await flush();
       assert.isFalse(saveStub.called);
     });
 
@@ -487,29 +520,32 @@
       assert.deepEqual(element._computeAddAndRemove(), {add: {}, remove: {}});
     });
 
-    test('_handleSaveForReview parent change', () => {
+    test('_handleSaveForReview parent change', async () => {
       element._inheritsFrom = {
         id: 'test-project',
       };
       element._originalInheritsFrom = {
         id: 'test-project-original',
       };
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), {
         parent: 'test-project', add: {}, remove: {},
       });
     });
 
-    test('_handleSaveForReview new parent with spaces', () => {
+    test('_handleSaveForReview new parent with spaces', async () => {
       element._inheritsFrom = {id: 'spaces+in+project+name'};
       element._originalInheritsFrom = {id: 'old-project'};
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), {
         parent: 'spaces in project name', add: {}, remove: {},
       });
     });
 
-    test('_handleSaveForReview rules', () => {
+    test('_handleSaveForReview rules', async () => {
       // Delete a rule.
       element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      await flush();
       let expectedInput = {
         add: {},
         remove: {
@@ -531,6 +567,7 @@
 
       // Modify a rule.
       element._local['refs/*'].permissions.owner.rules[123].modified = true;
+      await flush();
       expectedInput = {
         add: {
           'refs/*': {
@@ -558,7 +595,7 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove permissions', () => {
+    test('_computeAddAndRemove permissions', async () => {
       // Add a new rule to a permission.
       let expectedInput = {
         add: {
@@ -584,7 +621,7 @@
           ._handleAddRuleItem(
               {detail: {value: 'Maintainers'}});
 
-      flush();
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Remove the added rule.
@@ -592,6 +629,7 @@
 
       // Delete a permission.
       element._local['refs/*'].permissions.owner.deleted = true;
+      await flush();
       expectedInput = {
         add: {},
         remove: {
@@ -609,6 +647,7 @@
 
       // Modify a permission.
       element._local['refs/*'].permissions.owner.modified = true;
+      await flush();
       expectedInput = {
         add: {
           'refs/*': {
@@ -634,7 +673,7 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove sections', () => {
+    test('_computeAddAndRemove sections', async () => {
       // Add a new permission to a section
       let expectedInput = {
         add: {
@@ -652,7 +691,7 @@
       };
       element.shadowRoot
           .querySelector('gr-access-section')._handleAddPermission();
-      flush();
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Add a new rule to the new permission.
@@ -683,11 +722,13 @@
               'gr-permission')[2];
       newPermission._handleAddRuleItem(
           {detail: {value: 'Maintainers'}});
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Modify a section reference.
       element._local['refs/*'].updatedId = 'refs/for/bar';
       element._local['refs/*'].modified = true;
+      await flush();
       expectedInput = {
         add: {
           'refs/for/bar': {
@@ -726,10 +767,12 @@
           },
         },
       };
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Delete a section.
       element._local['refs/*'].deleted = true;
+      await flush();
       expectedInput = {
         add: {},
         remove: {
@@ -741,7 +784,7 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove new section', () => {
+    test('_computeAddAndRemove new section', async () => {
       // Add a new permission to a section
       let expectedInput = {
         add: {
@@ -753,6 +796,7 @@
         remove: {},
       };
       MockInteractions.tap(element.$.addReferenceBtn);
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       expectedInput = {
@@ -773,7 +817,7 @@
       const newSection = dom(element.root)
           .querySelectorAll('gr-access-section')[1];
       newSection._handleAddPermission();
-      flush();
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Add rule to the new permission.
@@ -803,12 +847,12 @@
       newSection.shadowRoot
           .querySelector('gr-permission')._handleAddRuleItem(
               {detail: {value: 'Maintainers'}});
-
-      flush();
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Modify a the reference from the default value.
       element._local['refs/for/*'].updatedId = 'refs/for/new';
+      await flush();
       expectedInput = {
         add: {
           'refs/for/new': {
@@ -835,10 +879,11 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
     });
 
-    test('_computeAddAndRemove combinations', () => {
+    test('_computeAddAndRemove combinations', async () => {
       // Modify rule and delete permission that it is inside of.
       element._local['refs/*'].permissions.owner.rules[123].modified = true;
       element._local['refs/*'].permissions.owner.deleted = true;
+      await flush();
       let expectedInput = {
         add: {},
         remove: {
@@ -853,10 +898,12 @@
       // Delete rule and delete permission that it is inside of.
       element._local['refs/*'].permissions.owner.rules[123].modified = false;
       element._local['refs/*'].permissions.owner.rules[123].deleted = true;
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Also modify a different rule inside of another permission.
       element._local['refs/*'].permissions.read.modified = true;
+      await flush();
       expectedInput = {
         add: {
           'refs/*': {
@@ -886,6 +933,7 @@
       element._local['refs/*'].permissions.owner.modified = true;
       element._local['refs/*'].permissions.read.exclusive = true;
       element._local['refs/*'].permissions.read.modified = true;
+      await flush();
       expectedInput = {
         add: {
           'refs/*': {
@@ -918,6 +966,7 @@
               'gr-permission')[1];
       readPermission._handleAddRuleItem(
           {detail: {value: 'Maintainers'}});
+      await flush();
 
       expectedInput = {
         add: {
@@ -948,6 +997,7 @@
       // Change one of the refs
       element._local['refs/*'].updatedId = 'refs/for/bar';
       element._local['refs/*'].modified = true;
+      await flush();
 
       expectedInput = {
         add: {
@@ -983,6 +1033,7 @@
         },
       };
       element._local['refs/*'].deleted = true;
+      await flush();
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
 
       // Add a new section.
@@ -990,12 +1041,13 @@
       let newSection = dom(element.root)
           .querySelectorAll('gr-access-section')[1];
       newSection._handleAddPermission();
-      flush();
+      await flush();
       newSection.shadowRoot
           .querySelector('gr-permission')._handleAddRuleItem(
               {detail: {value: 'Maintainers'}});
       // Modify a the reference from the default value.
       element._local['refs/for/*'].updatedId = 'refs/for/new';
+      await flush();
 
       expectedInput = {
         add: {
@@ -1029,6 +1081,7 @@
       // Modify newly added rule inside new ref.
       element._local['refs/for/*'].permissions['label-Code-Review'].
           rules['Maintainers'].modified = true;
+      await flush();
       expectedInput = {
         add: {
           'refs/for/new': {
@@ -1061,15 +1114,17 @@
 
       // Add a second new section.
       MockInteractions.tap(element.$.addReferenceBtn);
+      await flush();
       newSection = dom(element.root)
           .querySelectorAll('gr-access-section')[2];
       newSection._handleAddPermission();
-      flush();
+      await flush();
       newSection.shadowRoot
           .querySelector('gr-permission')._handleAddRuleItem(
               {detail: {value: 'Maintainers'}});
       // Modify a the reference from the default value.
       element._local['refs/for/**'].updatedId = 'refs/for/new2';
+      await flush();
       expectedInput = {
         add: {
           'refs/for/new': {
@@ -1119,20 +1174,23 @@
       assert.deepEqual(element._computeAddAndRemove(), expectedInput);
     });
 
-    test('Unsaved added refs are discarded when edit cancelled', () => {
+    test('Unsaved added refs are discarded when edit cancelled', async () => {
       // Unsaved changes are discarded when editing is cancelled.
       MockInteractions.tap(element.$.editBtn);
+      await flush();
       assert.equal(element._sections.length, 1);
       assert.equal(Object.keys(element._local).length, 1);
       MockInteractions.tap(element.$.addReferenceBtn);
+      await flush();
       assert.equal(element._sections.length, 2);
       assert.equal(Object.keys(element._local).length, 2);
       MockInteractions.tap(element.$.editBtn);
+      await flush();
       assert.equal(element._sections.length, 1);
       assert.equal(Object.keys(element._local).length, 1);
     });
 
-    test('_handleSave', done => {
+    test('_handleSave', async () => {
       const repoAccessInput = {
         add: {
           'refs/*': {
@@ -1170,16 +1228,15 @@
 
       element._modified = true;
       MockInteractions.tap(element.$.saveBtn);
+      await flush();
       assert.equal(element.$.saveBtn.hasAttribute('loading'), true);
       resolver({_number: 1});
-      flush(() => {
-        assert.isTrue(saveStub.called);
-        assert.isTrue(GerritNav.navigateToChange.notCalled);
-        done();
-      });
+      await flush();
+      assert.isTrue(saveStub.called);
+      assert.isTrue(GerritNav.navigateToChange.notCalled);
     });
 
-    test('_handleSaveForReview', done => {
+    test('_handleSaveForReview', async () => {
       const repoAccessInput = {
         add: {
           'refs/*': {
@@ -1217,14 +1274,13 @@
 
       element._modified = true;
       MockInteractions.tap(element.$.saveReviewBtn);
+      await flush();
       assert.equal(element.$.saveReviewBtn.hasAttribute('loading'), true);
       resolver({_number: 1});
-      flush(() => {
-        assert.isTrue(saveForReviewStub.called);
-        assert.isTrue(GerritNav.navigateToChange
-            .lastCall.calledWithExactly({_number: 1}));
-        done();
-      });
+      await flush();
+      assert.isTrue(saveForReviewStub.called);
+      assert.isTrue(GerritNav.navigateToChange
+          .lastCall.calledWithExactly({_number: 1}));
     });
   });
 });
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 df78df3..de43dc6 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
@@ -89,8 +90,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._loadRepo();
 
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 f572ac3..9948f8f 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
@@ -36,11 +39,11 @@
     <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
       <h2 id="options" class="heading-2">Command</h2>
       <div id="form">
-        <h3>Create change</h3>
+        <h3 class="heading-3">Create change</h3>
         <gr-button loading="[[_creatingChange]]" on-click="_createNewChange">
           Create change
         </gr-button>
-        <h3>Edit repo config</h3>
+        <h3 class="heading-3">Edit repo config</h3>
         <gr-button
           id="editRepoConfig"
           loading="[[_editingConfig]]"
@@ -48,7 +51,7 @@
         >
           Edit repo config
         </gr-button>
-        <h3 hidden="[[!_repoConfig.actions.gc.enabled]]">
+        <h3 class="heading-3" hidden="[[!_repoConfig.actions.gc.enabled]]">
           [[_repoConfig.actions.gc.label]]
         </h3>
         <gr-button
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 893efe55..ac48484 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,7 +18,11 @@
 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';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi,
+} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-repo-commands');
 
@@ -112,7 +116,7 @@
   });
 
   suite('404', () => {
-    test('fires page-error', done => {
+    test('fires page-error', async () => {
       repoStub.restore();
 
       element.repo = 'test';
@@ -120,13 +124,18 @@
       const response = {status: 404};
       stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
         errFn(response);
+        return Promise.resolve(undefined);
       });
+
+      await flush();
+      const promise = mockPromise();
       addListenerForTest(document, 'page-error', e => {
         assert.deepEqual(e.detail.response, response);
-        done();
+        promise.resolve();
       });
 
       element._loadRepo();
+      await promise;
     });
   });
 });
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 815e3ac..7800653 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
@@ -15,16 +15,15 @@
  * limitations under the License.
  */
 
-import '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-dashboards_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {RepoName, DashboardId, DashboardInfo} from '../../../types/common';
 import {firePageError} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 interface DashboardRef {
   section: string;
@@ -32,12 +31,8 @@
 }
 
 @customElement('gr-repo-dashboards')
-export class GrRepoDashboards extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, observer: '_repoChanged'})
+export class GrRepoDashboards extends LitElement {
+  @property({type: String})
   repo?: RepoName;
 
   @property({type: Boolean})
@@ -48,7 +43,85 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  _repoChanged(repo?: RepoName) {
+  static override get styles() {
+    return [
+      sharedStyles,
+      tableStyles,
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-xxl);
+        }
+        .loading #dashboards,
+        #loadingContainer {
+          display: none;
+        }
+        .loading #loadingContainer {
+          display: block;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <table
+      id="list"
+      class="genericList ${this._computeLoadingClass(this._loading)}"
+    >
+      <tbody>
+        <tr class="headerRow">
+          <th class="topHeader">Dashboard name</th>
+          <th class="topHeader">Dashboard title</th>
+          <th class="topHeader">Dashboard description</th>
+          <th class="topHeader">Inherited from</th>
+          <th class="topHeader">Default</th>
+        </tr>
+        <tr id="loadingContainer">
+          <td>Loading...</td>
+        </tr>
+      </tbody>
+      <tbody id="dashboards">
+        ${(this._dashboards ?? []).map(
+          item => html`
+            <tr class="groupHeader">
+              <td colspan="5">${item.section}</td>
+            </tr>
+            ${(item.dashboards ?? []).map(
+              info => html`
+                <tr class="table">
+                  <td class="name">
+                    <a href="${this._getUrl(info.project, info.id)}"
+                      >${info.path}</a
+                    >
+                  </td>
+                  <td class="title">${info.title}</td>
+                  <td class="desc">${info.description}</td>
+                  <td class="inherited">
+                    ${this._computeInheritedFrom(
+                      info.project,
+                      info.defining_project
+                    )}
+                  </td>
+                  <td class="default">
+                    ${this._computeIsDefault(info.is_default)}
+                  </td>
+                </tr>
+              `
+            )}
+          `
+        )}
+      </tbody>
+    </table>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this.repoChanged();
+    }
+  }
+
+  private repoChanged() {
+    const repo = this.repo;
     this._loading = true;
     if (!repo) {
       return Promise.resolve();
@@ -89,11 +162,10 @@
 
         this._dashboards = dashboardBuilder;
         this._loading = false;
-        flush();
       });
   }
 
-  _getUrl(project: RepoName, id: DashboardId) {
+  _getUrl(project?: RepoName, id?: DashboardId) {
     if (!project || !id) {
       return '';
     }
@@ -109,7 +181,7 @@
     return project === definingProject ? '' : definingProject;
   }
 
-  _computeIsDefault(isDefault: boolean) {
+  _computeIsDefault(isDefault?: boolean) {
     return isDefault ? '✓' : '';
   }
 }
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
deleted file mode 100644
index 51ea417..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_html.ts
+++ /dev/null
@@ -1,70 +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">
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-    .loading #dashboards,
-    #loadingContainer {
-      display: none;
-    }
-    .loading #loadingContainer {
-      display: block;
-    }
-  </style>
-  <style include="gr-table-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <table id="list" class$="genericList [[_computeLoadingClass(_loading)]]">
-    <tbody>
-      <tr class="headerRow">
-        <th class="topHeader">Dashboard name</th>
-        <th class="topHeader">Dashboard title</th>
-        <th class="topHeader">Dashboard description</th>
-        <th class="topHeader">Inherited from</th>
-        <th class="topHeader">Default</th>
-      </tr>
-      <tr id="loadingContainer">
-        <td>Loading...</td>
-      </tr>
-    </tbody>
-    <tbody id="dashboards">
-      <template is="dom-repeat" items="[[_dashboards]]">
-        <tr class="groupHeader">
-          <td colspan="5">[[item.section]]</td>
-        </tr>
-        <template is="dom-repeat" items="[[item.dashboards]]">
-          <tr class="table">
-            <td class="name">
-              <a href$="[[_getUrl(item.project, item.id)]]">[[item.path]]</a>
-            </td>
-            <td class="title">[[item.title]]</td>
-            <td class="desc">[[item.description]]</td>
-            <td class="inherited">
-              [[_computeInheritedFrom(item.project, item.defining_project)]]
-            </td>
-            <td class="default">[[_computeIsDefault(item.is_default)]]</td>
-          </tr>
-        </template>
-      </template>
-    </tbody>
-  </table>
-`;
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
deleted file mode 100644
index 829611d..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.js
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * @license
- * Copyright (C) 2017 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../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');
-
-suite('gr-repo-dashboards tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('dashboard table', () => {
-    setup(() => {
-      stubRestApi('getRepoDashboards').returns(
-          Promise.resolve([
-            {
-              id: 'default:contributor',
-              project: 'gerrit',
-              defining_project: 'gerrit',
-              ref: 'default',
-              path: 'contributor',
-              description: 'Own contributions.',
-              foreach: 'owner:self',
-              url: '/dashboard/?params',
-              title: 'Contributor Dashboard',
-              sections: [
-                {
-                  name: 'Mine To Rebase',
-                  query: 'is:open -is:mergeable',
-                },
-                {
-                  name: 'My Recently Merged',
-                  query: 'is:merged limit:10',
-                },
-              ],
-            },
-            {
-              id: 'custom:custom2',
-              project: 'gerrit',
-              defining_project: 'Public-Projects',
-              ref: 'custom',
-              path: 'open',
-              description: 'Recent open changes.',
-              url: '/dashboard/?params',
-              title: 'Open Changes',
-              sections: [
-                {
-                  name: 'Open Changes',
-                  query: 'status:open project:${project} -age:7w',
-                },
-              ],
-            },
-            {
-              id: 'default:abc',
-              project: 'gerrit',
-              ref: 'default',
-            },
-            {
-              id: 'custom:custom1',
-              project: 'gerrit',
-              ref: 'custom',
-            },
-          ]));
-    });
-
-    test('loading, sections, and ordering', done => {
-      assert.isTrue(element._loading);
-      assert.notEqual(getComputedStyle(element.$.loadingContainer).display,
-          'none');
-      assert.equal(getComputedStyle(element.$.dashboards).display,
-          'none');
-      element.repo = 'test';
-      flush(() => {
-        assert.equal(getComputedStyle(element.$.loadingContainer).display,
-            'none');
-        assert.notEqual(getComputedStyle(element.$.dashboards).display,
-            'none');
-
-        assert.equal(element._dashboards.length, 2);
-        assert.equal(element._dashboards[0].section, 'custom');
-        assert.equal(element._dashboards[1].section, 'default');
-
-        const dashboards = element._dashboards[0].dashboards;
-        assert.equal(dashboards.length, 2);
-        assert.equal(dashboards[0].id, 'custom:custom1');
-        assert.equal(dashboards[1].id, 'custom:custom2');
-
-        done();
-      });
-    });
-  });
-
-  suite('test url', () => {
-    test('_getUrl', () => {
-      sinon.stub(GerritNav, 'getUrlForRepoDashboard').callsFake(
-          () => '/r/dashboard/test');
-
-      assert.equal(element._getUrl('/dashboard/test', {}), '/r/dashboard/test');
-
-      assert.equal(element._getUrl(undefined, undefined), '');
-    });
-  });
-
-  suite('404', () => {
-    test('fires page-error', done => {
-      const response = {status: 404};
-      stubRestApi('getRepoDashboards').callsFake((repo, errFn) => {
-        errFn(response);
-      });
-
-      addListenerForTest(document, 'page-error', e => {
-        assert.deepEqual(e.detail.response, response);
-        done();
-      });
-
-      element.repo = 'test';
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
new file mode 100644
index 0000000..a54eafb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-repo-dashboards/gr-repo-dashboards_test.ts
@@ -0,0 +1,165 @@
+/**
+ * @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';
+import './gr-repo-dashboards';
+import {GrRepoDashboards} from './gr-repo-dashboards';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  addListenerForTest,
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {DashboardId, DashboardInfo, RepoName} from '../../../types/common';
+import {PageErrorEvent} from '../../../types/events.js';
+
+const basicFixture = fixtureFromElement('gr-repo-dashboards');
+
+suite('gr-repo-dashboards tests', () => {
+  let element: GrRepoDashboards;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
+  });
+
+  suite('dashboard table', () => {
+    setup(() => {
+      stubRestApi('getRepoDashboards').returns(
+        Promise.resolve([
+          {
+            id: 'default:contributor',
+            project: 'gerrit',
+            defining_project: 'gerrit',
+            ref: 'default',
+            path: 'contributor',
+            description: 'Own contributions.',
+            foreach: 'owner:self',
+            url: '/dashboard/?params',
+            title: 'Contributor Dashboard',
+            sections: [
+              {
+                name: 'Mine To Rebase',
+                query: 'is:open -is:mergeable',
+              },
+              {
+                name: 'My Recently Merged',
+                query: 'is:merged limit:10',
+              },
+            ],
+          },
+          {
+            id: 'custom:custom2',
+            project: 'gerrit',
+            defining_project: 'Public-Projects',
+            ref: 'custom',
+            path: 'open',
+            description: 'Recent open changes.',
+            url: '/dashboard/?params',
+            title: 'Open Changes',
+            sections: [
+              {
+                name: 'Open Changes',
+                query: 'status:open project:${project} -age:7w',
+              },
+            ],
+          },
+          {
+            id: 'default:abc',
+            project: 'gerrit',
+            ref: 'default',
+          },
+          {
+            id: 'custom:custom1',
+            project: 'gerrit',
+            ref: 'custom',
+          },
+        ] as DashboardInfo[])
+      );
+    });
+
+    test('loading, sections, and ordering', async () => {
+      assert.isTrue(element._loading);
+      assert.notEqual(
+        getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
+        'none'
+      );
+      assert.equal(
+        getComputedStyle(queryAndAssert(element, '#dashboards')).display,
+        'none'
+      );
+      element.repo = 'test' as RepoName;
+      await flush();
+      assert.equal(
+        getComputedStyle(queryAndAssert(element, '#loadingContainer')).display,
+        'none'
+      );
+      assert.notEqual(
+        getComputedStyle(queryAndAssert(element, '#dashboards')).display,
+        'none'
+      );
+
+      const dashboard = element._dashboards!;
+      assert.equal(dashboard.length!, 2);
+      assert.equal(dashboard[0].section!, 'custom');
+      assert.equal(dashboard[1].section!, 'default');
+
+      const dashboards = dashboard[0].dashboards;
+      assert.equal(dashboards.length, 2);
+      assert.equal(dashboards[0].id, 'custom:custom1');
+      assert.equal(dashboards[1].id, 'custom:custom2');
+    });
+  });
+
+  suite('test url', () => {
+    test('_getUrl', () => {
+      sinon
+        .stub(GerritNav, 'getUrlForRepoDashboard')
+        .callsFake(() => '/r/p/test/+/dashboard/default:contributor');
+
+      assert.equal(
+        element._getUrl(
+          'test' as RepoName,
+          'default:contributor' as DashboardId
+        ),
+        '/r/p/test/+/dashboard/default:contributor'
+      );
+
+      assert.equal(element._getUrl(undefined, undefined), '');
+    });
+  });
+
+  suite('404', () => {
+    test('fires page-error', async () => {
+      const response = {status: 404} as Response;
+      stubRestApi('getRepoDashboards').callsFake((_repo, errFn) => {
+        errFn!(response);
+        return Promise.resolve([]);
+      });
+
+      const promise = mockPromise();
+      addListenerForTest(document, 'page-error', e => {
+        assert.deepEqual((e as PageErrorEvent).detail.response, response);
+        promise.resolve();
+      });
+
+      element.repo = 'test' as RepoName;
+      await promise;
+    });
+  });
+});
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 bb65f98..052e07a 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
@@ -30,7 +30,6 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-detail-list_html';
-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 {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
@@ -49,6 +48,7 @@
 import {firePageError} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {ErrorCallback} from '../../../api/rest';
+import {SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 
 const PGP_START = '-----BEGIN PGP SIGNATURE-----';
 
@@ -59,8 +59,9 @@
     createNewModal: GrCreatePointerDialog;
   };
 }
+
 @customElement('gr-repo-detail-list')
-export class GrRepoDetailList extends ListViewMixin(PolymerElement) {
+export class GrRepoDetailList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -69,7 +70,7 @@
   params?: AppElementRepoParams;
 
   @property({type: String})
-  detailType?: RepoDetailView;
+  detailType?: RepoDetailView.BRANCHES | RepoDetailView.TAGS;
 
   @property({type: Boolean})
   _editing = false;
@@ -81,7 +82,7 @@
   _loggedIn = false;
 
   @property({type: Number})
-  _offset?: number;
+  _offset = 0;
 
   @property({type: String})
   _repo?: RepoName;
@@ -89,8 +90,11 @@
   @property({type: Array})
   _items?: BranchInfo[] | TagInfo[];
 
+  // _shownItems should be BranchInfo[] | TagInfo[],
+  // but TS incorrectly assumes that in the loop for(const item of _shownItems)
+  // item has type BranchInfo, not BranchInfo | TagInfo.
   @property({type: Array, computed: 'computeShownItems(_items)'})
-  _shownItems?: BranchInfo[] | TagInfo[];
+  _shownItems?: Array<BranchInfo | TagInfo>;
 
   @property({type: Number})
   _itemsPerPage = 25;
@@ -105,10 +109,10 @@
   _refName?: GitRef;
 
   @property({type: Boolean})
-  _hasNewItemName?: boolean;
+  _hasNewItemName = false;
 
   @property({type: Boolean})
-  _isEditing?: boolean;
+  _isEditing = false;
 
   @property({type: String})
   _revisedRef?: GitRef;
@@ -148,8 +152,8 @@
 
     this.detailType = params.detail;
 
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
     if (!this.detailType)
       return Promise.reject(new Error('undefined detailType'));
 
@@ -204,11 +208,11 @@
     return Promise.reject(new Error('unknown detail type'));
   }
 
-  _getPath(repo: RepoName) {
-    return `/admin/repos/${encodeURL(repo, false)},${this.detailType}`;
+  _getPath(repo?: RepoName, detailType?: RepoDetailView) {
+    return `/admin/repos/${encodeURL(repo ?? '', false)},${detailType}`;
   }
 
-  _computeWeblink(repo: ProjectInfo) {
+  _computeWeblink(repo: ProjectInfo | BranchInfo | TagInfo) {
     if (!repo.web_links) {
       return '';
     }
@@ -216,7 +220,7 @@
     return webLinks.length ? webLinks : null;
   }
 
-  _computeFirstWebLink(repo: ProjectInfo) {
+  _computeFirstWebLink(repo: ProjectInfo | BranchInfo | TagInfo) {
     const webLinks = this._computeWeblink(repo);
     return webLinks ? webLinks[0].url : null;
   }
@@ -229,7 +233,7 @@
     return message.split(PGP_START)[0];
   }
 
-  _stripRefs(item: GitRef, detailType?: string) {
+  _stripRefs(item: GitRef, detailType?: RepoDetailView) {
     if (detailType === RepoDetailView.BRANCHES) {
       return item.replace('refs/heads/', '');
     } else if (detailType === RepoDetailView.TAGS) {
@@ -246,14 +250,19 @@
     return isEditing ? 'editing' : '';
   }
 
-  _computeCanEditClass(ref: GitRef, detailType: string, isOwner: boolean) {
+  _computeCanEditClass(
+    ref?: GitRef,
+    detailType?: RepoDetailView,
+    isOwner?: boolean
+  ) {
+    if (ref === undefined || detailType === undefined) return '';
     return isOwner && this._stripRefs(ref, detailType) === 'HEAD'
       ? 'canEdit'
       : '';
   }
 
   _handleEditRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
-    this._revisedRef = (e.model.get('item.revision') as unknown) as GitRef;
+    this._revisedRef = e.model.get('item.revision') as unknown as GitRef;
     this._isEditing = true;
   }
 
@@ -261,12 +270,16 @@
     this._isEditing = false;
   }
 
-  _handleSaveRevision(e: PolymerDomRepeatEvent<GitRef>) {
+  _handleSaveRevision(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
     if (this._revisedRef && this._repo)
       this._setRepoHead(this._repo, this._revisedRef, e);
   }
 
-  _setRepoHead(repo: RepoName, ref: GitRef, e: PolymerDomRepeatEvent<GitRef>) {
+  _setRepoHead(
+    repo: RepoName,
+    ref: GitRef,
+    e: PolymerDomRepeatEvent<BranchInfo | TagInfo>
+  ) {
     return this.restApiService.setRepoHead(repo, ref).then(res => {
       if (res.status < 400) {
         this._isEditing = false;
@@ -284,7 +297,8 @@
     });
   }
 
-  _computeItemName(detailType: string) {
+  _computeItemName(detailType?: RepoDetailView) {
+    if (detailType === undefined) return '';
     if (detailType === RepoDetailView.BRANCHES) {
       return 'Branch';
     } else if (detailType === RepoDetailView.TAGS) {
@@ -334,7 +348,7 @@
     this.$.overlay.close();
   }
 
-  _handleDeleteItem(e: PolymerDomRepeatEvent<GitRef>) {
+  _handleDeleteItem(e: PolymerDomRepeatEvent<BranchInfo | TagInfo>) {
     const name = this._stripRefs(
       e.model.get('item.ref'),
       this.detailType
@@ -346,7 +360,7 @@
     this.$.overlay.open();
   }
 
-  _computeHideDeleteClass(owner: boolean, canDelete: boolean) {
+  _computeHideDeleteClass(owner?: boolean, canDelete?: boolean) {
     if (canDelete || owner) {
       return 'show';
     }
@@ -367,7 +381,7 @@
     this.$.createOverlay.open();
   }
 
-  _hideIfBranch(type: string) {
+  _hideIfBranch(type?: RepoDetailView) {
     if (type === RepoDetailView.BRANCHES) {
       return 'hideItem';
     }
@@ -375,9 +389,17 @@
     return '';
   }
 
-  _computeHideTagger(tagger: GitPersonInfo) {
+  _computeHideTagger(tagger?: GitPersonInfo) {
     return tagger ? '' : 'hide';
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
+
+  computeShownItems(items: BranchInfo[] | TagInfo[]) {
+    return items.slice(0, SHOWN_ITEMS_COUNT);
+  }
 }
 
 declare global {
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 2ac32c0..429a6d6 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
@@ -145,10 +145,7 @@
             <td class$="tagger [[_hideIfBranch(detailType)]]">
               <div class$="tagger [[_computeHideTagger(item.tagger)]]">
                 <gr-account-link account="[[item.tagger]]"> </gr-account-link>
-                (<gr-date-formatter
-                  has-tooltip=""
-                  date-str="[[item.tagger.date]]"
-                >
+                (<gr-date-formatter withTooltip date-str="[[item.tagger.date]]">
                 </gr-date-formatter
                 >)
               </div>
@@ -188,7 +185,7 @@
         on-confirm="_handleDeleteItemConfirm"
         on-cancel="_handleConfirmDialogCancel"
         item="[[_refName]]"
-        item-type="[[detailType]]"
+        item-type-name="[[_computeItemName(detailType)]]"
       ></gr-confirm-delete-item-dialog>
     </gr-overlay>
   </gr-list-view>
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 990ea4b..d5eb5d5 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,7 +20,12 @@
 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';
+import {
+  addListenerForTest,
+  mockPromise,
+  stubRestApi,
+} from '../../../test/test-utils.js';
+import {RepoDetailView} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-repo-detail-list');
 
@@ -70,7 +75,7 @@
     });
 
     suite('list of repo branches', () => {
-      setup(done => {
+      setup(async () => {
         branches = [{
           ref: 'HEAD',
           revision: 'master',
@@ -81,53 +86,43 @@
           repo: 'test',
           detail: 'branches',
         };
-        element._paramsChanged(params).then(() => { flush(done); });
+        await element._paramsChanged(params);
+        await flush();
       });
 
-      test('test for branch in the list', done => {
-        flush(() => {
-          assert.equal(element._items[2].ref, 'refs/heads/test2');
-          done();
-        });
+      test('test for branch in the list', () => {
+        assert.equal(element._items[2].ref, 'refs/heads/test2');
       });
 
-      test('test for web links in the branches list', done => {
-        flush(() => {
-          assert.equal(element._items[2].web_links[0].url,
-              'https://git.example.org/branch/test;refs/heads/test2');
-          done();
-        });
+      test('test for web links in the branches list', () => {
+        assert.equal(element._items[2].web_links[0].url,
+            'https://git.example.org/branch/test;refs/heads/test2');
       });
 
-      test('test for refs/heads/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[2].ref,
-              element.detailType), 'test2');
-          done();
-        });
+      test('test for refs/heads/ being striped from ref', () => {
+        assert.equal(element._stripRefs(element._items[2].ref,
+            element.detailType), 'test2');
       });
 
       test('_shownItems', () => {
         assert.equal(element._shownItems.length, 25);
       });
 
-      test('Edit HEAD button not admin', done => {
+      test('Edit HEAD button not admin', async () => {
         sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         stubRestApi('getRepoAccess').returns(
             Promise.resolve({
               test: {is_owner: false},
             }));
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, false);
-          assert.equal(getComputedStyle(dom(element.root)
-              .querySelector('.revisionNoEditing')).display, 'inline');
-          assert.equal(getComputedStyle(dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
-          done();
-        });
+        await element._determineIfOwner('test');
+        assert.equal(element._isOwner, false);
+        assert.equal(getComputedStyle(dom(element.root)
+            .querySelector('.revisionNoEditing')).display, 'inline');
+        assert.equal(getComputedStyle(dom(element.root)
+            .querySelector('.revisionEdit')).display, 'none');
       });
 
-      test('Edit HEAD button admin', done => {
+      test('Edit HEAD button admin', async () => {
         const saveBtn = element.root.querySelector('.saveBtn');
         const cancelBtn = element.root.querySelector('.cancelBtn');
         const editBtn = element.root.querySelector('.editBtn');
@@ -142,76 +137,74 @@
               test: {is_owner: true},
             }));
         sinon.stub(element, '_handleSaveRevision');
-        element._determineIfOwner('test').then(() => {
-          assert.equal(element._isOwner, true);
-          // The revision container for non-editing enabled row is not visible.
-          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+        await element._determineIfOwner('test');
+        assert.equal(element._isOwner, true);
+        // The revision container for non-editing enabled row is not visible.
+        assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
 
-          // The revision container for editing enabled row is visible.
-          assert.notEqual(getComputedStyle(dom(element.root)
-              .querySelector('.revisionEdit')).display, 'none');
+        // The revision container for editing enabled row is visible.
+        assert.notEqual(getComputedStyle(dom(element.root)
+            .querySelector('.revisionEdit')).display, 'none');
 
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+        // The revision and edit button are visible.
+        assert.notEqual(getComputedStyle(revisionWithEditing).display,
+            'none');
+        assert.notEqual(getComputedStyle(editBtn).display, 'none');
 
-          // The input, cancel, and save buttons are not visible.
-          const hiddenElements = dom(element.root)
-              .querySelectorAll('.canEdit .editItem');
+        // The input, cancel, and save buttons are not visible.
+        const hiddenElements = dom(element.root)
+            .querySelectorAll('.canEdit .editItem');
 
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
+        for (const item of hiddenElements) {
+          assert.equal(getComputedStyle(item).display, 'none');
+        }
 
-          MockInteractions.tap(editBtn);
-          flush();
-          // The revision and edit button are not visible.
-          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
-          assert.equal(getComputedStyle(editBtn).display, 'none');
+        MockInteractions.tap(editBtn);
+        await flush();
+        // The revision and edit button are not visible.
+        assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+        assert.equal(getComputedStyle(editBtn).display, 'none');
 
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.notEqual(getComputedStyle(item).display, 'none');
-          }
+        // The input, cancel, and save buttons are not visible.
+        for (const item of hiddenElements) {
+          assert.notEqual(getComputedStyle(item).display, 'none');
+        }
 
-          // The revised ref was set correctly
-          assert.equal(element._revisedRef, 'master');
+        // The revised ref was set correctly
+        assert.equal(element._revisedRef, 'master');
 
-          assert.isFalse(saveBtn.disabled);
+        assert.isFalse(saveBtn.disabled);
 
-          // Delete the ref.
-          element._revisedRef = '';
-          assert.isTrue(saveBtn.disabled);
+        // Delete the ref.
+        element._revisedRef = '';
+        assert.isTrue(saveBtn.disabled);
 
-          // Change the ref to something else
-          element._revisedRef = 'newRef';
-          element._repo = 'test';
-          assert.isFalse(saveBtn.disabled);
+        // Change the ref to something else
+        element._revisedRef = 'newRef';
+        element._repo = 'test';
+        assert.isFalse(saveBtn.disabled);
 
-          // Save button calls handleSave. since this is stubbed, the edit
-          // section remains open.
-          MockInteractions.tap(saveBtn);
-          assert.isTrue(element._handleSaveRevision.called);
+        // Save button calls handleSave. since this is stubbed, the edit
+        // section remains open.
+        MockInteractions.tap(saveBtn);
+        assert.isTrue(element._handleSaveRevision.called);
 
-          // When cancel is tapped, the edit secion closes.
-          MockInteractions.tap(cancelBtn);
-          flush();
+        // When cancel is tapped, the edit secion closes.
+        MockInteractions.tap(cancelBtn);
+        await flush();
 
-          // The revision and edit button are visible.
-          assert.notEqual(getComputedStyle(revisionWithEditing).display,
-              'none');
-          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+        // The revision and edit button are visible.
+        assert.notEqual(getComputedStyle(revisionWithEditing).display,
+            'none');
+        assert.notEqual(getComputedStyle(editBtn).display, 'none');
 
-          // The input, cancel, and save buttons are not visible.
-          for (const item of hiddenElements) {
-            assert.equal(getComputedStyle(item).display, 'none');
-          }
-          done();
-        });
+        // The input, cancel, and save buttons are not visible.
+        for (const item of hiddenElements) {
+          assert.equal(getComputedStyle(item).display, 'none');
+        }
       });
 
-      test('_handleSaveRevision with invalid rev', done => {
+      test('_handleSaveRevision with invalid rev', async () => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
         stubRestApi('setRepoHead').returns(
@@ -220,14 +213,12 @@
             })
         );
 
-        element._setRepoHead('test', 'newRef', event).then(() => {
-          assert.isTrue(element._isEditing);
-          assert.isFalse(event.model.set.called);
-          done();
-        });
+        await element._setRepoHead('test', 'newRef', event);
+        assert.isTrue(element._isEditing);
+        assert.isFalse(event.model.set.called);
       });
 
-      test('_handleSaveRevision with valid rev', done => {
+      test('_handleSaveRevision with valid rev', async () => {
         const event = {model: {set: sinon.stub()}};
         element._isEditing = true;
         stubRestApi('setRepoHead').returns(
@@ -236,11 +227,9 @@
             })
         );
 
-        element._setRepoHead('test', 'newRef', event).then(() => {
-          assert.isFalse(element._isEditing);
-          assert.isTrue(event.model.set.called);
-          done();
-        });
+        await element._setRepoHead('test', 'newRef', event);
+        assert.isFalse(element._isEditing);
+        assert.isTrue(event.model.set.called);
       });
 
       test('test _computeItemName', () => {
@@ -250,7 +239,7 @@
     });
 
     suite('list with less then 25 branches', () => {
-      setup(done => {
+      setup(async () => {
         branches = _.times(25, branchGenerator);
         stubRestApi('getRepoBranches').returns(Promise.resolve(branches));
 
@@ -259,7 +248,8 @@
           detail: 'branches',
         };
 
-        element._paramsChanged(params).then(() => { flush(done); });
+        await element._paramsChanged(params);
+        await flush();
       });
 
       test('_shownItems', () => {
@@ -286,16 +276,18 @@
     });
 
     suite('404', () => {
-      test('fires page-error', done => {
+      test('fires page-error', async () => {
         const response = {status: 404};
         stubRestApi('getRepoBranches').callsFake(
             (filter, repo, reposBranchesPerPage, opt_offset, errFn) => {
               errFn(response);
+              return Promise.resolve();
             });
 
+        const promise = mockPromise();
         addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
-          done();
+          promise.resolve();
         });
 
         const params = {
@@ -305,6 +297,7 @@
           offset: 25,
         };
         element._paramsChanged(params);
+        await promise;
       });
     });
   });
@@ -340,7 +333,7 @@
     });
 
     suite('list of repo tags', () => {
-      setup(done => {
+      setup(async () => {
         tags = _.times(26, tagGenerator);
         stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
@@ -349,50 +342,37 @@
           detail: 'tags',
         };
 
-        element._paramsChanged(params).then(() => { flush(done); });
+        await element._paramsChanged(params);
+        await flush();
       });
 
-      test('test for tag in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].ref, 'refs/tags/test2');
-          done();
-        });
+      test('test for tag in the list', async () => {
+        assert.equal(element._items[1].ref, 'refs/tags/test2');
       });
 
-      test('test for tag message in the list', done => {
-        flush(() => {
-          assert.equal(element._items[1].message, 'Annotated tag');
-          done();
-        });
+      test('test for tag message in the list', async () => {
+        assert.equal(element._items[1].message, 'Annotated tag');
       });
 
-      test('test for tagger in the tag list', done => {
+      test('test for tagger in the tag list', async () => {
         const tagger = {
           name: 'Test User',
           email: 'test.user@gmail.com',
           date: '2017-09-19 14:54:00.000000000',
           tz: 540,
         };
-        flush(() => {
-          assert.deepEqual(element._items[1].tagger, tagger);
-          done();
-        });
+
+        assert.deepEqual(element._items[1].tagger, tagger);
       });
 
-      test('test for web links in the tags list', done => {
-        flush(() => {
-          assert.equal(element._items[1].web_links[0].url,
-              'https://git.example.org/tag/test;refs/tags/test2');
-          done();
-        });
+      test('test for web links in the tags list', async () => {
+        assert.equal(element._items[1].web_links[0].url,
+            'https://git.example.org/tag/test;refs/tags/test2');
       });
 
-      test('test for refs/tags/ being striped from ref', done => {
-        flush(() => {
-          assert.equal(element._stripRefs(element._items[1].ref,
-              element.detailType), 'test2');
-          done();
-        });
+      test('test for refs/tags/ being striped from ref', async () => {
+        assert.equal(element._stripRefs(element._items[1].ref,
+            element.detailType), 'test2');
       });
 
       test('_shownItems', () => {
@@ -410,7 +390,7 @@
     });
 
     suite('list with less then 25 tags', () => {
-      setup(done => {
+      setup(async () => {
         tags = _.times(25, tagGenerator);
         stubRestApi('getRepoTags').returns(Promise.resolve(tags));
 
@@ -419,7 +399,8 @@
           detail: 'tags',
         };
 
-        element._paramsChanged(params).then(() => { flush(done); });
+        await element._paramsChanged(params);
+        await flush();
       });
 
       test('_shownItems', () => {
@@ -481,16 +462,18 @@
     });
 
     suite('404', () => {
-      test('fires page-error', done => {
+      test('fires page-error', async () => {
         const response = {status: 404};
         stubRestApi('getRepoTags').callsFake(
             (filter, repo, reposTagsPerPage, opt_offset, errFn) => {
               errFn(response);
+              return Promise.resolve();
             });
 
+        const promise = mockPromise();
         addListenerForTest(document, 'page-error', e => {
           assert.deepEqual(e.detail.response, response);
-          done();
+          promise.resolve();
         });
 
         const params = {
@@ -500,6 +483,7 @@
           offset: 25,
         };
         element._paramsChanged(params);
+        await promise;
       });
     });
 
@@ -508,6 +492,12 @@
       assert.deepEqual(element._computeHideDeleteClass(false, true), 'show');
       assert.deepEqual(element._computeHideDeleteClass(false, false), '');
     });
+
+    test('_computeItemName', () => {
+      assert.equal(element._computeItemName(RepoDetailView.BRANCHES), 'Branch');
+      assert.equal(element._computeItemName(RepoDetailView.TAGS),
+          'Tag');
+    });
   });
 });
 
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 3bc3bef..adcfb64 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
@@ -22,16 +22,16 @@
 import '../gr-create-repo-dialog/gr-create-repo-dialog';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-repo-list_html';
-import {ListViewMixin} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {customElement, property, observe, computed} from '@polymer/decorators';
 import {AppElementAdminParams} from '../../gr-app-types';
 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 {ProjectState, SHOWN_ITEMS_COUNT} from '../../../constants/constants';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {encodeURL, getBaseUrl} from '../../../utils/url-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -47,7 +47,7 @@
 }
 
 @customElement('gr-repo-list')
-export class GrRepoList extends ListViewMixin(PolymerElement) {
+export class GrRepoList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -56,7 +56,7 @@
   params?: AppElementAdminParams;
 
   @property({type: Number})
-  _offset?: number;
+  _offset = 0;
 
   @property({type: String})
   readonly _path = '/admin/repos';
@@ -81,13 +81,12 @@
 
   @computed('_repos')
   get _shownRepos() {
-    return this.computeShownItems(this._repos);
+    return this._repos.slice(0, SHOWN_ITEMS_COUNT);
   }
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._getCreateRepoCapability();
     fireTitleChange(this, 'Repos');
@@ -97,8 +96,8 @@
   @observe('params')
   _paramsChanged(params: AppElementAdminParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
-    this._offset = this.getOffsetValue(params);
+    this._filter = params?.filter ?? '';
+    this._offset = Number(params?.offset ?? 0);
 
     return this._getRepos(this._filter, this._reposPerPage, this._offset);
   }
@@ -113,7 +112,7 @@
   }
 
   _computeRepoUrl(name: string) {
-    return this.getUrl(this._path + '/', name);
+    return getBaseUrl() + this._path + '/' + encodeURL(name, true);
   }
 
   _computeChangesLink(name: string) {
@@ -183,4 +182,8 @@
     const webLinks = repo.web_links;
     return webLinks.length ? webLinks : null;
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
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 4864af5..8fef4d0 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
@@ -53,17 +53,16 @@
   });
 
   suite('list with repos', () => {
-    setup(done => {
+    setup(async () => {
       repos = _.times(26, repoGenerator);
       stubRestApi('getRepos').returns(Promise.resolve(repos));
-      element._paramsChanged(value).then(() => { flush(done); });
+      await element._paramsChanged(value);
+      await flush();
     });
 
-    test('test for test repo in the list', done => {
-      flush(() => {
-        assert.equal(element._repos[1].id, 'test2');
-        done();
-      });
+    test('test for test repo in the list', async () => {
+      await flush();
+      assert.equal(element._repos[1].id, 'test2');
     });
 
     test('_shownRepos', () => {
@@ -84,10 +83,11 @@
   });
 
   suite('list with less then 25 repos', () => {
-    setup(done => {
+    setup(async () => {
       repos = _.times(25, repoGenerator);
       stubRestApi('getRepos').returns(Promise.resolve(repos));
-      element._paramsChanged(value).then(() => { flush(done); });
+      await element._paramsChanged(value);
+      await flush();
     });
 
     test('_shownRepos', () => {
@@ -113,17 +113,15 @@
       assert.isTrue(repoStub.lastCall.calledWithExactly('test', 25, 25));
     });
 
-    test('latest repos requested are always set', done => {
+    test('latest repos requested are always set', async () => {
       const repoStub = stubRestApi('getRepos');
       repoStub.withArgs('test').returns(Promise.resolve(repos));
       repoStub.withArgs('filter').returns(Promise.resolve(reposFiltered));
       element._filter = 'test';
 
       // Repos are not set because the element._filter differs.
-      element._getRepos('filter', 25, 0).then(() => {
-        assert.deepEqual(element._repos, []);
-        done();
-      });
+      await element._getRepos('filter', 25, 0);
+      assert.deepEqual(element._repos, []);
     });
 
     test('filter is case insensitive', async () => {
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
index 6a96f55..d5515f9 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config-types.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 /**
- * @fileOverview This file contains interfaces shared between
+ * @fileoverview This file contains interfaces shared between
  * gr-repo-plugin-config.ts and nested editors
  * (e.g. gr-plugin-config-array-editor.ts)
  *
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
index ef35f63..9b2bf68 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config.ts
@@ -16,19 +16,16 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
-import '../../../styles/gr-form-styles';
-import '../../../styles/gr-subpage-styles';
-import '../../../styles/shared-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
 import '../../shared/gr-icons/gr-icons';
 import '../../shared/gr-select/gr-select';
 import '../../shared/gr-tooltip-content/gr-tooltip-content';
 import '../gr-plugin-config-array-editor/gr-plugin-config-array-editor';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-plugin-config_html';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {ConfigParameterInfoType} from '../../../constants/constants';
-import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {
   ConfigParameterInfo,
   PluginParameterToConfigParameterInfoMap,
@@ -60,11 +57,7 @@
 }
 
 @customElement('gr-repo-plugin-config')
-class GrRepoPluginConfig extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRepoPluginConfig extends LitElement {
   /**
    * Fired when the plugin config changes.
    *
@@ -74,58 +67,152 @@
   @property({type: Object})
   pluginData?: PluginData;
 
-  @property({
-    type: Array,
-    computed: '_computePluginConfigOptions(pluginData.*)',
-  })
-  _pluginConfigOptions!: PluginOption[]; // _computePluginConfigOptions never returns null
-
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  _computePluginConfigOptions(
-    dataRecord: PolymerDeepPropertyChange<PluginData, PluginData>
-  ): PluginOption[] {
-    if (!dataRecord || !dataRecord.base || !dataRecord.base.config) {
-      return [];
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      subpageStyles,
+      css`
+        .inherited {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-m);
+        }
+        section.section:not(.ARRAY) .title {
+          align-items: center;
+          display: flex;
+        }
+        section.section.ARRAY .title {
+          padding-top: var(--spacing-m);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // Render can be called prior to pluginData being updated.
+    const pluginConfigOptions = this.pluginData
+      ? this._computePluginConfigOptions(this.pluginData)
+      : [];
+
+    return html`
+      <div class="gr-form-styles">
+        <fieldset>
+          <h4>${this.pluginData?.name || ''}</h4>
+          ${pluginConfigOptions.map(option => this.renderOption(option))}
+        </fieldset>
+      </div>
+    `;
+  }
+
+  private renderInherited(option: PluginOption) {
+    if (option.info.inherited_value) {
+      return html`
+        <span class="inherited">
+          (Inherited: ${option.info.inherited_value})
+        </span>
+      `;
+    } else {
+      return html``;
     }
-    const config = dataRecord.base.config;
+  }
+
+  private renderOption(option: PluginOption) {
+    return html`
+      <section class="section ${option.info.type}">
+        <span class="title"> ${this.renderOptionTitle(option)} </span>
+        <span class="value">
+          ${this.renderOptionDetail(option)} ${this.renderInherited(option)}
+        </span>
+      </section>
+    `;
+  }
+
+  private renderOptionTitle(option: PluginOption) {
+    const titleName = html`<span>${option.info.display_name}</span>`;
+    if (!option.info.description) return titleName;
+    return html` <gr-tooltip-content
+      has-tooltip
+      show-icon
+      title="${option.info.description}"
+    >
+      ${titleName}
+    </gr-tooltip-content>`;
+  }
+
+  private renderOptionDetail(option: PluginOption) {
+    if (option.info.type === ConfigParameterInfoType.ARRAY) {
+      return html`
+        <gr-plugin-config-array-editor
+          @plugin-config-option-changed=${this._handleArrayChange}
+          .pluginOption="${option}"
+        ></gr-plugin-config-array-editor>
+      `;
+    } else if (option.info.type === ConfigParameterInfoType.BOOLEAN) {
+      return html`
+        <paper-toggle-button
+          ?checked=${this._computeChecked(option.info.value)}
+          @change=${this._handleBooleanChange}
+          data-option-key=${option._key}
+          ?disabled=${this.disabled || !option.info.editable}
+          @click=${this._onTapPluginBoolean}
+        ></paper-toggle-button>
+      `;
+    } else if (option.info.type === ConfigParameterInfoType.LIST) {
+      return html`
+        <gr-select
+          .bindValue=${option.info.value}
+          @change=${this._handleListChange}
+        >
+          <select
+            data-option-key=${option._key}
+            ?disabled=${this.disabled || !option.info.editable}
+          >
+            ${(option.info.permitted_values || []).map(
+              value => html`<option value="${value}">${value}</option>`
+            )}
+          </select>
+        </gr-select>
+      `;
+    } else if (
+      option.info.type === ConfigParameterInfoType.STRING ||
+      option.info.type === ConfigParameterInfoType.INT ||
+      option.info.type === ConfigParameterInfoType.LONG
+    ) {
+      return html`
+        <iron-input
+          @input=${this._handleStringChange}
+          data-option-key="${option._key}"
+        >
+          <input
+            is="iron-input"
+            .value="${option.info.value ?? ''}"
+            @input=${this._handleStringChange}
+            data-option-key="${option._key}"
+            ?disabled=${this.disabled || !option.info.editable}
+          />
+        </iron-input>
+      `;
+    } else {
+      return html``;
+    }
+  }
+
+  _computePluginConfigOptions(pluginData: PluginData) {
+    const config = pluginData.config;
     return Object.keys(config).map(_key => {
       return {_key, info: config[_key]};
     });
   }
 
-  _isArray(type: ConfigParameterInfoType) {
-    return type === ConfigParameterInfoType.ARRAY;
-  }
-
-  _isBoolean(type: ConfigParameterInfoType) {
-    return type === ConfigParameterInfoType.BOOLEAN;
-  }
-
-  _isList(type: ConfigParameterInfoType) {
-    return type === ConfigParameterInfoType.LIST;
-  }
-
-  _isString(type: ConfigParameterInfoType) {
-    // Treat numbers like strings for simplicity.
-    return (
-      type === ConfigParameterInfoType.STRING ||
-      type === ConfigParameterInfoType.INT ||
-      type === ConfigParameterInfoType.LONG
-    );
-  }
-
-  _computeDisabled(disabled: boolean, editable: boolean) {
-    return disabled || !editable;
-  }
-
   _computeChecked(value = 'false') {
     return JSON.parse(value) as boolean;
   }
 
   _handleStringChange(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as IronInputElement;
+    const el = e.target as IronInputElement;
     // In the template, the data-option-key is assigned to each editor
     const _key = el.getAttribute('data-option-key')!;
     const configChangeInfo = this._buildConfigChangeInfo(el.value, _key);
@@ -133,7 +220,7 @@
   }
 
   _handleListChange(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as HTMLOptionElement;
+    const el = e.target as HTMLOptionElement;
     // In the template, the data-option-key is assigned to each editor
     const _key = el.getAttribute('data-option-key')!;
     const configChangeInfo = this._buildConfigChangeInfo(el.value, _key);
@@ -141,7 +228,7 @@
   }
 
   _handleBooleanChange(e: Event) {
-    const el = (dom(e) as EventApi).localTarget as PaperToggleButtonElement;
+    const el = e.target as PaperToggleButtonElement;
     // In the template, the data-option-key is assigned to each editor
     const _key = el.getAttribute('data-option-key')!;
     const configChangeInfo = this._buildConfigChangeInfo(
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
deleted file mode 100644
index 2920cdf..0000000
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_html.ts
+++ /dev/null
@@ -1,115 +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">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-subpage-styles">
-    .inherited {
-      color: var(--deemphasized-text-color);
-      margin-left: var(--spacing-m);
-    }
-    section.section:not(.ARRAY) .title {
-      align-items: center;
-      display: flex;
-    }
-    section.section.ARRAY .title {
-      padding-top: var(--spacing-m);
-    }
-  </style>
-  <div class="gr-form-styles">
-    <fieldset>
-      <h4>[[pluginData.name]]</h4>
-      <template is="dom-repeat" items="[[_pluginConfigOptions]]" as="option">
-        <section class$="section [[option.info.type]]">
-          <span class="title">
-            <gr-tooltip-content
-              has-tooltip="[[option.info.description]]"
-              show-icon="[[option.info.description]]"
-              title="[[option.info.description]]"
-            >
-              <span>[[option.info.display_name]]</span>
-            </gr-tooltip-content>
-          </span>
-          <span class="value">
-            <template is="dom-if" if="[[_isArray(option.info.type)]]">
-              <gr-plugin-config-array-editor
-                on-plugin-config-option-changed="_handleArrayChange"
-                plugin-option="[[option]]"
-                disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-              ></gr-plugin-config-array-editor>
-            </template>
-            <template is="dom-if" if="[[_isBoolean(option.info.type)]]">
-              <paper-toggle-button
-                checked="[[_computeChecked(option.info.value)]]"
-                on-change="_handleBooleanChange"
-                data-option-key$="[[option._key]]"
-                disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-                on-click="_onTapPluginBoolean"
-              ></paper-toggle-button>
-            </template>
-            <template is="dom-if" if="[[_isList(option.info.type)]]">
-              <gr-select
-                bind-value$="[[option.info.value]]"
-                on-change="_handleListChange"
-              >
-                <select
-                  data-option-key$="[[option._key]]"
-                  disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-                >
-                  <template
-                    is="dom-repeat"
-                    items="[[option.info.permitted_values]]"
-                    as="value"
-                  >
-                    <option value$="[[value]]">[[value]]</option>
-                  </template>
-                </select>
-              </gr-select>
-            </template>
-            <template is="dom-if" if="[[_isString(option.info.type)]]">
-              <iron-input
-                bind-value="[[option.info.value]]"
-                on-input="_handleStringChange"
-                data-option-key$="[[option._key]]"
-                disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-              >
-                <input
-                  is="iron-input"
-                  value="[[option.info.value]]"
-                  on-input="_handleStringChange"
-                  data-option-key$="[[option._key]]"
-                  disabled$="[[_computeDisabled(disabled, option.info.editable)]]"
-                />
-              </iron-input>
-            </template>
-            <template is="dom-if" if="[[option.info.inherited_value]]">
-              <span class="inherited">
-                (Inherited: [[option.info.inherited_value]])
-              </span>
-            </template>
-          </span>
-        </section>
-      </template>
-    </fieldset>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
index a5c7dfe..8c2e6b3 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
+++ b/polygerrit-ui/app/elements/admin/gr-repo-plugin-config/gr-repo-plugin-config_test.js
@@ -23,29 +23,18 @@
 suite('gr-repo-plugin-config tests', () => {
   let element;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await flush();
   });
 
   test('_computePluginConfigOptions', () => {
-    assert.deepEqual(element._computePluginConfigOptions(), []);
-    assert.deepEqual(element._computePluginConfigOptions({}), []);
-    assert.deepEqual(element._computePluginConfigOptions({base: {}}), []);
+    assert.deepEqual(element._computePluginConfigOptions({config: {}}), []);
     assert.deepEqual(element._computePluginConfigOptions(
-        {base: {config: {}}}), []);
-    assert.deepEqual(element._computePluginConfigOptions(
-        {base: {config: {testKey: 'testInfo'}}}),
+        {config: {testKey: 'testInfo'}}),
     [{_key: 'testKey', info: 'testInfo'}]);
   });
 
-  test('_computeDisabled', () => {
-    assert.isFalse(element._computeDisabled(false, true));
-    assert.isTrue(element._computeDisabled(false, undefined));
-    assert.isTrue(element._computeDisabled(false, null));
-    assert.isTrue(element._computeDisabled(false, false));
-    assert.isTrue(element._computeDisabled(true, true));
-  });
-
   test('_handleChange', () => {
     const eventStub = sinon.stub(element, 'dispatchEvent');
     element.pluginData = {
@@ -75,12 +64,12 @@
       buildStub = sinon.stub(element, '_buildConfigChangeInfo');
     });
 
-    test('ARRAY type option', () => {
+    test('ARRAY type option', async () => {
       element.pluginData = {
         name: 'testName',
         config: {plugin: {value: 'test', type: 'ARRAY', editable: true}},
       };
-      flush();
+      await flush();
 
       const editor = element.shadowRoot
           .querySelector('gr-plugin-config-array-editor');
@@ -90,18 +79,18 @@
       assert.equal(changeStub.lastCall.args[0], 'test');
     });
 
-    test('BOOLEAN type option', () => {
+    test('BOOLEAN type option', async () => {
       element.pluginData = {
         name: 'testName',
         config: {plugin: {value: 'true', type: 'BOOLEAN', editable: true}},
       };
-      flush();
+      await flush();
 
       const toggle = element.shadowRoot
           .querySelector('paper-toggle-button');
       assert.ok(toggle);
       toggle.click();
-      flush();
+      await flush();
 
       assert.isTrue(buildStub.called);
       assert.deepEqual(buildStub.lastCall.args, ['false', 'plugin']);
@@ -109,19 +98,19 @@
       assert.isTrue(changeStub.called);
     });
 
-    test('INT/LONG/STRING type option', () => {
+    test('INT/LONG/STRING type option', async () => {
       element.pluginData = {
         name: 'testName',
         config: {plugin: {value: 'test', type: 'STRING', editable: true}},
       };
-      flush();
+      await flush();
 
       const input = element.shadowRoot
           .querySelector('input');
       assert.ok(input);
       input.value = 'newTest';
       input.dispatchEvent(new Event('input'));
-      flush();
+      await flush();
 
       assert.isTrue(buildStub.called);
       assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
@@ -129,7 +118,7 @@
       assert.isTrue(changeStub.called);
     });
 
-    test('LIST type option', () => {
+    test('LIST type option', async () => {
       const permitted_values = ['test', 'newTest'];
       element.pluginData = {
         name: 'testName',
@@ -137,7 +126,7 @@
           {value: 'test', type: 'LIST', editable: true, permitted_values},
         },
       };
-      flush();
+      await flush();
 
       const select = element.shadowRoot
           .querySelector('select');
@@ -145,7 +134,7 @@
       select.value = 'newTest';
       select.dispatchEvent(new Event(
           'change', {bubbles: true, composed: true}));
-      flush();
+      await flush();
 
       assert.isTrue(buildStub.called);
       assert.deepEqual(buildStub.lastCall.args, ['newTest', 'plugin']);
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 dc28bcb..83c3d6a 100644
--- a/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
+++ b/polygerrit-ui/app/elements/admin/gr-repo/gr-repo.ts
@@ -14,12 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '@polymer/iron-input/iron-input';
 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-select/gr-select';
+import '../../shared/gr-textarea/gr-textarea';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-subpage-styles';
 import '../../../styles/shared-styles';
@@ -139,8 +140,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._loadRepo();
 
@@ -190,7 +190,7 @@
             }
 
             // If the user is not an owner, is_owner is not a property.
-            this._readOnly = !access[repo].is_owner;
+            this._readOnly = !access[repo]?.is_owner;
           });
         }
       })
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 8db498a..2bfad1f 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
@@ -17,10 +17,16 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
   <style include="gr-subpage-styles">
+    .info {
+      margin-bottom: var(--spacing-xl);
+    }
     h2.edited:after {
       color: var(--deemphasized-text-color);
       content: ' *';
@@ -87,14 +93,15 @@
         <fieldset>
           <h3 id="Description" class="heading-3">Description</h3>
           <fieldset>
-            <iron-autogrow-textarea
+            <gr-textarea
               id="descriptionInput"
               class="description"
               autocomplete="on"
               placeholder="<Insert repo description here>"
-              bind-value="{{_repoConfig.description}}"
+              rows="4"
+              text="{{_repoConfig.description}}"
               disabled$="[[_readOnly]]"
-            ></iron-autogrow-textarea>
+            ></gr-textarea>
           </fieldset>
           <h3 id="Options" class="heading-3">Repository Options</h3>
           <fieldset id="options">
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 c299b55..c780a62 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
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-repo.js';
+import {mockPromise} from '../../../test/test-utils.js';
 import {PolymerElement} from '@polymer/polymer/polymer-element.js';
 import {addListenerForTest, stubRestApi} from '../../../test/test-utils.js';
 
@@ -226,21 +227,24 @@
     ]);
   });
 
-  test('fires page-error', done => {
+  test('fires page-error', async () => {
     repoStub.restore();
 
     element.repo = 'test';
 
+    const pageErrorFired = mockPromise();
     const response = {status: 404};
     stubRestApi('getProjectConfig').callsFake((repo, errFn) => {
       errFn(response);
+      return Promise.resolve(undefined);
     });
     addListenerForTest(document, 'page-error', e => {
       assert.deepEqual(e.detail.response, response);
-      done();
+      pageErrorFired.resolve();
     });
 
     element._loadRepo();
+    await pageErrorFired;
   });
 
   suite('admin', () => {
@@ -305,7 +309,7 @@
       await element._loadRepo();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.isFalse(element.$.Title.classList.contains('edited'));
-      element.$.descriptionInput.bindValue = configInputObj.description;
+      element.$.descriptionInput.text = configInputObj.description;
       element.$.stateSelect.bindValue = configInputObj.state;
       element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
       element.$.contentMergeSelect.bindValue =
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 37e0596..172f807 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
@@ -141,8 +141,7 @@
     this.addEventListener('access-saved', () => this._handleAccessSaved());
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     // Called on ready rather than the observer because when new rules are
     // added, the observer is triggered prior to being ready.
@@ -152,8 +151,7 @@
     this._setupValues(this.rule);
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     // Check needed for test purposes.
     if (!this._originalRuleValues && this.rule) {
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 9c3646a..f3df132 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
@@ -180,7 +180,7 @@
   });
 
   suite('already existing generic rule', () => {
-    setup(done => {
+    setup(async () => {
       element.group = 'Group Name';
       element.permission = 'submit';
       element.rule = {
@@ -195,11 +195,8 @@
       // Typically called on ready since elements will have properties defined
       // by the parent element.
       element._setupValues(element.rule);
-      flush();
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -295,7 +292,7 @@
   });
 
   suite('new edit rule', () => {
-    setup(done => {
+    setup(async () => {
       element.group = 'Group Name';
       element.permission = 'editTopicName';
       element.rule = {
@@ -303,12 +300,10 @@
       };
       element.section = 'refs/*';
       element._setupValues(element.rule);
-      flush();
+      await flush();
       element.rule.value.added = true;
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -348,7 +343,7 @@
   });
 
   suite('already existing rule with labels', () => {
-    setup(done => {
+    setup(async () => {
       element.label = {values: [
         {value: -2, text: 'This shall not be merged'},
         {value: -1, text: 'I would prefer this is not merged as is'},
@@ -369,11 +364,8 @@
       };
       element.section = 'refs/*';
       element._setupValues(element.rule);
-      flush();
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -406,7 +398,7 @@
   });
 
   suite('new rule with labels', () => {
-    setup(done => {
+    setup(async () => {
       sinon.spy(element, '_setDefaultRuleValues');
       element.label = {values: [
         {value: -2, text: 'This shall not be merged'},
@@ -422,12 +414,10 @@
       };
       element.section = 'refs/*';
       element._setupValues(element.rule);
-      flush();
+      await flush();
       element.rule.value.added = true;
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -468,7 +458,7 @@
   });
 
   suite('already existing push rule', () => {
-    setup(done => {
+    setup(async () => {
       element.group = 'Group Name';
       element.permission = 'push';
       element.rule = {
@@ -480,11 +470,8 @@
       };
       element.section = 'refs/*';
       element._setupValues(element.rule);
-      flush();
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -513,7 +500,7 @@
   });
 
   suite('new push rule', () => {
-    setup(done => {
+    setup(async () => {
       element.group = 'Group Name';
       element.permission = 'push';
       element.rule = {
@@ -521,12 +508,10 @@
       };
       element.section = 'refs/*';
       element._setupValues(element.rule);
-      flush();
+      await flush();
       element.rule.value.added = true;
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -557,7 +542,7 @@
   });
 
   suite('already existing edit rule', () => {
-    setup(done => {
+    setup(async () => {
       element.group = 'Group Name';
       element.permission = 'editTopicName';
       element.rule = {
@@ -569,11 +554,8 @@
       };
       element.section = 'refs/*';
       element._setupValues(element.rule);
-      flush();
-      flush(() => {
-        element.connectedCallback();
-        done();
-      });
+      await flush();
+      element.connectedCallback();
     });
 
     test('_ruleValues and _originalRuleValues are set correctly', () => {
@@ -590,10 +572,10 @@
       assert.isNotOk(element.root.querySelector('#labelMax'));
     });
 
-    test('modify value', () => {
+    test('modify value', async () => {
       assert.isNotOk(element.rule.value.modified);
       element.$.action.bindValue = false;
-      flush();
+      await flush();
       assert.isTrue(element.rule.value.modified);
 
       // The original value should now differ from the rule values.
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 2869750..c476d2d 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
@@ -28,7 +28,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list-item_html';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -48,6 +47,7 @@
 } from '../../../types/common';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {pluralize} from '../../../utils/string-util';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 
 enum ChangeSize {
   XS = 10,
@@ -76,7 +76,7 @@
 const PRIMARY_REVIEWERS_COUNT = 2;
 
 @customElement('gr-change-list-item')
-export class GrChangeListItem extends ChangeTableMixin(PolymerElement) {
+export class GrChangeListItem extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -105,7 +105,7 @@
   changeURL?: string;
 
   @property({type: Array, computed: '_changeStatuses(change)'})
-  statuses?: string[];
+  statuses?: ChangeStates[];
 
   @property({type: Boolean})
   showStar = false;
@@ -121,8 +121,7 @@
 
   reporting: ReportingService = appContext.reportingService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
@@ -285,7 +284,7 @@
    * @param truncate whether or not the project name should be
    * truncated. If this value is truthy, the name will be truncated.
    */
-  _computeRepoDisplay(change: ChangeInfo | undefined, truncate: boolean) {
+  _computeRepoDisplay(change?: ChangeInfo) {
     if (!change?.project) {
       return '';
     }
@@ -293,7 +292,19 @@
     if (change.internalHost) {
       str += change.internalHost + '/';
     }
-    str += truncate ? truncatePath(change.project, 2) : change.project;
+    str += change.project;
+    return str;
+  }
+
+  _computeTruncatedRepoDisplay(change?: ChangeInfo) {
+    if (!change?.project) {
+      return '';
+    }
+    let str = '';
+    if (change.internalHost) {
+      str += change.internalHost + '/';
+    }
+    str += truncatePath(change.project, 2);
     return str;
   }
 
@@ -351,10 +362,7 @@
     return this._computeAdditionalReviewers(change).length;
   }
 
-  _computeAdditionalReviewersTitle(
-    change: ChangeInfo | undefined,
-    config: ServerInfo
-  ) {
+  _computeAdditionalReviewersTitle(change?: ChangeInfo, config?: ServerInfo) {
     if (!change || !config) return '';
     return this._computeAdditionalReviewers(change)
       .map(user => getDisplayName(config, user, true))
@@ -390,13 +398,20 @@
   }
 
   _computeWaiting(
-    account?: AccountInfo,
-    change?: ChangeInfo
+    account?: AccountInfo | null,
+    change?: ChangeInfo | null
   ): Timestamp | undefined {
     if (!account?._account_id || !change?.attention_set) return undefined;
     return change?.attention_set[account._account_id]?.last_update;
   }
 
+  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+    if (!columnsToDisplay || !columnToCheck) {
+      return false;
+    }
+    return !columnsToDisplay.includes(columnToCheck);
+  }
+
   toggleReviewed() {
     if (!this.change) return;
     const newVal = !this.change?.reviewed;
@@ -414,6 +429,11 @@
     );
   }
 
+  _formatDate(date: Timestamp | undefined): string | undefined {
+    if (!date) return undefined;
+    return date.toString();
+  }
+
   _handleChangeClick() {
     // Don't prevent the default and neither stop bubbling. We just want to
     // report the click, but then let the browser handle the click on the link.
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 2f07268..a0aa962 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
@@ -28,10 +28,6 @@
     :host(:hover) {
       background-color: var(--hover-background-color);
     }
-    :host([needs-review]) {
-      font-weight: var(--font-weight-bold);
-      color: var(--primary-text-color);
-    }
     .container {
       position: relative;
     }
@@ -131,7 +127,7 @@
   </td>
   <td
     class="cell subject"
-    hidden$="[[isColumnHidden('Subject', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Subject', visibleChangeTableColumns)]]"
   >
     <a
       title$="[[change.subject]]"
@@ -147,7 +143,7 @@
   </td>
   <td
     class="cell status"
-    hidden$="[[isColumnHidden('Status', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Status', visibleChangeTableColumns)]]"
   >
     <template is="dom-repeat" items="[[statuses]]" as="status">
       <div class="comma">,</div>
@@ -159,17 +155,17 @@
   </td>
   <td
     class="cell owner"
-    hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Owner', visibleChangeTableColumns)]]"
   >
     <gr-account-link
-      highlight-attention
+      highlightAttention
       change="[[change]]"
       account="[[change.owner]]"
     ></gr-account-link>
   </td>
   <td
     class="cell assignee"
-    hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Assignee', visibleChangeTableColumns)]]"
   >
     <template is="dom-if" if="[[change.assignee]]">
       <gr-account-link
@@ -183,7 +179,7 @@
   </td>
   <td
     class="cell reviewers"
-    hidden$="[[isColumnHidden('Reviewers', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Reviewers', visibleChangeTableColumns)]]"
   >
     <div>
       <template
@@ -193,10 +189,10 @@
         indexAs="index"
       >
         <gr-account-link
-          hide-avatar=""
-          hide-status=""
-          first-name
-          highlight-attention
+          hideAvatar=""
+          hideStatus=""
+          firstName
+          highlightAttention
           change="[[change]]"
           account="[[reviewer]]"
         ></gr-account-link
@@ -208,14 +204,14 @@
       </template>
       <template is="dom-if" if="[[_computeAdditionalReviewersCount(change)]]">
         <span title="[[_computeAdditionalReviewersTitle(change, config)]]">
-          +[[_computeAdditionalReviewersCount(change, config)]]
+          +[[_computeAdditionalReviewersCount(change)]]
         </span>
       </template>
     </div>
   </td>
   <td
     class="cell comments"
-    hidden$="[[isColumnHidden('Comments', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Comments', visibleChangeTableColumns)]]"
   >
     <iron-icon
       hidden$="[[!change.unresolved_comment_count]]"
@@ -225,7 +221,7 @@
   </td>
   <td
     class="cell repo"
-    hidden$="[[isColumnHidden('Repo', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Repo', visibleChangeTableColumns)]]"
   >
     <a class="fullRepo" href$="[[_computeRepoUrl(change)]]">
       [[_computeRepoDisplay(change)]]
@@ -235,12 +231,12 @@
       href$="[[_computeRepoUrl(change)]]"
       title$="[[_computeRepoDisplay(change)]]"
     >
-      [[_computeRepoDisplay(change, 'true')]]
+      [[_computeTruncatedRepoDisplay(change)]]
     </a>
   </td>
   <td
     class="cell branch"
-    hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Branch', visibleChangeTableColumns)]]"
   >
     <a href$="[[_computeRepoBranchURL(change)]]"> [[change.branch]] </a>
     <template is="dom-if" if="[[change.topic]]">
@@ -254,38 +250,38 @@
   </td>
   <td
     class="cell updated"
-    hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Updated', visibleChangeTableColumns)]]"
   >
     <gr-date-formatter
-      has-tooltip=""
-      date-str="[[change.updated]]"
+      withTooltip
+      date-str="[[_formatDate(change.updated)]]"
     ></gr-date-formatter>
   </td>
   <td
     class="cell submitted"
-    hidden$="[[isColumnHidden('Submitted', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Submitted', visibleChangeTableColumns)]]"
   >
     <gr-date-formatter
-      has-tooltip=""
-      date-str="[[change.submitted]]"
+      withTooltip
+      date-str="[[_formatDate(change.submitted)]]"
     ></gr-date-formatter>
   </td>
   <td
     class="cell waiting"
-    hidden$="[[isColumnHidden('Waiting', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Waiting', visibleChangeTableColumns)]]"
   >
     <gr-date-formatter
-      has-tooltip=""
-      force-relative=""
-      relative-option-no-ago=""
+      withTooltip
+      forceRelative
+      relativeOptionNoAgo
       date-str="[[_computeWaiting(account, change)]]"
     ></gr-date-formatter>
   </td>
   <td
     class="cell size"
-    hidden$="[[isColumnHidden('Size', visibleChangeTableColumns)]]"
+    hidden$="[[_computeIsColumnHidden('Size', visibleChangeTableColumns)]]"
   >
-    <gr-tooltip-content has-tooltip="" title="[[_computeSizeTooltip(change)]]">
+    <gr-tooltip-content has-tooltip title="[[_computeSizeTooltip(change)]]">
       <template is="dom-if" if="[[_changeSize]]">
         <span>[[_changeSize]]</span>
       </template>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
index ac0b929..34cb6eb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.ts
@@ -29,6 +29,7 @@
   TopicName,
 } from '../../../types/common';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {columnNames} from '../gr-change-list/gr-change-list';
 import './gr-change-list-item';
 import {GrChangeListItem, LabelCategory} from './gr-change-list-item';
 
@@ -372,7 +373,7 @@
 
     await flush();
 
-    for (const column of element.columnNames) {
+    for (const column of columnNames) {
       const elementClass = '.' + column.toLowerCase();
       assert.isFalse(
         queryAndAssert(element, elementClass).hasAttribute('hidden')
@@ -395,7 +396,7 @@
 
     await flush();
 
-    for (const column of element.columnNames) {
+    for (const column of columnNames) {
       const elementClass = '.' + column.toLowerCase();
       if (column === 'Repo') {
         assert.isTrue(
@@ -565,13 +566,13 @@
   });
 
   test('_computeRepoDisplay', () => {
+    assert.equal(element._computeRepoDisplay(change), 'host/a/test/repo');
     assert.equal(
-      element._computeRepoDisplay(change, false),
-      'host/a/test/repo'
+      element._computeTruncatedRepoDisplay(change),
+      'host/…/test/repo'
     );
-    assert.equal(element._computeRepoDisplay(change, true), 'host/…/test/repo');
     delete change.internalHost;
-    assert.equal(element._computeRepoDisplay(change, false), 'a/test/repo');
-    assert.equal(element._computeRepoDisplay(change, true), '…/test/repo');
+    assert.equal(element._computeRepoDisplay(change), 'a/test/repo');
+    assert.equal(element._computeTruncatedRepoDisplay(change), '…/test/repo');
   });
 });
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 8ce00f2..2360312 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
@@ -32,13 +32,14 @@
   ChangeInfo,
   EmailAddress,
   PreferencesInput,
+  RepoName,
 } from '../../../types/common';
-import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {ChangeListViewState} from '../../../types/types';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {GerritView} from '../../../services/router/router-model';
+import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
 
 const LOOKUP_QUERY_PATTERNS: RegExp[] = [
   /^\s*i?[0-9a-f]{7,40}\s*$/i, // CHANGE_ID
@@ -48,7 +49,8 @@
 
 const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
 
-const REPO_QUERY_PATTERN = /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
+const REPO_QUERY_PATTERN =
+  /^project:\s?("[^"]+"|[^ ]+)(\sstatus\s?:(open|"open"))?$/;
 
 const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
 
@@ -105,22 +107,48 @@
   _userId: AccountId | EmailAddress | null = null;
 
   @property({type: String})
-  _repo: string | null = null;
+  _repo: RepoName | null = null;
 
   private readonly restApiService = appContext.restApiService;
 
+  private reporting = appContext.reportingService;
+
+  private lastVisibleTimestampMs = 0;
+
   constructor() {
     super();
     this.addEventListener('next-page', () => this._handleNextPage());
     this.addEventListener('previous-page', () => this._handlePreviousPage());
+    this.addEventListener('reload', () => this.reload());
+    // We are not currently verifying if the view is actually visible. We rely
+    // on gr-app-element to restamp the component if view changes
+    document.addEventListener('visibilitychange', () => {
+      if (document.visibilityState === 'visible') {
+        if (
+          Date.now() - this.lastVisibleTimestampMs >
+          RELOAD_DASHBOARD_INTERVAL_MS
+        )
+          this.reload();
+      } else {
+        this.lastVisibleTimestampMs = Date.now();
+      }
+    });
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._loadPreferences();
   }
 
+  reload() {
+    if (this._loading) return;
+    this._loading = true;
+    this._getChanges().then(changes => {
+      this._changes = changes || [];
+      this._loading = false;
+    });
+  }
+
   _paramsChanged(value: AppElementParams) {
     if (value.view !== GerritView.SEARCH) return;
 
@@ -273,17 +301,21 @@
   }
 
   _handleToggleStar(e: CustomEvent<ChangeStarToggleStarDetail>) {
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-change-list');
+    }
     this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
   }
 
-  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    this.restApiService.saveChangeReviewed(
-      e.detail.change._number,
-      e.detail.reviewed
-    );
+  /**
+   * Returns `this` as the visibility observer target for the keyboard shortcut
+   * mixin to decide whether shortcuts should be enabled or not.
+   */
+  _computeObserverTarget() {
+    return this;
   }
 }
 
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 9914e70..355ef45 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
@@ -66,7 +66,7 @@
     ></gr-repo-header>
     <gr-user-header
       user-id="[[_userId]]"
-      show-dashboard-link=""
+      showDashboardLink=""
       logged-in="[[_loggedIn]]"
       class$="[[_computeHeaderClass(_userId)]]"
     ></gr-user-header>
@@ -77,7 +77,7 @@
       selected-index="{{viewState.selectedChangeIndex}}"
       show-star="[[_loggedIn]]"
       on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
+      observer-target="[[_computeObserverTarget()]]"
     ></gr-change-list>
     <nav class$="[[_computeNavClass(_loading)]]">
       Page [[_computePage(_offset, _changesPerPage)]]
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 445254a..86b2fd1 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,7 +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';
+import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-change-list-view');
 
@@ -38,10 +38,8 @@
     element = basicFixture.instantiate();
   });
 
-  teardown(done => {
-    flush(() => {
-      done();
-    });
+  teardown(async () => {
+    await flush();
   });
 
   test('_computePage', () => {
@@ -126,128 +124,123 @@
     assert.isTrue(showStub.called);
   });
 
-  test('_userId query', done => {
+  test('_userId query', async () => {
     assert.isNull(element._userId);
     element._query = 'owner: foo@bar';
     element._changes = [{owner: {email: 'foo@bar'}}];
-    flush(() => {
-      assert.equal(element._userId, 'foo@bar');
+    await flush();
+    assert.equal(element._userId, 'foo@bar');
 
-      element._query = 'foo bar baz';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      assert.isNull(element._userId);
-
-      done();
-    });
+    element._query = 'foo bar baz';
+    element._changes = [{owner: {email: 'foo@bar'}}];
+    assert.isNull(element._userId);
   });
 
-  test('_userId query without email', done => {
+  test('_userId query without email', async () => {
     assert.isNull(element._userId);
     element._query = 'owner: foo@bar';
     element._changes = [{owner: {}}];
-    flush(() => {
-      assert.isNull(element._userId);
-      done();
-    });
+    await flush();
+    assert.isNull(element._userId);
   });
 
-  test('_repo query', done => {
+  test('_repo query', async () => {
     assert.isNull(element._repo);
     element._query = 'project: test-repo';
     element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    flush(() => {
-      assert.equal(element._repo, 'test-repo');
-      element._query = 'foo bar baz';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      assert.isNull(element._repo);
-      done();
-    });
+    await flush();
+    assert.equal(element._repo, 'test-repo');
+    element._query = 'foo bar baz';
+    element._changes = [{owner: {email: 'foo@bar'}}];
+    assert.isNull(element._repo);
   });
 
-  test('_repo query with open status', done => {
+  test('_repo query with open status', async () => {
     assert.isNull(element._repo);
     element._query = 'project:test-repo status:open';
     element._changes = [{owner: {email: 'foo@bar'}, project: 'test-repo'}];
-    flush(() => {
-      assert.equal(element._repo, 'test-repo');
-      element._query = 'foo bar baz';
-      element._changes = [{owner: {email: 'foo@bar'}}];
-      assert.isNull(element._repo);
-      done();
-    });
+    await flush();
+    assert.equal(element._repo, 'test-repo');
+    element._query = 'foo bar baz';
+    element._changes = [{owner: {email: 'foo@bar'}}];
+    assert.isNull(element._repo);
   });
 
   suite('query based navigation', () => {
     setup(() => {
     });
 
-    teardown(done => {
-      flush(() => {
-        sinon.restore();
-        done();
-      });
+    teardown(async () => {
+      await flush();
+      sinon.restore();
     });
 
-    test('Searching for a change ID redirects to change', done => {
+    test('Searching for a change ID redirects to change', async () => {
       const change = {_number: 1};
       sinon.stub(element, '_getChanges')
           .returns(Promise.resolve([change]));
+      const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake(
           (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
             assert.equal(url, change);
             assert.isTrue(opt_redirect);
-            done();
+            promise.resolve();
           });
 
       element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
+      await promise;
     });
 
-    test('Searching for a change num redirects to change', done => {
+    test('Searching for a change num redirects to change', async () => {
       const change = {_number: 1};
       sinon.stub(element, '_getChanges')
           .returns(Promise.resolve([change]));
+      const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake(
           (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
             assert.equal(url, change);
             assert.isTrue(opt_redirect);
-            done();
+            promise.resolve();
           });
 
       element.params = {view: GerritNav.View.SEARCH, query: '1'};
+      await promise;
     });
 
-    test('Commit hash redirects to change', done => {
+    test('Commit hash redirects to change', async () => {
       const change = {_number: 1};
       sinon.stub(element, '_getChanges')
           .returns(Promise.resolve([change]));
+      const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake(
           (url, opt_patchNum, opt_basePatchNum, opt_isEdit, opt_redirect) => {
             assert.equal(url, change);
             assert.isTrue(opt_redirect);
-            done();
+            promise.resolve();
           });
 
       element.params = {view: GerritNav.View.SEARCH, query: COMMIT_HASH};
+      await promise;
     });
 
-    test('Searching for an invalid change ID searches', () => {
+    test('Searching for an invalid change ID searches', async () => {
       sinon.stub(element, '_getChanges')
           .returns(Promise.resolve([]));
       const stub = sinon.stub(GerritNav, 'navigateToChange');
 
       element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      flush();
+      await flush();
 
       assert.isFalse(stub.called);
     });
 
-    test('Change ID with multiple search results searches', () => {
+    test('Change ID with multiple search results searches', async () => {
       sinon.stub(element, '_getChanges')
           .returns(Promise.resolve([{}, {}]));
       const stub = sinon.stub(GerritNav, 'navigateToChange');
 
       element.params = {view: GerritNav.View.SEARCH, query: CHANGE_ID};
-      flush();
+      await flush();
 
       assert.isFalse(stub.called);
     });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.ts
index 62cfcb4..a79dd8e 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
@@ -24,11 +24,10 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-list_html';
 import {appContext} from '../../../services/app-context';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  Modifier,
+  ShortcutListener,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
@@ -38,7 +37,7 @@
 } from '../../core/gr-navigation/gr-navigation';
 import {getPluginEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {changeIsOpen, isOwner} from '../../../utils/change-util';
+import {isOwner} from '../../../utils/change-util';
 import {customElement, property, observe} from '@polymer/decorators';
 import {GrCursorManager} from '../../shared/gr-cursor-manager/gr-cursor-manager';
 import {
@@ -47,32 +46,44 @@
   ServerInfo,
   PreferencesInput,
 } from '../../../types/common';
-import {
-  hasAttention,
-  isAttentionSetEnabled,
-} from '../../../utils/attention-set-util';
-import {CustomKeyboardEvent} from '../../../types/events';
-import {fireEvent} from '../../../utils/event-util';
-import {windowLocationReload} from '../../../utils/dom-util';
+import {hasAttention} from '../../../utils/attention-set-util';
+import {fireEvent, fireReload} from '../../../utils/event-util';
 import {ScrollMode} from '../../../constants/constants';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
 
 const NUMBER_FIXED_COLUMNS = 3;
 const CLOSED_STATUS = ['MERGED', 'ABANDONED'];
 const LABEL_PREFIX_INVALID_PROLOG = 'Invalid-Prolog-Rules-Label-Name--';
 const MAX_SHORTCUT_CHARS = 5;
 
+export const columnNames = [
+  'Subject',
+  'Status',
+  'Owner',
+  'Assignee',
+  'Reviewers',
+  'Comments',
+  'Repo',
+  'Branch',
+  'Updated',
+  'Size',
+];
+
 export interface ChangeListSection {
   name?: string;
   query?: string;
   results: ChangeInfo[];
 }
+
 export interface GrChangeList {
   $: {};
 }
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = KeyboardShortcutMixin(PolymerElement);
+
 @customElement('gr-change-list')
-export class GrChangeList extends ChangeTableMixin(
-  KeyboardShortcutMixin(PolymerElement)
-) {
+export class GrChangeList extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -124,9 +135,6 @@
   @property({type: Boolean})
   showReviewedState = false;
 
-  @property({type: Object})
-  keyEventTarget: HTMLElement = document.body;
-
   @property({type: Array})
   changeTableColumns?: string[];
 
@@ -142,21 +150,23 @@
   @property({type: Object})
   _config?: ServerInfo;
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
   private readonly restApiService = appContext.restApiService;
 
-  keyboardShortcuts() {
-    return {
-      [Shortcut.CURSOR_NEXT_CHANGE]: '_nextChange',
-      [Shortcut.CURSOR_PREV_CHANGE]: '_prevChange',
-      [Shortcut.NEXT_PAGE]: '_nextPage',
-      [Shortcut.PREV_PAGE]: '_prevPage',
-      [Shortcut.OPEN_CHANGE]: '_openChange',
-      [Shortcut.TOGGLE_CHANGE_REVIEWED]: '_toggleChangeReviewed',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_toggleChangeStar',
-      [Shortcut.REFRESH_CHANGE_LIST]: '_refreshChangeList',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.CURSOR_NEXT_CHANGE, _ => this._nextChange()),
+      listen(Shortcut.CURSOR_PREV_CHANGE, _ => this._prevChange()),
+      listen(Shortcut.NEXT_PAGE, _ => this._nextPage()),
+      listen(Shortcut.PREV_PAGE, _ => this._prevPage()),
+      listen(Shortcut.OPEN_CHANGE, _ => this.openChange()),
+      listen(Shortcut.TOGGLE_CHANGE_REVIEWED, _ =>
+        this._toggleChangeReviewed()
+      ),
+      listen(Shortcut.TOGGLE_CHANGE_STAR, _ => this._toggleChangeStar()),
+      listen(Shortcut.REFRESH_CHANGE_LIST, _ => this._refreshChangeList()),
+    ];
   }
 
   private cursor = new GrCursorManager();
@@ -168,34 +178,30 @@
     this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-list-header'
-        );
+        this._dynamicHeaderEndpoints =
+          getPluginEndpoints().getDynamicEndpoints('change-list-header');
       });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.cursor.unsetCursor();
     super.disconnectedCallback();
   }
 
   /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+   * shortcut-service catches keyboard events globally. Some keyboard
    * events must be scoped to a component level (e.g. `enter`) in order to not
    * override native browser functionality.
    *
@@ -204,7 +210,7 @@
   _scopedKeydownHandler(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter.
-      this._openChange((e as unknown) as CustomKeyboardEvent);
+      this.openChange();
     }
   }
 
@@ -222,32 +228,44 @@
       return;
     }
 
-    this.changeTableColumns = this.columnNames;
+    this.changeTableColumns = columnNames;
     this.showNumber = false;
-    this.visibleChangeTableColumns = this.getEnabledColumns(
-      this.columnNames,
-      config,
-      this.flagsService.enabledExperiments
+    this.visibleChangeTableColumns = this.changeTableColumns.filter(col =>
+      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
     );
-
     if (account && preferences) {
       this.showNumber = !!(
         preferences && preferences.legacycid_in_change_table
       );
       if (preferences.change_table && preferences.change_table.length > 0) {
-        const prefColumns = this.renameProjectToRepoColumn(
-          preferences.change_table
+        const prefColumns = preferences.change_table.map(column =>
+          column === 'Project' ? 'Repo' : column
         );
-        this.visibleChangeTableColumns = this.getEnabledColumns(
-          prefColumns,
-          config,
-          this.flagsService.enabledExperiments
+        this.visibleChangeTableColumns = prefColumns.filter(col =>
+          this._isColumnEnabled(
+            col,
+            config,
+            this.flagsService.enabledExperiments
+          )
         );
       }
     }
   }
 
   /**
+   * Is the column disabled by a server config or experiment? For example the
+   * assignee feature might be disabled and thus the corresponding column is
+   * also disabled.
+   *
+   */
+  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+    if (!config || !config.change) return true;
+    if (column === 'Assignee') return !!config.change.enable_assignee;
+    if (column === 'Comments') return experiments.includes('comments-column');
+    return true;
+  }
+
+  /**
    * This methods allows us to customize the columns per section.
    *
    * @param visibleColumns are the columns according to configs and user prefs
@@ -371,94 +389,44 @@
       : undefined;
   }
 
-  _computeItemNeedsReview(
-    account: AccountInfo | undefined,
-    change: ChangeInfo,
-    showReviewedState: boolean,
-    config?: ServerInfo
-  ) {
-    return (
-      !isAttentionSetEnabled(config) &&
-      showReviewedState &&
-      !change.reviewed &&
-      !change.work_in_progress &&
-      changeIsOpen(change) &&
-      (!account || account._account_id !== change.owner._account_id)
-    );
-  }
-
   _computeItemHighlight(
     account?: AccountInfo,
     change?: ChangeInfo,
-    config?: ServerInfo,
     sectionName?: string
   ) {
     if (!change || !account) return false;
     if (CLOSED_STATUS.indexOf(change.status) !== -1) return false;
-    return isAttentionSetEnabled(config)
-      ? hasAttention(config, account, change) &&
-          !isOwner(change, account) &&
-          sectionName === YOUR_TURN.name
-      : account._account_id === change.assignee?._account_id;
+    return (
+      hasAttention(account, change) &&
+      !isOwner(change, account) &&
+      sectionName === YOUR_TURN.name
+    );
   }
 
-  _nextChange(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _nextChange() {
     this.isCursorMoving = true;
     this.cursor.next();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
   }
 
-  _prevChange(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _prevChange() {
     this.isCursorMoving = true;
     this.cursor.previous();
     this.isCursorMoving = false;
     this.selectedIndex = this.cursor.index;
   }
 
-  _openChange(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  openChange() {
     const change = this._changeForIndex(this.selectedIndex);
     if (change) GerritNav.navigateToChange(change);
   }
 
-  _nextPage(e: CustomKeyboardEvent) {
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _nextPage() {
     fireEvent(this, 'next-page');
   }
 
-  _prevPage(e: CustomKeyboardEvent) {
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY))
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _prevPage() {
     this.dispatchEvent(
       new CustomEvent('previous-page', {
         composed: true,
@@ -467,12 +435,7 @@
     );
   }
 
-  _toggleChangeReviewed(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _toggleChangeReviewed() {
     this._toggleReviewedForIndex(this.selectedIndex);
   }
 
@@ -486,25 +449,11 @@
     changeEl.toggleReviewed();
   }
 
-  _refreshChangeList(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this._reloadWindow();
+  _refreshChangeList() {
+    fireReload(this);
   }
 
-  _reloadWindow() {
-    windowLocationReload();
-  }
-
-  _toggleChangeStar(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _toggleChangeStar() {
     this._toggleStarForIndex(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 317d27f..c31da77 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
@@ -63,7 +63,7 @@
               class="cell"
               colspan$="[[_computeColspan(changeSection, visibleChangeTableColumns, labelNames)]]"
             >
-              <h2>
+              <h2 class="heading-3">
                 <a
                   href$="[[_sectionHref(changeSection.query)]]"
                   class="section-title"
@@ -144,8 +144,7 @@
           <gr-change-list-item
             account="[[account]]"
             selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
-            highlight$="[[_computeItemHighlight(account, change, _config, changeSection.name)]]"
-            needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState, _config)]]"
+            highlight$="[[_computeItemHighlight(account, change, changeSection.name)]]"
             change="[[change]]"
             config="[[_config]]"
             section-name="[[changeSection.name]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
index 494d05a..de1a0a2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.js
@@ -19,8 +19,7 @@
 import './gr-change-list.js';
 import {afterNextRender} from '@polymer/polymer/lib/utils/render-status.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {TestKeyboardShortcutBinder} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {mockPromise} from '../../../test/test-utils.js';
 import {YOUR_TURN} from '../../core/gr-navigation/gr-navigation.js';
 
 const basicFixture = fixtureFromElement('gr-change-list');
@@ -28,22 +27,6 @@
 suite('gr-change-list basic tests', () => {
   let element;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    kb.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
-    kb.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.NEXT_PAGE, 'n');
-    kb.bindShortcut(Shortcut.NEXT_PAGE, 'p');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
   setup(() => {
     element = basicFixture.instantiate();
   });
@@ -149,7 +132,7 @@
         {}, changeTableColumns, labelNames));
   });
 
-  test('keyboard shortcuts', done => {
+  test('keyboard shortcuts', async () => {
     sinon.stub(element, '_computeLabelNames');
     element.sections = [
       {results: new Array(1)},
@@ -161,111 +144,40 @@
       {_number: 1},
       {_number: 2},
     ];
-    flush();
+    await flush();
+    const promise = mockPromise();
     afterNextRender(element, () => {
-      const elementItems = element.root.querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 3);
-
-      assert.isTrue(elementItems[0].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 1);
-      assert.isTrue(elementItems[1].hasAttribute('selected'));
-      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      assert.equal(element.selectedIndex, 2);
-      assert.isTrue(elementItems[2].hasAttribute('selected'));
-
-      const navStub = sinon.stub(GerritNav, 'navigateToChange');
-      assert.equal(element.selectedIndex, 2);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-          'Should navigate to /c/2/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-          'Should navigate to /c/1/');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-      assert.equal(element.selectedIndex, 0);
-
-      const reloadStub = sinon.stub(element, '_reloadWindow');
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      assert.isTrue(reloadStub.called);
-
-      done();
+      promise.resolve();
     });
-  });
-
-  test('changes needing review', () => {
-    element.changes = [
-      {
-        _number: 0,
-        status: 'NEW',
-        reviewed: true,
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 1,
-        status: 'NEW',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 2,
-        status: 'MERGED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 3,
-        status: 'ABANDONED',
-        owner: {_account_id: 0},
-      },
-      {
-        _number: 4,
-        status: 'NEW',
-        work_in_progress: true,
-        owner: {_account_id: 0},
-      },
-    ];
-    flush();
-    let elementItems = element.root.querySelectorAll(
+    await promise;
+    const elementItems = element.root.querySelectorAll(
         'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
+    assert.equal(elementItems.length, 3);
 
-    element.showReviewedState = true;
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+    assert.isTrue(elementItems[0].hasAttribute('selected'));
+    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+    assert.equal(element.selectedIndex, 1);
+    assert.isTrue(elementItems[1].hasAttribute('selected'));
+    MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+    assert.equal(element.selectedIndex, 2);
+    assert.isTrue(elementItems[2].hasAttribute('selected'));
 
-    element.account = {_account_id: 42};
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    assert.equal(elementItems.length, 5);
-    assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-    assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-    assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    assert.equal(element.selectedIndex, 2);
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+        'Should navigate to /c/2/');
 
-    element._config = {
-      change: {enable_attention_set: true},
-    };
-    elementItems = element.root.querySelectorAll(
-        'gr-change-list-item');
-    for (let i = 0; i < elementItems.length; i++) {
-      assert.isFalse(elementItems[i].hasAttribute('needs-review'));
-    }
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    assert.equal(element.selectedIndex, 1);
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+    assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+        'Should navigate to /c/1/');
+
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+    assert.equal(element.selectedIndex, 0);
   });
 
   test('no changes', () => {
@@ -339,7 +251,7 @@
     });
 
     test('all columns visible', () => {
-      for (const column of element.columnNames) {
+      for (const column of element.changeTableColumns) {
         const elementClass = '.' + element._lowerCase(column);
         assert.isFalse(element.shadowRoot
             .querySelector(elementClass).hidden);
@@ -508,7 +420,7 @@
       element = basicFixture.instantiate();
     });
 
-    test('keyboard shortcuts', done => {
+    test('keyboard shortcuts', async () => {
       element.selectedIndex = 0;
       element.sections = [
         {
@@ -533,77 +445,48 @@
           ],
         },
       ];
-      flush();
+      await flush();
+      const promise = mockPromise();
       afterNextRender(element, () => {
-        const elementItems = element.root.querySelectorAll(
-            'gr-change-list-item');
-        assert.equal(elementItems.length, 9);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-
-        const navStub = sinon.stub(GerritNav, 'navigateToChange');
-        assert.equal(element.selectedIndex, 2);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
-            'Should navigate to /c/2/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
-        assert.equal(element.selectedIndex, 1);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
-            'Should navigate to /c/1/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
-        assert.equal(element.selectedIndex, 4);
-        MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
-        assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
-            'Should navigate to /c/4/');
-
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-        const change = element._changeForIndex(element.selectedIndex);
-        assert.equal(change.reviewed, true,
-            'Should mark change as reviewed');
-        MockInteractions.pressAndReleaseKeyOn(element, 82); // 'r'
-        assert.equal(change.reviewed, false,
-            'Should mark change as unreviewed');
-        done();
+        promise.resolve();
       });
-    });
+      await promise;
+      const elementItems = element.root.querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 9);
 
-    test('highlight attribute is updated correctly', () => {
-      element.changes = [
-        {
-          _number: 0,
-          status: 'NEW',
-          owner: {_account_id: 0},
-        },
-        {
-          _number: 1,
-          status: 'ABANDONED',
-          owner: {_account_id: 0},
-        },
-      ];
-      element.account = {_account_id: 42};
-      flush();
-      let items = element._getListItems();
-      assert.equal(items.length, 2);
-      assert.isFalse(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
-      // Assign all issues to the user, but only the first one is highlighted
-      // because the second one is abandoned.
-      element.set(['changes', 0, 'assignee'], {_account_id: 12});
-      element.set(['changes', 1, 'assignee'], {_account_id: 12});
-      element.account = {_account_id: 12};
-      flush();
-      items = element._getListItems();
-      assert.isTrue(items[0].hasAttribute('highlight'));
-      assert.isFalse(items[1].hasAttribute('highlight'));
+      const navStub = sinon.stub(GerritNav, 'navigateToChange');
+      assert.equal(element.selectedIndex, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
+          'Should navigate to /c/2/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
+      assert.equal(element.selectedIndex, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
+          'Should navigate to /c/1/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      assert.equal(element.selectedIndex, 4);
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
+          'Should navigate to /c/4/');
+
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      const change = element._changeForIndex(element.selectedIndex);
+      assert.equal(change.reviewed, true,
+          'Should mark change as reviewed');
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.equal(change.reviewed, false,
+          'Should mark change as unreviewed');
     });
 
     test('_computeItemHighlight gives false for null account', () => {
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 0e2668d..fd2a5d7 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
@@ -15,13 +15,13 @@
  * limitations under the License.
  */
 
-import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-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';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement} from 'lit/decorators';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,9 +30,73 @@
 }
 
 @customElement('gr-create-change-help')
-class GrCreateChangeHelp extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrCreateChangeHelp extends LitElement {
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        :host {
+          display: block;
+        }
+        #graphic {
+          display: inline-block;
+          margin: var(--spacing-m);
+          margin-left: 0;
+        }
+        #graphic #circle {
+          align-items: center;
+          background-color: var(--chip-background-color);
+          border-radius: 50%;
+          display: flex;
+          height: 10em;
+          justify-content: center;
+          width: 10em;
+        }
+        #graphic iron-icon {
+          color: var(--gray-foreground);
+          height: 5em;
+          width: 5em;
+        }
+        #graphic p {
+          color: var(--deemphasized-text-color);
+          text-align: center;
+        }
+        #help {
+          display: inline-block;
+          margin: var(--spacing-m);
+          padding-top: var(--spacing-xl);
+          vertical-align: top;
+        }
+        #help p {
+          margin-bottom: var(--spacing-m);
+          max-width: 35em;
+        }
+        @media only screen and (max-width: 50em) {
+          #graphic {
+            display: none;
+          }
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <div id="graphic">
+        <div id="circle">
+          <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
+        </div>
+        <p>No outgoing changes yet</p>
+      </div>
+      <div id="help">
+        <h2 class="heading-3">Push your first change for code review</h2>
+        <p>
+          Pushing a change for review is easy, but a little different from other
+          git code review tools. Click on the \`Create Change' button and follow
+          the step by step instructions.
+        </p>
+        <gr-button @click=${this._handleCreateTap}>Create Change</gr-button>
+      </div>`;
   }
 
   /**
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
deleted file mode 100644
index 95a67af..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_html.ts
+++ /dev/null
@@ -1,78 +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">
-    :host {
-      display: block;
-    }
-    #graphic {
-      display: inline-block;
-      margin: var(--spacing-m);
-      margin-left: 0;
-    }
-    #graphic #circle {
-      align-items: center;
-      background-color: var(--chip-background-color);
-      border-radius: 50%;
-      display: flex;
-      height: 10em;
-      justify-content: center;
-      width: 10em;
-    }
-    #graphic iron-icon {
-      color: var(--gray-foreground);
-      height: 5em;
-      width: 5em;
-    }
-    #graphic p {
-      color: var(--deemphasized-text-color);
-      text-align: center;
-    }
-    #help {
-      display: inline-block;
-      margin: var(--spacing-m);
-      padding-top: var(--spacing-xl);
-      vertical-align: top;
-    }
-    #help p {
-      margin-bottom: var(--spacing-m);
-      max-width: 35em;
-    }
-    @media only screen and (max-width: 50em) {
-      #graphic {
-        display: none;
-      }
-    }
-  </style>
-  <div id="graphic">
-    <div id="circle">
-      <iron-icon id="icon" icon="gr-icons:zeroState"></iron-icon>
-    </div>
-    <p>No outgoing changes yet</p>
-  </div>
-  <div id="help">
-    <h2 class="heading-3">Push your first change for code review</h2>
-    <p>
-      Pushing a change for review is easy, but a little different from other git
-      code review tools. Click on the \`Create Change' button and follow the
-      step by step instructions.
-    </p>
-    <gr-button on-click="_handleCreateTap">Create Change</gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js
deleted file mode 100644
index 9dd0a35..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.js
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-create-change-help.js';
-
-const basicFixture = fixtureFromElement('gr-create-change-help');
-
-suite('gr-create-change-help tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('Create change tap', done => {
-    element.addEventListener('create-tap', () => done());
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button'));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
new file mode 100644
index 0000000..e170a74
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-create-change-help/gr-create-change-help_test.ts
@@ -0,0 +1,41 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-create-change-help';
+import {GrCreateChangeHelp} from './gr-create-change-help';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-create-change-help');
+
+suite('gr-create-change-help tests', () => {
+  let element: GrCreateChangeHelp;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
+  });
+
+  test('Create change tap', async () => {
+    const promise = mockPromise();
+    element.addEventListener('create-tap', () => promise.resolve());
+    MockInteractions.tap(queryAndAssert<GrButton>(element, 'gr-button'));
+    await promise;
+  });
+});
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
index f2f767a..6c9fa68 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog.ts
@@ -19,9 +19,9 @@
 import '../../shared/gr-overlay/gr-overlay';
 import '../../shared/gr-shell-command/gr-shell-command';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-create-commands-dialog_html';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query} from 'lit/decorators';
 
 enum Commands {
   CREATE = 'git commit',
@@ -35,42 +35,79 @@
   }
 }
 
-export interface GrCreateCommandsDialog {
-  $: {
-    commandsOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-create-commands-dialog')
-export class GrCreateCommandsDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrCreateCommandsDialog extends LitElement {
+  @query('#commandsOverlay')
+  commandsOverlay?: GrOverlay;
 
   @property({type: String})
   branch?: string;
 
-  @property({type: String})
-  readonly _createNewCommitCommand = Commands.CREATE;
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        ol {
+          list-style: decimal;
+          margin-left: var(--spacing-l);
+        }
+        p {
+          margin-bottom: var(--spacing-m);
+        }
+        #commandsDialog {
+          max-width: 40em;
+        }
+      `,
+    ];
+  }
 
-  @property({type: String})
-  readonly _amendExistingCommitCommand = Commands.AMEND;
-
-  @property({
-    type: String,
-    computed: '_computePushCommand(branch)',
-  })
-  _pushCommand?: string;
+  override render() {
+    return html` <gr-overlay id="commandsOverlay" with-backdrop="">
+      <gr-dialog
+        id="commandsDialog"
+        confirm-label="Done"
+        cancel-label=""
+        confirm-on-enter=""
+        @confirm=${() => this.commandsOverlay?.close()}
+      >
+        <div class="header" slot="header">Create change commands</div>
+        <div class="main" slot="main">
+          <ol>
+            <li>
+              <p>Make the changes to the files on your machine</p>
+            </li>
+            <li>
+              <p>If you are making a new commit use</p>
+              <gr-shell-command
+                .command="${Commands.CREATE}"
+              ></gr-shell-command>
+              <p>Or to amend an existing commit use</p>
+              <gr-shell-command .command="${Commands.AMEND}"></gr-shell-command>
+              <p>
+                Please make sure you add a commit message as it becomes the
+                description for your change.
+              </p>
+            </li>
+            <li>
+              <p>Push the change for code review</p>
+              <gr-shell-command
+                .command="${Commands.PUSH_PREFIX + (this.branch ?? '[BRANCH]')}"
+              ></gr-shell-command>
+            </li>
+            <li>
+              <p>
+                Close this dialog and you should be able to see your recently
+                created change in the 'Outgoing changes' section on the 'Your
+                changes' page.
+              </p>
+            </li>
+          </ol>
+        </div>
+      </gr-dialog>
+    </gr-overlay>`;
+  }
 
   open() {
-    this.$.commandsOverlay.open();
-  }
-
-  _handleClose() {
-    this.$.commandsOverlay.close();
-  }
-
-  _computePushCommand(branch: string): string {
-    return Commands.PUSH_PREFIX + branch;
+    this.commandsOverlay?.open();
   }
 }
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts
deleted file mode 100644
index d2f35d6..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_html.ts
+++ /dev/null
@@ -1,75 +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">
-    ol {
-      list-style: decimal;
-      margin-left: var(--spacing-l);
-    }
-    p {
-      margin-bottom: var(--spacing-m);
-    }
-    #commandsDialog {
-      max-width: 40em;
-    }
-  </style>
-  <gr-overlay id="commandsOverlay" with-backdrop="">
-    <gr-dialog
-      id="commandsDialog"
-      confirm-label="Done"
-      cancel-label=""
-      confirm-on-enter=""
-      on-confirm="_handleClose"
-    >
-      <div class="header" slot="header">Create change commands</div>
-      <div class="main" slot="main">
-        <ol>
-          <li>
-            <p>Make the changes to the files on your machine</p>
-          </li>
-          <li>
-            <p>If you are making a new commit use</p>
-            <gr-shell-command
-              command="[[_createNewCommitCommand]]"
-            ></gr-shell-command>
-            <p>Or to amend an existing commit use</p>
-            <gr-shell-command
-              command="[[_amendExistingCommitCommand]]"
-            ></gr-shell-command>
-            <p>
-              Please make sure you add a commit message as it becomes the
-              description for your change.
-            </p>
-          </li>
-          <li>
-            <p>Push the change for code review</p>
-            <gr-shell-command command="[[_pushCommand]]"></gr-shell-command>
-          </li>
-          <li>
-            <p>
-              Close this dialog and you should be able to see your recently
-              created change in the 'Outgoing changes' section on the 'Your
-              changes' page.
-            </p>
-          </li>
-        </ol>
-      </div>
-    </gr-dialog>
-  </gr-overlay>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
similarity index 69%
rename from polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
rename to polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
index 9dbcd29..ea367f7 100644
--- a/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.js
+++ b/polygerrit-ui/app/elements/change-list/gr-create-commands-dialog/gr-create-commands-dialog_test.ts
@@ -15,26 +15,21 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-create-commands-dialog.js';
+import '../../../test/common-test-setup-karma';
+import './gr-create-commands-dialog';
+import {GrCreateCommandsDialog} from './gr-create-commands-dialog';
 
 const basicFixture = fixtureFromElement('gr-create-commands-dialog');
 
 suite('gr-create-commands-dialog tests', () => {
-  let element;
+  let element: GrCreateCommandsDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
-  test('_computePushCommand', () => {
+  test('branch', () => {
     element.branch = 'master';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/master');
-
-    element.branch = 'stable-2.15';
-    assert.equal(element._pushCommand,
-        'git push origin HEAD:refs/for/stable-2.15');
+    assert.equal(element.branch, 'master');
   });
 });
-
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 f9dee5f..9eca3bc 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-change-list/gr-change-list';
 import '../../shared/gr-button/gr-button';
@@ -50,14 +51,13 @@
   GrCreateDestinationDialog,
 } from '../gr-create-destination-dialog/gr-create-destination-dialog';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {ChangeListToggleReviewedDetail} from '../gr-change-list-item/gr-change-list-item';
 import {ChangeStarToggleStarDetail} from '../../shared/gr-change-star/gr-change-star';
 import {DashboardViewState} from '../../../types/types';
 import {firePageError, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
+import {RELOAD_DASHBOARD_INTERVAL_MS} from '../../../constants/constants';
 
 const PROJECT_PLACEHOLDER_PATTERN = /\${project}/g;
-const RELOAD_DASHBOARD_INTERVAL_MS = 10 * 1000;
 
 export interface GrDashboardView {
   $: {
@@ -123,16 +123,9 @@
 
   constructor() {
     super();
-  }
-
-  /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    this._loadPreferences();
-    this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(this.params);
-    });
+    this.addEventListener('reload', () => this._reload(this.params));
+    // We are not currently verifying if the view is actually visible. We rely
+    // on gr-app-element to restamp the component if view changes
     document.addEventListener('visibilitychange', () => {
       if (document.visibilityState === 'visible') {
         if (
@@ -146,6 +139,11 @@
     });
   }
 
+  override connectedCallback() {
+    super.connectedCallback();
+    this._loadPreferences();
+  }
+
   _loadPreferences() {
     return this.restApiService.getLoggedIn().then(loggedIn => {
       if (loggedIn) {
@@ -256,7 +254,7 @@
       })
       .catch(err => {
         fireTitleChange(this, title || this._computeTitle(user));
-        console.warn(err);
+        this.reporting.error(err);
       })
       .then(() => {
         this._loading = false;
@@ -376,6 +374,9 @@
       e.detail.change._number,
       e.detail.starred
     );
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-dashboard');
+    }
     // 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.
@@ -391,26 +392,6 @@
     );
   }
 
-  _handleToggleReviewed(e: CustomEvent<ChangeListToggleReviewedDetail>) {
-    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
-          );
-        }
-      })
-    );
-  }
-
   /**
    * Banner is shown if a user is on their own dashboard and they have draft
    * comments on closed changes.
@@ -474,6 +455,14 @@
     this.$.commandsDialog.branch = e.detail.branch;
     this.$.commandsDialog.open();
   }
+
+  /**
+   * Returns `this` as the visibility observer target for the keyboard shortcut
+   * mixin to decide whether shortcuts should be enabled or not.
+   */
+  _computeObserverTarget() {
+    return this;
+  }
 }
 
 declare global {
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 fd23970..a55befb 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
@@ -39,11 +42,6 @@
       justify-content: space-between;
       padding: var(--spacing-xs) var(--spacing-l);
     }
-    .banner gr-button {
-      --gr-button: {
-        color: var(--primary-text-color);
-      }
-    }
     .hide {
       display: none;
     }
@@ -78,13 +76,12 @@
     <h1 class="assistive-tech-only">Dashboard</h1>
     <gr-change-list
       show-star=""
-      show-reviewed-state=""
       account="[[account]]"
       preferences="[[preferences]]"
       selected-index="{{_selectedChangeIndex}}"
       sections="[[_results]]"
       on-toggle-star="_handleToggleStar"
-      on-toggle-reviewed="_handleToggleReviewed"
+      observer-target="[[_computeObserverTarget()]]"
     >
       <div id="emptyOutgoing" slot="empty-outgoing">
         <template is="dom-if" if="[[_showNewUserHelp]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.js
index 165306e..aa76347 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
@@ -22,7 +22,7 @@
 import {changeIsOpen} from '../../../utils/change-util.js';
 import {ChangeStatus} from '../../../constants/constants.js';
 import {createAccountWithId} from '../../../test/test-data-generators.js';
-import {addListenerForTest, stubRestApi, isHidden} from '../../../test/test-utils.js';
+import {addListenerForTest, stubRestApi, isHidden, mockPromise} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-dashboard-view');
 
@@ -128,7 +128,7 @@
 
       // Open confirmation dialog and tap confirm button.
       await element.$.confirmDeleteOverlay.open();
-      MockInteractions.tap(element.$.confirmDeleteDialog.$.confirm);
+      MockInteractions.tap(element.$.confirmDeleteDialog.confirmButton);
       flush();
       assert.isTrue(deleteStub.calledWithExactly('-is:open'));
       assert.isTrue(element.$.confirmDeleteDialog.disabled);
@@ -356,31 +356,6 @@
     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;
@@ -420,21 +395,23 @@
         'hide');
   });
 
-  test('404 page', done => {
+  test('404 page', async () => {
     const response = {status: 404};
     stubRestApi('getDashboard').callsFake(
         async (project, dashboard, errFn) => {
           errFn(response);
         });
+    const promise = mockPromise();
     addListenerForTest(document, 'page-error', e => {
       assert.strictEqual(e.detail.response, response);
-      paramsChangedPromise.then(done);
+      promise.resolve();
     });
     element.params = {
       view: GerritNav.View.DASHBOARD,
       project: 'project',
       dashboard: 'dashboard',
     };
+    await Promise.all([paramsChangedPromise, promise]);
   });
 
   test('params change triggers dashboardDisplayed()', async () => {
@@ -452,7 +429,7 @@
     assert.isTrue(element.reporting.dashboardDisplayed.calledOnce);
   });
 
-  test('selectedChangeIndex is derived from the params', () => {
+  test('selectedChangeIndex is derived from the params', async () => {
     stubRestApi('getDashboard').returns(Promise.resolve({
       title: 'title',
       sections: [],
@@ -468,9 +445,8 @@
     };
     flush();
     sinon.stub(element.reporting, 'dashboardDisplayed');
-    paramsChangedPromise.then(() => {
-      assert.equal(element._selectedChangeIndex, 23);
-    });
+    await paramsChangedPromise;
+    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 6d7b5b1..d8949ed 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
@@ -15,25 +15,20 @@
  * limitations under the License.
  */
 
-import '../../../styles/dashboard-header-styles';
-import '../../../styles/shared-styles';
-import '../../shared/gr-date-formatter/gr-date-formatter';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-repo-header_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {RepoName} from '../../../types/common';
 import {WebLinkInfo} from '../../../types/diff';
 import {appContext} from '../../../services/app-context';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 @customElement('gr-repo-header')
-class GrRepoHeader extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, observer: '_repoChanged'})
-  repo?: string;
+export class GrRepoHeader extends LitElement {
+  @property({type: String})
+  repo?: RepoName;
 
   @property({type: String})
   _repoUrl: string | null = null;
@@ -43,7 +38,54 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  _repoChanged(repoName: RepoName) {
+  static override get styles() {
+    return [
+      sharedStyles,
+      dashboardHeaderStyles,
+      fontStyles,
+      css`
+        .browse {
+          display: inline-block;
+          font-weight: var(--font-weight-bold);
+          text-align: right;
+          width: 4em;
+        }
+        a {
+          padding-right: 0.3em;
+        }
+      `,
+    ];
+  }
+
+  _renderLinks(webLinks: WebLinkInfo[]) {
+    if (!webLinks) return;
+    return html`<div>
+      <span class="browse">Browse:</span>
+      ${webLinks.map(
+        link => html`<a target="_blank" href="${link.url}">${link.name}</a> `
+      )}
+    </div> `;
+  }
+
+  override render() {
+    return html` <div class="info">
+      <h1 class="heading-1">${this.repo}</h1>
+      <hr />
+      <div>
+        <span>Detail:</span> <a href="${this._repoUrl!}">Repo settings</a>
+      </div>
+      ${this._renderLinks(this._webLinks)}
+    </div>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('repo')) {
+      this._repoChanged();
+    }
+  }
+
+  _repoChanged() {
+    const repoName = this.repo;
     if (!repoName) {
       this._repoUrl = null;
       return;
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
deleted file mode 100644
index 0dbec1c..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_html.ts
+++ /dev/null
@@ -1,45 +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">
-    .browse {
-      display: inline-block;
-      font-weight: var(--font-weight-bold);
-      text-align: right;
-      width: 4em;
-    }
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="dashboard-header-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="info">
-    <h1 class="heading-1">[[repo]]</h1>
-    <hr />
-    <div><span>Detail:</span> <a href$="[[_repoUrl]]">Repo settings</a></div>
-    <span is="dom-if" if="[[_webLinks]]">
-      <div>
-        <span class="browse">Browse:</span>
-        <template is="dom-repeat" items="[[_webLinks]]" as="weblink">
-          <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
-        </template>
-      </div>
-    </span>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js
deleted file mode 100644
index f877e97..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.js
+++ /dev/null
@@ -1,60 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-repo-header.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-repo-header');
-
-suite('gr-repo-header tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('repoUrl reset once repo changed', () => {
-    sinon.stub(GerritNav, 'getUrlForRepo').callsFake(
-        repoName => `http://test.com/${repoName}`
-    );
-    assert.equal(element._repoUrl, undefined);
-    element.repo = 'test';
-    assert.equal(element._repoUrl, 'http://test.com/test');
-  });
-
-  test('webLinks set', () => {
-    const repoRes = {
-      web_links: [
-        {
-          name: 'gitiles',
-          url: 'https://gerrit.test/g',
-        },
-      ],
-    };
-
-    stubRestApi('getRepo').returns(Promise.resolve(repoRes));
-
-    assert.deepEqual(element._webLinks, []);
-
-    element.repo = 'test';
-    flush(() => {
-      assert.deepEqual(element._webLinks, repoRes.web_links);
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
new file mode 100644
index 0000000..56ad8bc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-repo-header/gr-repo-header_test.ts
@@ -0,0 +1,63 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-repo-header';
+import {GrRepoHeader} from './gr-repo-header';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {stubRestApi} from '../../../test/test-utils';
+import {RepoName, UrlEncodedRepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-repo-header');
+
+suite('gr-repo-header tests', () => {
+  let element: GrRepoHeader;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('repoUrl reset once repo changed', async () => {
+    sinon
+      .stub(GerritNav, 'getUrlForRepo')
+      .callsFake(repoName => `http://test.com/${repoName},general`);
+    assert.equal(element._repoUrl, undefined);
+    element.repo = 'test' as RepoName;
+    await flush();
+    assert.equal(element._repoUrl, 'http://test.com/test,general');
+  });
+
+  test('webLinks set', async () => {
+    const repoRes = {
+      id: 'test' as UrlEncodedRepoName,
+      web_links: [
+        {
+          name: 'gitiles',
+          url: 'https://gerrit.test/g',
+        },
+      ],
+    };
+
+    stubRestApi('getRepo').returns(Promise.resolve(repoRes));
+
+    assert.deepEqual(element._webLinks, []);
+
+    element.repo = 'test' as RepoName;
+    await flush();
+    assert.deepEqual(element._webLinks, repoRes.web_links);
+  });
+});
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 5e4c361..50de7b9 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
@@ -15,27 +15,23 @@
  * limitations under the License.
  */
 
-import '../../../styles/shared-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../shared/gr-avatar/gr-avatar';
 import '../../shared/gr-date-formatter/gr-date-formatter';
-import '../../../styles/dashboard-header-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-user-header_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {AccountDetailInfo, AccountId} from '../../../types/common';
 import {getDisplayName} from '../../../utils/display-name-util';
 import {appContext} from '../../../services/app-context';
+import {dashboardHeaderStyles} from '../../../styles/dashboard-header-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 @customElement('gr-user-header')
-export class GrUserHeader extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, observer: '_accountChanged'})
+export class GrUserHeader extends LitElement {
+  @property({type: String})
   userId?: AccountId;
 
   @property({type: Boolean})
@@ -45,34 +41,108 @@
   loggedIn = false;
 
   @property({type: Object})
-  _accountDetails: AccountDetailInfo | null = null;
+  _accountDetails: AccountDetailInfo | undefined;
 
   @property({type: String})
   _status = '';
 
   private readonly restApiService = appContext.restApiService;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      dashboardHeaderStyles,
+      fontStyles,
+      css`
+        .status.hide,
+        .name.hide,
+        .dashboardLink.hide {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<gr-avatar
+        .account="${this._accountDetails}"
+        .imageSize=${100}
+        aria-label="Account avatar"
+      ></gr-avatar>
+      <div class="info">
+        <h1 class="heading-1">${this._computeHeading(this._accountDetails)}</h1>
+        <hr />
+        <div class="status ${this._computeStatusClass(this._status)}">
+          <span>Status:</span> ${this._status}
+        </div>
+        <div>
+          <span>Email:</span>
+          <a href="mailto:${this._computeDetail(this._accountDetails, 'email')}"
+            ><!--
+          -->${this._computeDetail(this._accountDetails, 'email')}</a
+          >
+        </div>
+        <div>
+          <span>Joined:</span>
+          <gr-date-formatter
+            dateStr="${this._computeDetail(
+              this._accountDetails,
+              'registered_on'
+            )}"
+          >
+          </gr-date-formatter>
+        </div>
+        <gr-endpoint-decorator name="user-header">
+          <gr-endpoint-param
+            name="accountDetails"
+            .value="${this._accountDetails}"
+          >
+          </gr-endpoint-param>
+          <gr-endpoint-param name="loggedIn" .value="${this.loggedIn}">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+      <div class="info">
+        <div
+          class="${this._computeDashboardLinkClass(
+            this.showDashboardLink,
+            this.loggedIn
+          )}"
+        >
+          <a href="${this._computeDashboardUrl(this._accountDetails)}"
+            >View dashboard</a
+          >
+        </div>
+      </div>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('userId')) {
+      this._accountChanged(this.userId);
+    }
+  }
+
   _accountChanged(userId?: AccountId) {
     if (!userId) {
-      this._accountDetails = null;
+      this._accountDetails = undefined;
       this._status = '';
       return;
     }
 
     this.restApiService.getAccountDetails(userId).then(details => {
-      this._accountDetails = details ?? null;
+      this._accountDetails = details ?? undefined;
       this._status = details?.status ?? '';
     });
   }
 
   _computeDetail(
-    accountDetails: AccountDetailInfo | null,
+    accountDetails: AccountDetailInfo | undefined,
     name: keyof AccountDetailInfo
   ) {
-    return accountDetails ? accountDetails[name] : '';
+    return accountDetails ? String(accountDetails[name]) : '';
   }
 
-  _computeHeading(accountDetails: AccountDetailInfo | null) {
+  _computeHeading(accountDetails: AccountDetailInfo | undefined) {
     if (!accountDetails) return '';
     return getDisplayName(undefined, accountDetails);
   }
@@ -81,9 +151,9 @@
     return status ? '' : 'hide';
   }
 
-  _computeDashboardUrl(accountDetails: AccountDetailInfo | null) {
+  _computeDashboardUrl(accountDetails: AccountDetailInfo | undefined) {
     if (!accountDetails) {
-      return null;
+      return undefined;
     }
     const id = accountDetails._account_id;
     if (id) {
@@ -93,7 +163,7 @@
     if (email) {
       return GerritNav.getUrlForUserDashboard(email);
     }
-    return null;
+    return undefined;
   }
 
   _computeDashboardLinkClass(showDashboardLink: boolean, loggedIn: boolean) {
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
deleted file mode 100644
index 1924a66..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_html.ts
+++ /dev/null
@@ -1,67 +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">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="dashboard-header-styles">
-    .status.hide,
-    .name.hide,
-    .dashboardLink.hide {
-      display: none;
-    }
-  </style>
-  <gr-avatar
-    account="[[_accountDetails]]"
-    image-size="100"
-    aria-label="Account avatar"
-  ></gr-avatar>
-  <div class="info">
-    <h1 class="heading-1">[[_computeHeading(_accountDetails)]]</h1>
-    <hr />
-    <div class$="status [[_computeStatusClass(_status)]]">
-      <span>Status:</span> [[_status]]
-    </div>
-    <div>
-      <span>Email:</span>
-      <a href="mailto:[[_computeDetail(_accountDetails, 'email')]]"
-        ><!--
-          -->[[_computeDetail(_accountDetails, 'email')]]</a
-      >
-    </div>
-    <div>
-      <span>Joined:</span>
-      <gr-date-formatter
-        date-str="[[_computeDetail(_accountDetails, 'registered_on')]]"
-      >
-      </gr-date-formatter>
-    </div>
-    <gr-endpoint-decorator name="user-header">
-      <gr-endpoint-param name="accountDetails" value="[[_accountDetails]]">
-      </gr-endpoint-param>
-      <gr-endpoint-param name="loggedIn" value="[[loggedIn]]">
-      </gr-endpoint-param>
-    </gr-endpoint-decorator>
-  </div>
-  <div class="info">
-    <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
-      <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
-    </div>
-  </div>
-`;
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
deleted file mode 100644
index 4ec799f..0000000
--- a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.js
+++ /dev/null
@@ -1,63 +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 './gr-user-header.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-user-header');
-
-suite('gr-user-header tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('loads and clears account info', done => {
-    stubRestApi('getAccountDetails')
-        .returns(Promise.resolve({
-          name: 'foo',
-          email: 'bar',
-          status: 'OOO',
-          registered_on: '2015-03-12 18:32:08.000000000',
-        }));
-
-    element.userId = 'foo.bar@baz';
-    flush(() => {
-      assert.isOk(element._accountDetails);
-      assert.isOk(element._status);
-
-      element.userId = null;
-      flush(() => {
-        flush();
-        assert.isNull(element._accountDetails);
-        assert.equal(element._status, '');
-
-        done();
-      });
-    });
-  });
-
-  test('_computeDashboardLinkClass', () => {
-    assert.include(element._computeDashboardLinkClass(false, false), 'hide');
-    assert.include(element._computeDashboardLinkClass(true, false), 'hide');
-    assert.include(element._computeDashboardLinkClass(false, true), 'hide');
-    assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
new file mode 100644
index 0000000..0e35000
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.ts
@@ -0,0 +1,62 @@
+/**
+ * @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';
+import './gr-user-header';
+import {GrUserHeader} from './gr-user-header';
+import {stubRestApi} from '../../../test/test-utils';
+import {AccountId, EmailAddress, Timestamp} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-user-header');
+
+suite('gr-user-header tests', () => {
+  let element: GrUserHeader;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('loads and clears account info', async () => {
+    stubRestApi('getAccountDetails').returns(
+      Promise.resolve({
+        name: 'foo',
+        email: 'bar' as EmailAddress,
+        status: 'OOO',
+        registered_on: '2015-03-12 18:32:08.000000000' as Timestamp,
+      })
+    );
+
+    element.userId = 10 as AccountId;
+    await flush();
+
+    assert.isOk(element._accountDetails);
+    assert.isOk(element._status);
+
+    element.userId = undefined;
+    await flush();
+
+    assert.isUndefined(element._accountDetails);
+    assert.equal(element._status, '');
+  });
+
+  test('_computeDashboardLinkClass', () => {
+    assert.include(element._computeDashboardLinkClass(false, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(true, false), 'hide');
+    assert.include(element._computeDashboardLinkClass(false, true), 'hide');
+    assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
+  });
+});
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 e9a5a46..5f91dfb 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
@@ -26,7 +26,6 @@
 import '../gr-confirm-move-dialog/gr-confirm-move-dialog';
 import '../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog';
 import '../gr-confirm-revert-dialog/gr-confirm-revert-dialog';
-import '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
 import '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -35,7 +34,7 @@
 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, CURRENT} from '../../../utils/patch-set-util';
+import {CURRENT} from '../../../utils/patch-set-util';
 import {
   changeIsOpen,
   isOwner,
@@ -76,7 +75,6 @@
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrCreateChangeDialog} from '../../admin/gr-create-change-dialog/gr-create-change-dialog';
 import {GrConfirmSubmitDialog} from '../gr-confirm-submit-dialog/gr-confirm-submit-dialog';
-import {GrConfirmRevertSubmissionDialog} from '../gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog';
 import {
   ConfirmRevertEventDetail,
   GrConfirmRevertDialog,
@@ -95,11 +93,11 @@
   GrChangeActionsElement,
   UIActionInfo,
 } from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
-import {fireAlert} from '../../../utils/event-util';
+import {fireAlert, fireEvent, fireReload} from '../../../utils/event-util';
 import {
-  CODE_REVIEW,
   getApprovalInfo,
   getVotingRange,
+  StandardLabels,
 } from '../../../utils/label-util';
 import {CommentThread} from '../../../utils/comment-util';
 import {ShowAlertEventDetail} from '../../../types/events';
@@ -111,6 +109,7 @@
   RevisionActions,
 } from '../../../api/change-actions';
 import {ErrorCallback} from '../../../api/rest';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -151,7 +150,6 @@
   rebase: 'Rebasing...',
   restore: 'Restoring...',
   revert: 'Reverting...',
-  revert_submission: 'Reverting Submission...',
   submit: 'Submitting...',
 };
 
@@ -186,6 +184,15 @@
   __type: ActionType.REVISION,
 };
 
+const INCLUDED_IN_ACTION: UIActionInfo = {
+  enabled: true,
+  label: 'Included In',
+  title: 'Open Included In dialog',
+  __key: 'includedIn',
+  __primary: false,
+  __type: ActionType.CHANGE,
+};
+
 const REBASE_EDIT: UIActionInfo = {
   enabled: true,
   label: 'Rebase edit',
@@ -245,7 +252,6 @@
   ChangeActions.REBASE_EDIT,
   ChangeActions.RESTORE,
   ChangeActions.REVERT,
-  ChangeActions.REVERT_SUBMISSION,
   ChangeActions.STOP_EDIT,
   QUICK_APPROVE_ACTION.key,
   RevisionActions.REBASE,
@@ -263,18 +269,16 @@
 const AWAIT_CHANGE_ATTEMPTS = 5;
 const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
-/* Revert submission is skipped as the normal revert dialog will now show
-the user a choice between reverting single change or an entire submission.
-Hence, a second button is not needed.
-*/
-const SKIP_ACTION_KEYS = [ChangeActions.REVERT_SUBMISSION];
-
-const SKIP_ACTION_KEYS_ATTENTION_SET = [
+const SKIP_ACTION_KEYS: string[] = [
+  // REVIEWED/UNREVIEWED is made obsolete by AttentionSet. Once the
+  // backend stops supporting (UN)REVIEWED, we can remove these.
   ChangeActions.REVIEWED,
   ChangeActions.UNREVIEWED,
+  // REVERT_SUBMISSION is folded into the dialog for REVERT.
+  ChangeActions.REVERT_SUBMISSION,
 ];
 
-function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
+export function assertUIActionInfo(action?: ActionInfo): UIActionInfo {
   // TODO(TS): Remove this function. The gr-change-actions adds properties
   // to existing ActionInfo objects instead of creating a new objects. This
   // function checks, that 'action' has all property required by UIActionInfo.
@@ -325,20 +329,22 @@
     confirmCherrypickConflict: GrConfirmCherrypickConflictDialog;
     confirmMove: GrConfirmMoveDialog;
     confirmRevertDialog: GrConfirmRevertDialog;
-    confirmRevertSubmissionDialog: GrConfirmRevertSubmissionDialog;
     confirmAbandonDialog: GrConfirmAbandonDialog;
     confirmSubmitDialog: GrConfirmSubmitDialog;
     createFollowUpDialog: GrDialog;
     createFollowUpChange: GrCreateChangeDialog;
     confirmDeleteDialog: GrDialog;
     confirmDeleteEditDialog: GrDialog;
+    moreActions: GrDropdown;
+    secondaryActions: HTMLElement;
   };
 }
 
 @customElement('gr-change-actions')
 export class GrChangeActions
   extends PolymerElement
-  implements GrChangeActionsElement {
+  implements GrChangeActionsElement
+{
   static get template() {
     return htmlTemplate;
   }
@@ -376,10 +382,12 @@
 
   RevisionActions = RevisionActions;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
   private readonly jsAPI = appContext.jsApiService;
 
+  private readonly changeService = appContext.changeService;
+
   @property({type: Object})
   change?: ChangeViewChangeInfo;
 
@@ -448,7 +456,7 @@
     computed:
       '_computeAllActions(actions.*, revisionActions.*,' +
       'primaryActionKeys.*, _additionalActions.*, change, ' +
-      '_config, _actionPriorityOverrides.*)',
+      '_actionPriorityOverrides.*)',
   })
   _allActionValues: UIActionInfo[] = []; // _computeAllActions always returns an array
 
@@ -525,6 +533,10 @@
       type: ActionType.CHANGE,
       key: ChangeActions.FOLLOW_UP,
     },
+    {
+      type: ActionType.CHANGE,
+      key: ChangeActions.INCLUDED_IN,
+    },
   ];
 
   @property({type: Array})
@@ -551,6 +563,9 @@
   @property({type: Object})
   _config?: ServerInfo;
 
+  @property({type: Boolean})
+  loggedIn = false;
+
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
@@ -563,8 +578,7 @@
     );
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this.jsAPI.addElement(TargetElement.CHANGE_ACTIONS, this);
     this.restApiService.getConfig().then(config => {
@@ -809,6 +823,10 @@
         this.set('revisionActions.download', DOWNLOAD_ACTION);
       }
     }
+    const actions = actionsChangeRecord.base || {};
+    if (!actions.includedIn && this.change?.status === ChangeStatus.MERGED) {
+      this.set('actions.includedIn', INCLUDED_IN_ACTION);
+    }
   }
 
   _deleteAndNotify(actionName: string) {
@@ -825,6 +843,7 @@
     'editPatchsetLoaded',
     'editBasedOnCurrentPatchSet',
     'disableEdit',
+    'loggedIn',
     'actions.*',
     'change.*'
   )
@@ -833,13 +852,19 @@
     editPatchsetLoaded: boolean,
     editBasedOnCurrentPatchSet: boolean,
     disableEdit: boolean,
+    loggedIn: boolean,
     actionsChangeRecord?: PolymerDeepPropertyChange<
       ActionNameToActionInfoMap,
       ActionNameToActionInfoMap
     >,
     changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
   ) {
-    if (actionsChangeRecord === undefined || changeChangeRecord === undefined) {
+    // Hide change edits if not logged in
+    if (
+      actionsChangeRecord === undefined ||
+      changeChangeRecord === undefined ||
+      !loggedIn
+    ) {
       return;
     }
     if (disableEdit) {
@@ -960,8 +985,9 @@
     // 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];
+    const codeReviewLabel = this.change.labels[StandardLabels.CODE_REVIEW];
+    const codeReviewPermittedValues =
+      this.change.permitted_labels[StandardLabels.CODE_REVIEW];
     if (
       !result &&
       codeReviewLabel &&
@@ -973,7 +999,7 @@
       getApprovalInfo(codeReviewLabel, this.account)?.value !==
         getVotingRange(codeReviewLabel)?.max
     ) {
-      result = CODE_REVIEW;
+      result = StandardLabels.CODE_REVIEW;
     }
 
     if (result) {
@@ -1175,21 +1201,11 @@
     });
   }
 
-  showRevertSubmissionDialog() {
-    const change = this.change;
-    if (!change) return;
-    const query = `submissionid:${change.submission_id}`;
-    this.restApiService.getChanges(0, query).then(changes => {
-      if (!changes) {
-        this.reporting.error(new Error('changes is undefined'));
-        return;
-      }
-      this.$.confirmRevertSubmissionDialog._populateRevertSubmissionMessage(
-        change,
-        changes
-      );
-      this._showActionDialog(this.$.confirmRevertSubmissionDialog);
-    });
+  showSubmitDialog() {
+    if (!this._canSubmitChange()) {
+      return;
+    }
+    this._showActionDialog(this.$.confirmSubmitDialog);
   }
 
   _handleActionTap(e: MouseEvent) {
@@ -1266,9 +1282,6 @@
       case ChangeActions.REVERT:
         this.showRevertDialog();
         break;
-      case ChangeActions.REVERT_SUBMISSION:
-        this.showRevertSubmissionDialog();
-        break;
       case ChangeActions.ABANDON:
         this._showActionDialog(this.$.confirmAbandonDialog);
         break;
@@ -1307,6 +1320,9 @@
       case ChangeActions.REBASE_EDIT:
         this._handleRebaseEditTap();
         break;
+      case ChangeActions.INCLUDED_IN:
+        this._handleIncludedInTap();
+        break;
       default:
         this._fireAction(
           this._prependSlash(key),
@@ -1451,9 +1467,11 @@
         );
         break;
       case RevertType.REVERT_SUBMISSION:
+        // TODO(dhruvsri): replace with this.actions.revert_submission once
+        // BE starts sending it again
         this._fireAction(
           '/revert_submission',
-          assertUIActionInfo(this.actions.revert_submission),
+          {__key: 'revert_submission', method: HttpMethod.POST} as UIActionInfo,
           false,
           {message}
         );
@@ -1463,18 +1481,6 @@
     }
   }
 
-  _handleRevertSubmissionDialogConfirm() {
-    const el = this.$.confirmRevertSubmissionDialog;
-    this.$.overlay.close();
-    el.hidden = true;
-    this._fireAction(
-      '/revert_submission',
-      assertUIActionInfo(this.actions.revert_submission),
-      false,
-      {message: el.message}
-    );
-  }
-
   _handleAbandonDialogConfirm() {
     const el = this.$.confirmAbandonDialog;
     this.$.overlay.close();
@@ -1499,6 +1505,7 @@
   }
 
   _handleDeleteConfirm() {
+    this._hideAllDialogs();
     this._fireAction(
       '/',
       assertUIActionInfo(this.actions[ChangeActions.DELETE]),
@@ -1621,7 +1628,7 @@
     return this.restApiService.getResponseObject(response).then(obj => {
       switch (action.__key) {
         case ChangeActions.REVERT: {
-          const revertChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          const revertChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
           this._waitForChangeReachable(revertChangeInfo._number)
             .then(() => this._setReviewOnRevert(revertChangeInfo._number))
             .then(() => {
@@ -1630,7 +1637,7 @@
           break;
         }
         case RevisionActions.CHERRYPICK: {
-          const cherrypickChangeInfo: ChangeInfo = (obj as unknown) as ChangeInfo;
+          const cherrypickChangeInfo: ChangeInfo = obj as unknown as ChangeInfo;
           this._waitForChangeReachable(cherrypickChangeInfo._number).then(
             () => {
               GerritNav.navigateToChange(cherrypickChangeInfo);
@@ -1649,16 +1656,10 @@
         case ChangeActions.REBASE_EDIT:
         case ChangeActions.REBASE:
         case ChangeActions.SUBMIT:
-          this.dispatchEvent(
-            new CustomEvent('reload', {
-              detail: {clearPatchset: true},
-              bubbles: false,
-              composed: true,
-            })
-          );
+          fireReload(this, true);
           break;
         case ChangeActions.REVERT_SUBMISSION: {
-          const revertSubmistionInfo = (obj as unknown) as RevertSubmissionInfo;
+          const revertSubmistionInfo = obj as unknown as RevertSubmissionInfo;
           if (
             !revertSubmistionInfo.revert_changes ||
             !revertSubmistionInfo.revert_changes.length
@@ -1672,22 +1673,12 @@
           break;
         }
         default:
-          this.dispatchEvent(
-            new CustomEvent('reload', {
-              detail: {action: action.__key, clearPatchset: true},
-              bubbles: false,
-              composed: true,
-            })
-          );
+          fireReload(this, true);
           break;
       }
     });
   }
 
-  _handleShowRevertSubmissionChangesConfirm() {
-    this._hideAllDialogs();
-  }
-
   _handleResponseError(
     action: UIActionInfo,
     response: Response | undefined | null,
@@ -1746,7 +1737,7 @@
         new Error('Properties change and changeNum must be set.')
       );
     }
-    return fetchChangeUpdates(change, this.restApiService).then(result => {
+    return this.changeService.fetchChangeUpdates(change).then(result => {
       if (!result.isLatest) {
         this.dispatchEvent(
           new CustomEvent<ShowAlertEventDetail>('show-alert', {
@@ -1755,15 +1746,7 @@
                 'Cannot set label: a newer patch has been ' +
                 'uploaded to this change.',
               action: 'Reload',
-              callback: () => {
-                this.dispatchEvent(
-                  new CustomEvent('reload', {
-                    detail: {clearPatchset: true},
-                    bubbles: false,
-                    composed: true,
-                  })
-                );
-              },
+              callback: () => fireReload(this, true),
             },
             composed: true,
             bubbles: true,
@@ -1793,10 +1776,6 @@
     });
   }
 
-  _handleAbandonTap() {
-    this._showActionDialog(this.$.confirmAbandonDialog);
-  }
-
   _handleCherrypickTap() {
     if (!this.change) {
       throw new Error('The change property must be set');
@@ -1826,12 +1805,11 @@
   }
 
   _handleDownloadTap() {
-    this.dispatchEvent(
-      new CustomEvent('download-tap', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEvent(this, 'download-tap');
+  }
+
+  _handleIncludedInTap() {
+    fireEvent(this, 'included-tap');
   }
 
   _handleDeleteTap() {
@@ -1905,8 +1883,7 @@
       UIActionInfo[],
       UIActionInfo[]
     >,
-    change?: ChangeInfo,
-    config?: ServerInfo
+    change?: ChangeInfo
   ): UIActionInfo[] {
     // Polymer 2: check for undefined
     if (
@@ -1945,15 +1922,9 @@
         if (ACTIONS_WITH_ICONS.has(action.__key)) {
           action.icon = action.__key;
         }
-        // TODO(brohlfs): Temporary hack until change 269573 is live in all
-        // backends.
-        if (action.__key === ChangeActions.READY) {
-          action.label = 'Mark as Active';
-        }
-        // End of hack
         return action;
       })
-      .filter(action => !this._shouldSkipAction(action, config));
+      .filter(action => !this._shouldSkipAction(action));
   }
 
   _getActionPriority(action: UIActionInfo) {
@@ -1992,14 +1963,8 @@
     }
   }
 
-  _shouldSkipAction(action: UIActionInfo, config?: ServerInfo) {
-    const skipActionKeys: string[] = [...SKIP_ACTION_KEYS];
-    const isAttentionSetEnabled =
-      !!config && !!config.change && config.change.enable_attention_set;
-    if (isAttentionSetEnabled) {
-      skipActionKeys.push(...SKIP_ACTION_KEYS_ATTENTION_SET);
-    }
-    return skipActionKeys.includes(action.__key);
+  _shouldSkipAction(action: UIActionInfo) {
+    return SKIP_ACTION_KEYS.includes(action.__key);
   }
 
   _computeTopLevelActions(
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 2d4ed32..d21c29f 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
@@ -33,6 +33,9 @@
       /* px because don't have the same font size */
       margin-left: 8px;
     }
+    gr-button {
+      display: block;
+    }
     #actionLoadingMessage {
       align-items: center;
       color: var(--deemphasized-text-color);
@@ -57,10 +60,8 @@
         flex-wrap: wrap;
       }
       gr-button {
-        --gr-button: {
-          padding: var(--spacing-m);
-          white-space: nowrap;
-        }
+        --gr-button-padding: var(--spacing-m);
+        white-space: nowrap;
       }
       gr-button,
       gr-dropdown {
@@ -84,23 +85,27 @@
       hidden$="[[_shouldHideActions(_topLevelActions.*, _loading)]]"
     >
       <template is="dom-repeat" items="[[_topLevelPrimaryActions]]" as="action">
-        <gr-button
-          link=""
+        <gr-tooltip-content
           title$="[[action.title]]"
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
-          data-action-key$="[[action.__key]]"
-          data-action-type$="[[action.__type]]"
-          data-label$="[[action.label]]"
-          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-          on-click="_handleActionTap"
         >
-          <iron-icon
-            class$="[[_computeHasIcon(action)]]"
-            icon$="gr-icons:[[action.icon]]"
-          ></iron-icon>
-          [[action.label]]
-        </gr-button>
+          <gr-button
+            link=""
+            data-action-key$="[[action.__key]]"
+            class$="[[action.__key]]"
+            data-action-type$="[[action.__type]]"
+            data-label$="[[action.label]]"
+            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+            on-click="_handleActionTap"
+          >
+            <iron-icon
+              class$="[[_computeHasIcon(action)]]"
+              icon$="gr-icons:[[action.icon]]"
+            ></iron-icon>
+            [[action.label]]
+          </gr-button>
+        </gr-tooltip-content>
       </template>
     </section>
     <section
@@ -112,23 +117,27 @@
         items="[[_topLevelSecondaryActions]]"
         as="action"
       >
-        <gr-button
-          link=""
+        <gr-tooltip-content
           title$="[[action.title]]"
           has-tooltip="[[_computeHasTooltip(action.title)]]"
           position-below="true"
-          data-action-key$="[[action.__key]]"
-          data-action-type$="[[action.__type]]"
-          data-label$="[[action.label]]"
-          disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-          on-click="_handleActionTap"
         >
-          <iron-icon
-            class$="[[_computeHasIcon(action)]]"
-            icon$="gr-icons:[[action.icon]]"
-          ></iron-icon>
-          [[action.label]]
-        </gr-button>
+          <gr-button
+            link=""
+            data-action-key$="[[action.__key]]"
+            class$="[[action.__key]]"
+            data-action-type$="[[action.__type]]"
+            data-label$="[[action.label]]"
+            disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
+            on-click="_handleActionTap"
+          >
+            <iron-icon
+              class$="[[_computeHasIcon(action)]]"
+              icon$="gr-icons:[[action.icon]]"
+            ></iron-icon>
+            [[action.label]]
+          </gr-button>
+        </gr-tooltip-content>
       </template>
     </section>
     <gr-button hidden$="[[!_loading]]" disabled=""
@@ -194,14 +203,6 @@
       on-cancel="_handleConfirmDialogCancel"
       hidden=""
     ></gr-confirm-revert-dialog>
-    <gr-confirm-revert-submission-dialog
-      id="confirmRevertSubmissionDialog"
-      class="confirmDialog"
-      commit-message="[[commitMessage]]"
-      on-confirm="_handleRevertSubmissionDialogConfirm"
-      on-cancel="_handleConfirmDialogCancel"
-      hidden=""
-    ></gr-confirm-revert-submission-dialog>
     <gr-confirm-abandon-dialog
       id="confirmAbandonDialog"
       class="confirmDialog"
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
deleted file mode 100644
index c193e60..0000000
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.js
+++ /dev/null
@@ -1,2186 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-change-actions.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
-import {
-  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');
-
-const CHERRY_PICK_TYPES = {
-  SINGLE_CHANGE: 1,
-  TOPIC: 2,
-};
-// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
-suite('gr-change-actions tests', () => {
-  let element;
-
-  suite('basic tests', () => {
-    setup(() => {
-      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({
-            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'));
-      });
-      stubRestApi('getProjectConfig').returns(Promise.resolve({}));
-
-      sinon.stub(getPluginLoader(), 'awaitPluginsLoaded')
-          .returns(Promise.resolve());
-
-      element = basicFixture.instantiate();
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-      element.actions = {
-        '/': {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        },
-      };
-      element.account = {
-        _account_id: 123,
-      };
-      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-
-      return element.reload();
-    });
-
-    test('show-revision-actions event should fire', done => {
-      const spy = sinon.spy(element, '_sendShowRevisionActions');
-      element.reload();
-      flush(() => {
-        assert.isTrue(spy.called);
-        done();
-      });
-    });
-
-    test('primary and secondary actions split properly', () => {
-      // Submit should be the only primary action.
-      assert.equal(element._topLevelPrimaryActions.length, 1);
-      assert.equal(element._topLevelPrimaryActions[0].label, 'Submit');
-      assert.equal(element._topLevelSecondaryActions.length,
-          element._topLevelActions.length - 1);
-    });
-
-    test('revert submission action is skipped', () => {
-      assert.equal(element._allActionValues.filter(action =>
-        action.__key === 'submit').length, 1);
-      assert.equal(element._allActionValues.filter(action =>
-        action.__key === 'revert_submission').length, 0);
-    });
-
-    test('_shouldHideActions', () => {
-      assert.isTrue(element._shouldHideActions(undefined, true));
-      assert.isTrue(element._shouldHideActions({base: {}}, false));
-      assert.isFalse(element._shouldHideActions({base: ['test']}, false));
-    });
-
-    test('plugin revision actions', done => {
-      const stub = stubRestApi('getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.revisionActions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.revisionActions['plugin~action']);
-      flush(() => {
-        assert.isTrue(stub.calledWith(
-            element.changeNum, element.latestPatchNum, '/plugin~action'));
-        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
-        done();
-      });
-    });
-
-    test('plugin change actions', async () => {
-      const stub = stubRestApi('getChangeActionURL').returns(
-          Promise.resolve('the-url'));
-      element.actions = {
-        'plugin~action': {},
-      };
-      assert.isOk(element.actions['plugin~action']);
-      await flush();
-      assert.isTrue(stub.calledWith(
-          element.changeNum, undefined, '/plugin~action'));
-      assert.equal(element.actions['plugin~action'].__url, 'the-url');
-    });
-
-    test('not supported actions are filtered out', () => {
-      element.revisionActions = {followup: {}};
-      assert.equal(element.querySelectorAll(
-          'section gr-button[data-action-type="revision"]').length, 0);
-    });
-
-    test('getActionDetails', () => {
-      element.revisionActions = {
-        'plugin~action': {},
-        ...element.revisionActions,
-      };
-      assert.isUndefined(element.getActionDetails('rubbish'));
-      assert.strictEqual(element.revisionActions['plugin~action'],
-          element.getActionDetails('plugin~action'));
-      assert.strictEqual(element.revisionActions['rebase'],
-          element.getActionDetails('rebase'));
-    });
-
-    test('hide revision action', done => {
-      flush(() => {
-        const buttonEl = element.shadowRoot
-            .querySelector('[data-action-key="submit"]');
-        assert.isOk(buttonEl);
-        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(() => {
-          const buttonEl = element.shadowRoot
-              .querySelector('[data-action-key="submit"]');
-          assert.isNotOk(buttonEl);
-
-          element.setActionHidden(element.ActionType.REVISION,
-              element.RevisionActions.SUBMIT, false);
-          flush(() => {
-            const buttonEl = element.shadowRoot
-                .querySelector('[data-action-key="submit"]');
-            assert.isOk(buttonEl);
-            assert.isFalse(buttonEl.hasAttribute('hidden'));
-            done();
-          });
-        });
-      });
-    });
-
-    test('buttons exist', done => {
-      element._loading = false;
-      flush(() => {
-        const buttonEls = dom(element.root)
-            .querySelectorAll('gr-button');
-        const menuItems = element.$.moreActions.items;
-
-        // Total button number is one greater than the number of total actions
-        // due to the existence of the overflow menu trigger.
-        assert.equal(buttonEls.length + menuItems.length,
-            element._allActionValues.length + 1);
-        assert.isFalse(element.hidden);
-        done();
-      });
-    });
-
-    test('delete buttons have explicit labels', done => {
-      flush(() => {
-        const deleteItems = element.$.moreActions.items
-            .filter(item => item.id.startsWith('delete'));
-        assert.equal(deleteItems.length, 1);
-        assert.notEqual(deleteItems[0].name);
-        assert.equal(deleteItems[0].name, 'Delete change');
-        done();
-      });
-    });
-
-    test('get revision object from change', () => {
-      const revObj = {_number: 2, foo: 'bar'};
-      const change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: revObj,
-        },
-      };
-      assert.deepEqual(element._getRevision(change, 2), revObj);
-    });
-
-    test('_actionComparator sort order', () => {
-      const actions = [
-        {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc-ro', __type: 'revision'},
-        {label: 'abc', __type: 'change'},
-        {label: 'def', __type: 'change'},
-        {label: 'def-p', __type: 'change', __primary: true},
-      ];
-
-      const result = actions.slice();
-      result.reverse();
-      result.sort(element._actionComparator.bind(element));
-      assert.deepEqual(result, actions);
-    });
-
-    test('submit change', () => {
-      const showSpy = sinon.spy(element, '_showActionDialog');
-      stubRestApi('getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="submit"]');
-      assert.ok(submitButton);
-      MockInteractions.tap(submitButton);
-
-      flush();
-      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
-    });
-
-    test('submit change, tap on icon', done => {
-      sinon.stub(element.$.confirmSubmitDialog, 'resetFocus').callsFake( done);
-      stubRestApi('getFromProjectLookup')
-          .returns(Promise.resolve('test'));
-      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
-      element.change = {
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {_number: 2},
-        },
-      };
-      element.latestPatchNum = '2';
-
-      const submitIcon =
-          element.shadowRoot
-              .querySelector('gr-button[data-action-key="submit"] iron-icon');
-      assert.ok(submitIcon);
-      MockInteractions.tap(submitIcon);
-    });
-
-    test('_handleSubmitConfirm', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(true);
-      element._handleSubmitConfirm();
-      assert.isTrue(fireStub.calledOnce);
-      assert.deepEqual(fireStub.lastCall.args,
-          ['/submit', element.revisionActions.submit, true]);
-    });
-
-    test('_handleSubmitConfirm when not able to submit', () => {
-      const fireStub = sinon.stub(element, '_fireAction');
-      sinon.stub(element, '_canSubmitChange').returns(false);
-      element._handleSubmitConfirm();
-      assert.isFalse(fireStub.called);
-    });
-
-    test('submit change with plugin hook', done => {
-      sinon.stub(element, '_canSubmitChange').callsFake(
-          () => false);
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      flush(() => {
-        const submitButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="submit"]');
-        assert.ok(submitButton);
-        MockInteractions.tap(submitButton);
-        assert.equal(fireActionStub.callCount, 0);
-
-        done();
-      });
-    });
-
-    test('chain state', () => {
-      assert.equal(element._hasKnownChainState, false);
-      element.hasParent = true;
-      assert.equal(element._hasKnownChainState, true);
-      element.hasParent = false;
-    });
-
-    test('_calculateDisabled', () => {
-      let hasKnownChainState = false;
-      const action = {__key: 'rebase', enabled: true};
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), true);
-
-      action.__key = 'delete';
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.__key = 'rebase';
-      hasKnownChainState = true;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-
-      action.enabled = false;
-      assert.equal(
-          element._calculateDisabled(action, hasKnownChainState), false);
-    });
-
-    test('rebase change', done => {
-      const fireActionStub = sinon.stub(element, '_fireAction');
-      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      flush(() => {
-        const rebaseButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
-        const rebaseAction = {
-          __key: 'rebase',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Rebase',
-          method: 'POST',
-          title: 'Rebase onto tip of branch or parent change',
-        };
-        assert.isTrue(fetchChangesStub.called);
-        element._handleRebaseConfirm({detail: {base: '1234'}});
-        assert.deepEqual(fireActionStub.lastCall.args,
-            ['/rebase', rebaseAction, true, {base: '1234'}]);
-        done();
-      });
-    });
-
-    test('rebase change fires reload event', done => {
-      const eventStub = sinon.stub(element, 'dispatchEvent');
-      stubRestApi('getResponseObject').returns(
-          Promise.resolve({}));
-      element._handleResponse({__key: 'rebase'}, {});
-      flush(() => {
-        assert.isTrue(eventStub.called);
-        assert.equal(eventStub.lastCall.args[0].type, 'reload');
-        done();
-      });
-    });
-
-    test(`rebase dialog gets recent changes each time it's opened`, done => {
-      const fetchChangesStub = sinon.stub(element.$.confirmRebase,
-          'fetchRecentChanges').returns(Promise.resolve([]));
-      element._hasKnownChainState = true;
-      const rebaseButton = element.shadowRoot
-          .querySelector('gr-button[data-action-key="rebase"]');
-      MockInteractions.tap(rebaseButton);
-      assert.isTrue(fetchChangesStub.calledOnce);
-
-      flush(() => {
-        element.$.confirmRebase.dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-        MockInteractions.tap(rebaseButton);
-        assert.isTrue(fetchChangesStub.calledTwice);
-        done();
-      });
-    });
-
-    test('two dialogs are not shown at the same time', async () => {
-      element._hasKnownChainState = true;
-      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', () => {
-      sinon.spy(element, '_handleHideBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-opened', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleHideBackgroundContent.called);
-      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('fullscreen-overlay-closed shows content', () => {
-      sinon.spy(element, '_handleShowBackgroundContent');
-      element.$.overlay.dispatchEvent(
-          new CustomEvent('fullscreen-overlay-closed', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._handleShowBackgroundContent.called);
-      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
-    });
-
-    test('_setReviewOnRevert', () => {
-      const review = {labels: {'Foo': 1, 'Bar-Baz': -2}};
-      const changeId = 1234;
-      sinon.stub(element.jsAPI, 'getReviewPostRevert').returns(review);
-      const saveStub = stubRestApi('saveChangeReview')
-          .returns(Promise.resolve());
-      return element._setReviewOnRevert(changeId).then(() => {
-        assert.isTrue(saveStub.calledOnce);
-        assert.equal(saveStub.lastCall.args[0], changeId);
-        assert.deepEqual(saveStub.lastCall.args[2], review);
-      });
-    });
-
-    suite('change edits', () => {
-      test('disableEdit', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        element.set('disableEdit', true);
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('shows confirm dialog for delete edit', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        element._handleDeleteEditTap();
-        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteEditDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.equal(fireActionStub.lastCall.args[0], '/edit');
-      });
-
-      test('hide publishEdit and rebaseEdit if change is not open', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'MERGED'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-      });
-
-      test('edit patchset is loaded, needs rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = false;
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit patchset is loaded, does not need rebase', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', true);
-        element.change = {status: 'NEW'};
-        element.editBasedOnCurrentPatchSet = true;
-        flush();
-
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit mode is loaded, no edit patchset', () => {
-        element.set('editMode', true);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('normal patch set', () => {
-        element.set('editMode', false);
-        element.set('editPatchsetLoaded', false);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="publishEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="rebaseEdit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="deleteEdit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-      });
-
-      test('edit action', done => {
-        element.addEventListener('edit-tap', () => { done(); });
-        element.set('editMode', true);
-        element.change = {status: 'NEW'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        assert.isOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="stopEdit"]'));
-        element.change = {status: 'MERGED'};
-        flush();
-
-        assert.isNotOk(element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]'));
-        element.change = {status: 'NEW'};
-        element.set('editMode', false);
-        flush();
-
-        const editButton = element.shadowRoot
-            .querySelector('gr-button[data-action-key="edit"]');
-        assert.isOk(editButton);
-        MockInteractions.tap(editButton);
-      });
-    });
-
-    suite('cherry-pick', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        sinon.stub(window, 'alert');
-      });
-
-      test('works', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: '',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmCherrypick.branch = 'master';
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConfirm({
-          detail: {
-            branch: 'master',
-            type: CHERRY_PICK_TYPES.SINGLE_CHANGE,
-          },
-        });
-
-        assert.equal(element.$.confirmCherrypick.shadowRoot.
-            querySelector('#messageInput').value, 'foo message');
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: false,
-          },
-        ]);
-      });
-
-      test('cherry pick even with conflicts', () => {
-        element._handleCherrypickTap();
-        const action = {
-          __key: 'cherrypick',
-          __type: 'revision',
-          __primary: false,
-          enabled: true,
-          label: 'Cherry pick',
-          method: 'POST',
-          title: 'Cherry pick change to a different branch',
-        };
-
-        element.$.confirmCherrypick.branch = 'master';
-
-        // Add attributes that are used to determine the message.
-        element.$.confirmCherrypick.commitMessage = 'foo message';
-        element.$.confirmCherrypick.changeStatus = 'OPEN';
-        element.$.confirmCherrypick.commitNum = '123';
-
-        element._handleCherrypickConflictConfirm();
-
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/cherrypick', action, true, {
-            destination: 'master',
-            base: null,
-            message: 'foo message',
-            allow_conflicts: true,
-          },
-        ]);
-      });
-
-      test('branch name cleared when re-open cherrypick', () => {
-        const emptyBranchName = '';
-        element.$.confirmCherrypick.branch = 'master';
-
-        element._handleCherrypickTap();
-        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
-      });
-
-      suite('cherry pick topics', () => {
-        const changes = [
-          {
-            change_id: '12345678901234', topic: 'T', subject: 'random',
-            project: 'A', status: 'MERGED',
-          },
-          {
-            change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-            project: 'B', status: 'NEW',
-          },
-        ];
-        setup(done => {
-          stubRestApi('getChanges')
-              .returns(Promise.resolve(changes));
-          element._handleCherrypickTap();
-          flush(() => {
-            const radioButtons = element.$.confirmCherrypick.shadowRoot.
-                querySelectorAll(`input[name='cherryPickOptions']`);
-            assert.equal(radioButtons.length, 2);
-            MockInteractions.tap(radioButtons[1]);
-            flush(() => {
-              done();
-            });
-          });
-        });
-
-        test('cherry pick topic dialog is rendered', done => {
-          const dialog = element.$.confirmCherrypick;
-          flush(() => {
-            const changesTable = dialog.shadowRoot.querySelector('table');
-            const headers = Array.from(changesTable.querySelectorAll('th'));
-            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++) {
-              assert.equal(headings[i].trim(), expectedHeadings[i]);
-            }
-            const changeRows = changesTable.querySelectorAll('tbody > tr');
-            const change = Array.from(changeRows[0].querySelectorAll('td'))
-                .map(e => e.innerText);
-            const expectedChange = ['', '1234567890', 'MERGED', 'random', 'A',
-              'NOT STARTED', ''];
-            for (let i = 0; i < change.length; i++) {
-              assert.equal(change[i].trim(), expectedChange[i]);
-            }
-            done();
-          });
-        });
-
-        test('changes with duplicate project show an error', done => {
-          const dialog = element.$.confirmCherrypick;
-          const error = dialog.shadowRoot.querySelector('.error-message');
-          assert.equal(error.innerText, '');
-          dialog.updateChanges([
-            {
-              change_id: '12345678901234', topic: 'T', subject: 'random',
-              project: 'A',
-            },
-            {
-              change_id: '23456', topic: 'T', subject: 'a'.repeat(100),
-              project: 'A',
-            },
-          ]);
-          flush(() => {
-            assert.equal(error.innerText, 'Two changes cannot be of the same'
-             + ' project');
-            done();
-          });
-        });
-      });
-    });
-
-    suite('move change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        sinon.stub(window, 'alert');
-        element.actions = {
-          move: {
-            method: 'POST',
-            label: 'Move',
-            title: 'Move the change',
-            enabled: true,
-          },
-        };
-      });
-
-      test('works', () => {
-        element._handleMoveTap();
-
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 0);
-
-        element.$.confirmMove.branch = 'master';
-        element._handleMoveConfirm();
-        assert.equal(fireActionStub.callCount, 1);
-      });
-
-      test('branch name cleared when re-open move', () => {
-        const emptyBranchName = '';
-        element.$.confirmMove.branch = 'master';
-
-        element._handleMoveTap();
-        assert.equal(element.$.confirmMove.branch, emptyBranchName);
-      });
-    });
-
-    test('custom actions', done => {
-      // Add a button with the same key as a server-based one to ensure
-      // collisions are taken care of.
-      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', e => {
-        assert.equal(e.detail.node.getAttribute('data-action-key'), key);
-        element.removeActionButton(key);
-        flush(() => {
-          assert.notOk(element.shadowRoot
-              .querySelector('[data-action-key="' + key + '"]'));
-          done();
-        });
-      });
-      flush(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('[data-action-key="' + key + '"]'));
-      });
-    });
-
-    test('_setLoadingOnButtonWithKey top-level', () => {
-      const key = 'rebase';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Rebasing...');
-
-      const button = element.shadowRoot
-          .querySelector('[data-action-key="' + key + '"]');
-      assert.isTrue(button.hasAttribute('loading'));
-      assert.isTrue(button.disabled);
-
-      assert.isOk(cleanup);
-      assert.isFunction(cleanup);
-      cleanup();
-
-      assert.isFalse(button.hasAttribute('loading'));
-      assert.isFalse(button.disabled);
-      assert.isNotOk(element._actionLoadingMessage);
-    });
-
-    test('_setLoadingOnButtonWithKey overflow menu', () => {
-      const key = 'cherrypick';
-      const type = 'revision';
-      const cleanup = element._setLoadingOnButtonWithKey(type, key);
-      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
-      assert.include(element._disabledMenuActions, 'cherrypick');
-      assert.isFunction(cleanup);
-
-      cleanup();
-
-      assert.notOk(element._actionLoadingMessage);
-      assert.notInclude(element._disabledMenuActions, 'cherrypick');
-    });
-
-    suite('abandon change', () => {
-      let alertStub;
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
-        element.actions = {
-          abandon: {
-            method: 'POST',
-            label: 'Abandon',
-            title: 'Abandon the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('abandon change with message', done => {
-        const newAbandonMsg = 'Test Abandon Message';
-        element.$.confirmAbandonDialog.message = newAbandonMsg;
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
-          done();
-        });
-      });
-
-      test('abandon change with no message', done => {
-        flush(() => {
-          const abandonButton =
-              element.shadowRoot
-                  .querySelector('gr-button[data-action-key="abandon"]');
-          MockInteractions.tap(abandonButton);
-
-          assert.isUndefined(element.$.confirmAbandonDialog.message);
-          done();
-        });
-      });
-
-      test('works', () => {
-        element.$.confirmAbandonDialog.message = 'original message';
-        const restoreButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key="abandon"]');
-        MockInteractions.tap(restoreButton);
-
-        element.$.confirmAbandonDialog.message = 'foo message';
-        element._handleAbandonDialogConfirm();
-        assert.notOk(alertStub.called);
-
-        const action = {
-          __key: 'abandon',
-          __type: 'change',
-          __primary: false,
-          enabled: true,
-          label: 'Abandon',
-          method: 'POST',
-          title: 'Abandon the change',
-        };
-        assert.deepEqual(fireActionStub.lastCall.args, [
-          '/abandon', action, false, {
-            message: 'foo message',
-          }]);
-      });
-    });
-
-    suite('revert change', () => {
-      let fireActionStub;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.commitMessage = 'random commit message';
-        element.change.current_revision = 'abcdef';
-        element.actions = {
-          revert: {
-            method: 'POST',
-            label: 'Revert',
-            title: 'Revert the change',
-            enabled: true,
-          },
-        };
-        return element.reload();
-      });
-
-      test('revert change with plugin hook', done => {
-        const newRevertMsg = 'Modified revert msg';
-        sinon.stub(element.$.confirmRevertDialog, '_modifyRevertMsg').callsFake(
-            () => newRevertMsg);
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        stubRestApi('getChanges')
-            .returns(Promise.resolve([
-              {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-            ]));
-        sinon.stub(element.$.confirmRevertDialog,
-            '_populateRevertSubmissionMessage').callsFake(() => 'original msg');
-        flush(() => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
-            done();
-          });
-        });
-      });
-
-      suite('revert change submitted together', () => {
-        let getChangesStub;
-        setup(() => {
-          element.change = {
-            submission_id: '199 0',
-            current_revision: '2000',
-          };
-          getChangesStub = stubRestApi('getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-              ]));
-        });
-
-        test('confirm revert dialog shows both options', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const revertSingleChangeLabel = confirmRevertDialog
-                .shadowRoot.querySelector('.revertSingleChange');
-            const revertSubmissionLabel = confirmRevertDialog.
-                shadowRoot.querySelector('.revertSubmission');
-            assert(revertSingleChangeLabel.innerText.trim() ===
-                'Revert single change');
-            assert(revertSubmissionLabel.innerText.trim() ===
-                'Revert entire submission (2 Changes)');
-            let expectedMsg = 'Revert submission 199 0' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890:random' + '\n' +
-              '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            assert.equal(confirmRevertDialog._message, expectedMsg);
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              expectedMsg = 'Revert "random commit message"\n\nThis reverts '
-               + 'commit 2000.\n\nReason'
-               + ' for revert: <INSERT REASONING HERE>\n';
-              assert.equal(confirmRevertDialog._message, expectedMsg);
-              done();
-            });
-          });
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('message modification is retained on switching', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            const revertSubmissionMsg = 'Revert submission 199 0' + '\n\n' +
-            'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-            'Reverted Changes:' + '\n' +
-            '1234567890:random' + '\n' +
-            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-            '\n';
-            const singleChangeMsg =
-            'Revert "random commit message"\n\nThis reverts '
-              + 'commit 2000.\n\nReason'
-              + ' for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
-            const newRevertMsg = revertSubmissionMsg + 'random';
-            const newSingleChangeMsg = singleChangeMsg + 'random';
-            confirmRevertDialog._message = newRevertMsg;
-            MockInteractions.tap(radioInputs[0]);
-            flush(() => {
-              assert.equal(confirmRevertDialog._message, singleChangeMsg);
-              confirmRevertDialog._message = newSingleChangeMsg;
-              MockInteractions.tap(radioInputs[1]);
-              flush(() => {
-                assert.equal(confirmRevertDialog._message, newRevertMsg);
-                MockInteractions.tap(radioInputs[0]);
-                flush(() => {
-                  assert.equal(
-                      confirmRevertDialog._message,
-                      newSingleChangeMsg
-                  );
-                  done();
-                });
-              });
-            });
-          });
-        });
-      });
-
-      suite('revert single change', () => {
-        setup(() => {
-          element.change = {
-            submission_id: '199',
-            current_revision: '2000',
-          };
-          stubRestApi('getChanges')
-              .returns(Promise.resolve([
-                {change_id: '12345678901234', topic: 'T', subject: 'random'},
-              ]));
-        });
-
-        test('submit fails if message is not edited', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          const confirmRevertDialog = element.$.confirmRevertDialog;
-          MockInteractions.tap(revertButton);
-          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
-          flush(() => {
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.isTrue(confirmRevertDialog._showErrorMessage);
-              assert.isFalse(fireStub.called);
-              done();
-            });
-          });
-        });
-
-        test('confirm revert dialog shows no radio button', done => {
-          const revertButton = element.shadowRoot
-              .querySelector('gr-button[data-action-key="revert"]');
-          MockInteractions.tap(revertButton);
-          flush(() => {
-            const confirmRevertDialog = element.$.confirmRevertDialog;
-            const radioInputs = confirmRevertDialog.shadowRoot
-                .querySelectorAll('input[name="revertOptions"]');
-            assert.equal(radioInputs.length, 0);
-            const msg = 'Revert "random commit message"\n\n'
-              + 'This reverts commit 2000.\n\nReason '
-              + 'for revert: <INSERT REASONING HERE>\n';
-            assert.equal(confirmRevertDialog._message, msg);
-            const editedMsg = msg + 'hello';
-            confirmRevertDialog._message += 'hello';
-            const confirmButton = element.$.confirmRevertDialog.shadowRoot
-                .querySelector('gr-dialog')
-                .shadowRoot.querySelector('#confirm');
-            MockInteractions.tap(confirmButton);
-            flush(() => {
-              assert.equal(fireActionStub.getCall(0).args[0], '/revert');
-              assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
-              assert.equal(fireActionStub.getCall(0).args[3].message,
-                  editedMsg);
-              done();
-            });
-          });
-        });
-      });
-    });
-
-    suite('mark change private', () => {
-      setup(() => {
-        const privateAction = {
-          __key: 'private',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Mark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          private: privateAction,
-        };
-
-        element.change.is_private = false;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the mark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          done();
-        });
-      });
-
-      test('private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          element.setActionOverflow('change', 'private', false);
-          flush();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private-change"]'));
-          done();
-        });
-      });
-    });
-
-    suite('unmark private change', () => {
-      setup(() => {
-        const unmarkPrivateAction = {
-          __key: 'private.delete',
-          __type: 'change',
-          __primary: false,
-          method: 'POST',
-          label: 'Unmark private',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          'private.delete': unmarkPrivateAction,
-        };
-
-        element.change.is_private = true;
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        return element.reload();
-      });
-
-      test('make sure the unmark private change button is not outside of the ' +
-           'overflow menu', done => {
-        flush(() => {
-          assert.isNotOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          done();
-        });
-      });
-
-      test('unmark the private change', done => {
-        flush(() => {
-          assert.isOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          element.setActionOverflow('change', 'private.delete', false);
-          flush();
-          assert.isOk(element.shadowRoot
-              .querySelector('[data-action-key="private.delete"]'));
-          assert.isNotOk(
-              element.$.moreActions.shadowRoot
-                  .querySelector('span[data-id="private.delete-change"]')
-          );
-          done();
-        });
-      });
-    });
-
-    suite('delete change', () => {
-      let fireActionStub;
-      let deleteAction;
-
-      setup(() => {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        element.change = {
-          current_revision: 'abc1234',
-        };
-        deleteAction = {
-          method: 'DELETE',
-          label: 'Delete Change',
-          title: 'Delete change X_X',
-          enabled: true,
-        };
-        element.actions = {
-          '/': deleteAction,
-        };
-      });
-
-      test('does not delete on action', () => {
-        element._handleDeleteTap();
-        assert.isFalse(fireActionStub.called);
-      });
-
-      test('shows confirm dialog', () => {
-        element._handleDeleteTap();
-        assert.isFalse(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button[primary]'));
-        flush();
-        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
-      });
-
-      test('hides delete confirm on cancel', () => {
-        element._handleDeleteTap();
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('#confirmDeleteDialog')
-                .shadowRoot
-                .querySelector('gr-button:not([primary])'));
-        flush();
-        assert.isTrue(element.shadowRoot
-            .querySelector('#confirmDeleteDialog').hidden);
-        assert.isFalse(fireActionStub.called);
-      });
-    });
-
-    suite('ignore change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const IgnoreAction = {
-          __key: 'ignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Ignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          ignore: IgnoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('make sure the ignore button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="ignore"]'));
-          });
-
-      test('ignoring change', () => {
-        assert.isOk(element.$.moreActions.shadowRoot
-            .querySelector('span[data-id="ignore-change"]'));
-        element.setActionOverflow('change', 'ignore', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="ignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="ignore-change"]'));
-      });
-    });
-
-    suite('unignore change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const UnignoreAction = {
-          __key: 'unignore',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Unignore',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unignore: UnignoreAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unignore button is not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-      });
-
-      test('unignoring change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-        element.setActionOverflow('change', 'unignore', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unignore"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unignore-change"]'));
-      });
-    });
-
-    suite('reviewed change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const ReviewedAction = {
-          __key: 'reviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark reviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          reviewed: ReviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('action is enabled', () => {
-        assert.equal(element._allActionValues.filter(action =>
-          action.__key === 'reviewed').length, 1);
-      });
-
-      test('action is skipped when attention set is enabled', () => {
-        element._config = {
-          change: {enable_attention_set: true},
-        };
-        assert.equal(element._allActionValues.filter(action =>
-          action.__key === 'reviewed').length, 0);
-      });
-
-      test('make sure the reviewed button is not outside of the overflow menu',
-          () => {
-            assert.isNotOk(element.shadowRoot
-                .querySelector('[data-action-key="reviewed"]'));
-          });
-
-      test('reviewing change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-        element.setActionOverflow('change', 'reviewed', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="reviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="reviewed-change"]'));
-      });
-    });
-
-    suite('unreviewed change', () => {
-      setup(done => {
-        sinon.stub(element, '_fireAction');
-
-        const UnreviewedAction = {
-          __key: 'unreviewed',
-          __type: 'change',
-          __primary: false,
-          method: 'PUT',
-          label: 'Mark unreviewed',
-          title: 'Working...',
-          enabled: true,
-        };
-
-        element.actions = {
-          unreviewed: UnreviewedAction,
-        };
-
-        element.changeNum = '2';
-        element.latestPatchNum = '2';
-
-        element.reload().then(() => { flush(done); });
-      });
-
-      test('unreviewed button not outside of the overflow menu', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-      });
-
-      test('unreviewed change', () => {
-        assert.isOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-        element.setActionOverflow('change', 'unreviewed', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="unreviewed"]'));
-        assert.isNotOk(
-            element.$.moreActions.shadowRoot
-                .querySelector('span[data-id="unreviewed-change"]'));
-      });
-    });
-
-    suite('quick approve', () => {
-      setup(() => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                '-1': '',
-                ' 0': '',
-                '+1': '',
-              },
-            },
-          },
-          permitted_labels: {
-            foo: ['-1', ' 0', '+1'],
-          },
-        };
-        flush();
-      });
-
-      test('added when can approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-      });
-
-      test('hide quick approve', () => {
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNotNull(approveButton);
-        assert.isFalse(element._hideQuickApproveAction);
-
-        // Assert approve button gets removed from list of buttons.
-        element.hideQuickApproveAction();
-        flush();
-        const approveButtonUpdated =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButtonUpdated);
-        assert.isTrue(element._hideQuickApproveAction);
-      });
-
-      test('is first in list of secondary actions', () => {
-        const approveButton = element.$.secondaryActions
-            .querySelector('gr-button');
-        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',
-          labels: {
-            foo: {
-              approved: {},
-              values: {},
-            },
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('not added when label not permitted', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {values: {}},
-          },
-          permitted_labels: {
-            bar: [],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approves when tapped', () => {
-        const fireActionStub = sinon.stub(element, '_fireAction');
-        MockInteractions.tap(
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']'));
-        flush();
-        assert.isTrue(fireActionStub.called);
-        assert.isTrue(fireActionStub.calledWith('/review'));
-        const payload = fireActionStub.lastCall.args[3];
-        assert.deepEqual(payload.labels, {foo: 1});
-      });
-
-      test('not added when multiple labels are required without code review',
-          () => {
-            element.change = {
-              current_revision: 'abc1234',
-              labels: {
-                foo: {values: {}},
-                bar: {values: {}},
-              },
-              permitted_labels: {
-                foo: [' 0', '+1'],
-                bar: [' 0', '+1', '+2'],
-              },
-            };
-            flush();
-            const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-            assert.isNull(approveButton);
-          });
-
-      test('code review shown with multiple missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            'foo': {values: {}},
-            'bar': {values: {}},
-            'Code-Review': {
-              approved: {},
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            'foo': [' 0', '+1'],
-            'bar': [' 0', '+1', '+2'],
-            'Code-Review': [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isOk(approveButton);
-      });
-
-      test('button label for missing approval', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            foo: {
-              values: {
-                ' 0': '',
-                '+1': '',
-              },
-            },
-            bar: {approved: {}, values: {}},
-          },
-          permitted_labels: {
-            foo: [' 0', '+1'],
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
-      });
-
-      test('no quick approve if score is not maximal for a label', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .querySelector('gr-button[data-action-key=\'review\']');
-        assert.isNull(approveButton);
-      });
-
-      test('approving label with a non-max score', () => {
-        element.change = {
-          current_revision: 'abc1234',
-          labels: {
-            bar: {
-              value: 1,
-              values: {
-                ' 0': '',
-                '+1': '',
-                '+2': '',
-              },
-            },
-          },
-          permitted_labels: {
-            bar: [' 0', '+1', '+2'],
-          },
-        };
-        flush();
-        const approveButton =
-            element.shadowRoot
-                .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', () => {
-      const handler = sinon.stub();
-      element.addEventListener('download-tap', handler);
-      assert.ok(element.revisionActions.download);
-      element._handleDownloadTap();
-      flush();
-
-      assert.isTrue(handler.called);
-    });
-
-    test('changing changeNum or patchNum does not reload', () => {
-      const reloadStub = sinon.stub(element, 'reload');
-      element.changeNum = 123;
-      assert.isFalse(reloadStub.called);
-      element.latestPatchNum = 456;
-      assert.isFalse(reloadStub.called);
-    });
-
-    test('_toSentenceCase', () => {
-      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
-      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
-      assert.equal(element._toSentenceCase('b'), 'B');
-      assert.equal(element._toSentenceCase(''), '');
-      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
-    });
-
-    suite('setActionOverflow', () => {
-      test('move action from overflow', () => {
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-        element.setActionOverflow('revision', 'cherrypick', false);
-        flush();
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="cherrypick"]'));
-        assert.notEqual(
-            element.$.moreActions.items[0].id, 'cherrypick-revision');
-      });
-
-      test('move action to overflow', () => {
-        assert.isOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        element.setActionOverflow('revision', 'submit', true);
-        flush();
-        assert.isNotOk(element.shadowRoot
-            .querySelector('[data-action-key="submit"]'));
-        assert.strictEqual(
-            element.$.moreActions.items[3].id, 'submit-revision');
-      });
-
-      suite('_waitForChangeReachable', () => {
-        let clock;
-        setup(() => {
-          clock = sinon.useFakeTimers();
-        });
-
-        const makeGetChange = numTries => () => {
-          if (numTries === 1) {
-            return Promise.resolve({_number: 123});
-          } else {
-            numTries--;
-            return Promise.resolve(undefined);
-          }
-        };
-
-        const tickAndFlush = async repetitions => {
-          for (let i = 1; i <= repetitions; i++) {
-            clock.tick(1000);
-            await flush();
-          }
-        };
-
-        test('succeed', async () => {
-          stubRestApi('getChange').callsFake(makeGetChange(5));
-          const promise = element._waitForChangeReachable(123);
-          tickAndFlush(5);
-          const success = await promise;
-          assert.isTrue(success);
-        });
-
-        test('fail', async () => {
-          stubRestApi('getChange').callsFake(makeGetChange(6));
-          const promise = element._waitForChangeReachable(123);
-          tickAndFlush(6);
-          const success = await promise;
-          assert.isFalse(success);
-        });
-      });
-    });
-
-    suite('_send', () => {
-      let cleanup;
-      let payload;
-      let onShowError;
-      let onShowAlert;
-      let getResponseObjectStub;
-
-      setup(() => {
-        cleanup = sinon.stub();
-        element.changeNum = 42;
-        element.change._number = 42;
-        element.latestPatchNum = 12;
-        element.change = {
-          ...createChange(),
-          revisions: createRevisions(element.latestPatchNum),
-          messages: createChangeMessages(1),
-        };
-        payload = {foo: 'bar'};
-
-        onShowError = sinon.stub();
-        element.addEventListener('show-error', onShowError);
-        onShowAlert = sinon.stub();
-        element.addEventListener('show-alert', onShowAlert);
-      });
-
-      suite('happy path', () => {
-        let sendStub;
-        setup(() => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // element has latest info
-                revisions: createRevisions(element.latestPatchNum),
-                messages: createChangeMessages(1),
-              }));
-          sendStub = stubRestApi('executeChangeAction')
-              .returns(Promise.resolve({}));
-          getResponseObjectStub = stubRestApi(
-              'getResponseObject');
-          sinon.stub(GerritNav,
-              'navigateToChange').returns(Promise.resolve(true));
-        });
-
-        test('change action', async () => {
-          await element._send('DELETE', payload, '/endpoint', false, cleanup);
-          assert.isFalse(onShowError.called);
-          assert.isTrue(cleanup.calledOnce);
-          assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-              undefined, payload));
-        });
-
-        suite('show revert submission dialog', () => {
-          setup(() => {
-            element.change.submission_id = '199';
-            element.change.current_revision = '2000';
-            stubRestApi('getChanges')
-                .returns(Promise.resolve([
-                  {change_id: '12345678901234', topic: 'T', subject: 'random'},
-                  {change_id: '23456', topic: 'T', subject: 'a'.repeat(100)},
-                ]));
-          });
-
-          test('revert submission shows submissionId', done => {
-            const expectedMsg = 'Revert submission 199' + '\n\n' +
-              'Reason for revert: <INSERT REASONING HERE>' + '\n' +
-              'Reverted Changes:' + '\n' +
-              '1234567890: random' + '\n' +
-              '23456: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
-              '\n';
-            const modifiedMsg = expectedMsg + 'abcd';
-            sinon.stub(element.$.confirmRevertSubmissionDialog,
-                '_modifyRevertSubmissionMsg').returns(modifiedMsg);
-            element.showRevertSubmissionDialog();
-            flush(() => {
-              const msg = element.$.confirmRevertSubmissionDialog.message;
-              assert.equal(msg, modifiedMsg);
-              done();
-            });
-          });
-        });
-
-        suite('single changes revert', () => {
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345},
-                ]}));
-            navigateToSearchQueryStub = sinon.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission single change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).
-                  then(() => {
-                    assert.isTrue(navigateToSearchQueryStub.called);
-                    done();
-                  });
-            });
-          });
-        });
-
-        suite('multiple changes revert', () => {
-          let showActionDialogStub;
-          let navigateToSearchQueryStub;
-          setup(() => {
-            getResponseObjectStub
-                .returns(Promise.resolve({revert_changes: [
-                  {change_id: 12345, topic: 'T'},
-                  {change_id: 23456, topic: 'T'},
-                ]}));
-            showActionDialogStub = sinon.stub(element, '_showActionDialog');
-            navigateToSearchQueryStub = sinon.stub(GerritNav,
-                'navigateToSearchQuery');
-          });
-
-          test('revert submission multiple change', done => {
-            element._send('POST', {message: 'Revert submission'},
-                '/revert_submission', false, cleanup).then(res => {
-              element._handleResponse({__key: 'revert_submission'}, {}).then(
-                  () => {
-                    assert.isFalse(showActionDialogStub.called);
-                    assert.isTrue(navigateToSearchQueryStub.calledWith(
-                        'topic: T'));
-                    done();
-                  });
-            });
-          });
-        });
-
-        test('revision action', done => {
-          element
-              ._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isTrue(sendStub.calledWith(42, 'DELETE', '/endpoint',
-                    12, payload));
-                done();
-              });
-        });
-      });
-
-      suite('failure modes', () => {
-        test('non-latest', () => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // new patchset was uploaded
-                revisions: createRevisions(element.latestPatchNum + 1),
-                messages: createChangeMessages(1),
-              }));
-          const sendStub = stubRestApi(
-              'executeChangeAction');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isTrue(onShowAlert.calledOnce);
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.calledOnce);
-                assert.isFalse(sendStub.called);
-              });
-        });
-
-        test('send fails', () => {
-          stubRestApi('getChangeDetail')
-              .returns(Promise.resolve({
-                ...createChange(),
-                // element has latest info
-                revisions: createRevisions(element.latestPatchNum),
-                messages: createChangeMessages(1),
-              }));
-          const sendStub = stubRestApi(
-              'executeChangeAction').callsFake(
-              (num, method, patchNum, endpoint, payload, onErr) => {
-                onErr();
-                return Promise.resolve(null);
-              });
-          const handleErrorStub = sinon.stub(element, '_handleResponseError');
-
-          return element._send('DELETE', payload, '/endpoint', true, cleanup)
-              .then(() => {
-                assert.isFalse(onShowError.called);
-                assert.isTrue(cleanup.called);
-                assert.isTrue(sendStub.calledOnce);
-                assert.isTrue(handleErrorStub.called);
-              });
-        });
-      });
-    });
-
-    test('_handleAction reports', () => {
-      sinon.stub(element, '_fireAction');
-      element.actions = {
-        key: {
-          __key: 'key',
-          __type: 'type',
-        },
-      };
-
-      const reportStub = sinon.stub(element.reporting, 'reportInteraction');
-      element._handleAction('type', 'key');
-      assert.isTrue(reportStub.called);
-      assert.equal(reportStub.lastCall.args[0], 'type-key');
-    });
-  });
-
-  suite('getChangeRevisionActions returns only some actions', () => {
-    let element;
-
-    let changeRevisionActions;
-
-    setup(() => {
-      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 properties
-      element.change = {};
-      element.changeNum = '42';
-      element.latestPatchNum = '2';
-
-      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
-      return element.reload();
-    });
-
-    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
-      changeRevisionActions = {};
-      element.reload();
-      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
-      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
-    });
-
-    test('_computeRebaseOnCurrent', () => {
-      const rebaseAction = {
-        enabled: true,
-        label: 'Rebase',
-        method: 'POST',
-        title: 'Rebase onto tip of branch or parent change',
-      };
-
-      // When rebase is enabled initially, rebaseOnCurrent should be set to
-      // true.
-      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
-
-      delete rebaseAction.enabled;
-
-      // When rebase is not enabled initially, rebaseOnCurrent should be set to
-      // false.
-      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
new file mode 100644
index 0000000..f9413ce
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -0,0 +1,2401 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-change-actions';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
+import {
+  createAccountWithId,
+  createApproval,
+  createChange,
+  createChangeMessages,
+  createChangeViewChange,
+  createRevision,
+  createRevisions,
+} from '../../../test/test-data-generators';
+import {ChangeStatus, HttpMethod} from '../../../constants/constants';
+import {
+  mockPromise,
+  query,
+  queryAll,
+  queryAndAssert,
+  stubReporting,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {assertUIActionInfo, GrChangeActions} from './gr-change-actions';
+import {
+  AccountId,
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  BranchName,
+  ChangeId,
+  ChangeSubmissionId,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+  RepoName,
+  ReviewInput,
+  TopicName,
+} from '../../../types/common';
+import {ActionType} from '../../../api/change-actions';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {SinonFakeTimers} from 'sinon';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+import {UIActionInfo} from '../../shared/gr-js-api-interface/gr-change-actions-js-api';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {appContext} from '../../../services/app-context';
+
+const basicFixture = fixtureFromElement('gr-change-actions');
+
+// TODO(dhruvsri): remove use of _populateRevertMessage as it's private
+suite('gr-change-actions tests', () => {
+  let element: GrChangeActions;
+
+  suite('basic tests', () => {
+    setup(() => {
+      stubRestApi('getChangeRevisionActions').returns(
+        Promise.resolve({
+          cherrypick: {
+            method: HttpMethod.POST,
+            label: 'Cherry Pick',
+            title: 'Cherry pick change to a different branch',
+            enabled: true,
+          },
+          rebase: {
+            method: HttpMethod.POST,
+            label: 'Rebase',
+            title: 'Rebase onto tip of branch or parent change',
+            enabled: true,
+          },
+          submit: {
+            method: HttpMethod.POST,
+            label: 'Submit',
+            title: 'Submit patch set 2 into master',
+            enabled: true,
+          },
+          revert_submission: {
+            method: HttpMethod.POST,
+            label: 'Revert submission',
+            title: 'Revert this submission',
+            enabled: true,
+          },
+        })
+      );
+      stubRestApi('send').callsFake((method, url) => {
+        if (method !== 'POST') {
+          return Promise.reject(new Error('bad method'));
+        }
+        if (url === '/changes/test~42/revisions/2/submit') {
+          return Promise.resolve({
+            ...new Response(),
+            ok: true,
+            text() {
+              return Promise.resolve(")]}'\n{}");
+            },
+          });
+        } else if (url === '/changes/test~42/revisions/2/rebase') {
+          return Promise.resolve({
+            ...new Response(),
+            ok: true,
+            text() {
+              return Promise.resolve(")]}'\n{}");
+            },
+          });
+        }
+        return Promise.reject(new Error('bad url'));
+      });
+
+      sinon
+        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      element.change = createChangeViewChange();
+      element.changeNum = 42 as NumericChangeId;
+      element.latestPatchNum = 2 as PatchSetNum;
+      element.actions = {
+        '/': {
+          method: HttpMethod.DELETE,
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        },
+      };
+      element.account = {
+        _account_id: 123 as AccountId,
+      };
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+
+      return element.reload();
+    });
+
+    test('show-revision-actions event should fire', async () => {
+      const spy = sinon.spy(element, '_sendShowRevisionActions');
+      element.reload();
+      await flush();
+      assert.isTrue(spy.called);
+    });
+
+    test('primary and secondary actions split properly', () => {
+      // Submit should be the only primary action.
+      assert.equal(element._topLevelPrimaryActions!.length, 1);
+      assert.equal(element._topLevelPrimaryActions![0].label, 'Submit');
+      assert.equal(
+        element._topLevelSecondaryActions!.length,
+        element._topLevelActions!.length - 1
+      );
+    });
+
+    test('revert submission action is skipped', () => {
+      assert.equal(
+        element._allActionValues.filter(action => action.__key === 'submit')
+          .length,
+        1
+      );
+      assert.equal(
+        element._allActionValues.filter(
+          action => action.__key === 'revert_submission'
+        ).length,
+        0
+      );
+    });
+
+    test('_shouldHideActions', () => {
+      assert.isTrue(element._shouldHideActions(undefined, true));
+      assert.isTrue(
+        element._shouldHideActions(
+          {base: [] as UIActionInfo[]} as PolymerDeepPropertyChange<
+            UIActionInfo[],
+            UIActionInfo[]
+          >,
+          false
+        )
+      );
+      assert.isFalse(
+        element._shouldHideActions(
+          {
+            base: [{__key: 'test'}] as UIActionInfo[],
+          } as PolymerDeepPropertyChange<UIActionInfo[], UIActionInfo[]>,
+          false
+        )
+      );
+    });
+
+    test('plugin revision actions', async () => {
+      const stub = stubRestApi('getChangeActionURL').returns(
+        Promise.resolve('the-url')
+      );
+      element.revisionActions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.revisionActions['plugin~action']);
+      await flush();
+      assert.isTrue(
+        stub.calledWith(
+          element.changeNum,
+          element.latestPatchNum,
+          '/plugin~action'
+        )
+      );
+      assert.equal(
+        (element.revisionActions['plugin~action'] as UIActionInfo)!.__url,
+        'the-url'
+      );
+    });
+
+    test('plugin change actions', async () => {
+      const stub = stubRestApi('getChangeActionURL').returns(
+        Promise.resolve('the-url')
+      );
+      element.actions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.actions['plugin~action']);
+      await flush();
+      assert.isTrue(
+        stub.calledWith(element.changeNum, undefined, '/plugin~action')
+      );
+      assert.equal(
+        (element.actions['plugin~action'] as UIActionInfo)!.__url,
+        'the-url'
+      );
+    });
+
+    test('not supported actions are filtered out', () => {
+      element.revisionActions = {followup: {}};
+      assert.equal(
+        element.querySelectorAll(
+          'section gr-button[data-action-type="revision"]'
+        ).length,
+        0
+      );
+    });
+
+    test('getActionDetails', () => {
+      element.revisionActions = {
+        'plugin~action': {},
+        ...element.revisionActions,
+      };
+      assert.isUndefined(element.getActionDetails('rubbish'));
+      assert.strictEqual(
+        element.revisionActions['plugin~action'],
+        element.getActionDetails('plugin~action')
+      );
+      assert.strictEqual(
+        element.revisionActions['rebase'],
+        element.getActionDetails('rebase')
+      );
+    });
+
+    test('hide revision action', async () => {
+      await flush();
+      let buttonEl: Element | undefined = queryAndAssert(
+        element,
+        '[data-action-key="submit"]'
+      );
+      assert.isOk(buttonEl);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        true
+      );
+      assert.lengthOf(element._hiddenActions, 1);
+      await flush();
+      buttonEl = query(element, '[data-action-key="submit"]');
+      assert.isNotOk(buttonEl);
+
+      element.setActionHidden(
+        element.ActionType.REVISION,
+        element.RevisionActions.SUBMIT,
+        false
+      );
+      await flush();
+      buttonEl = queryAndAssert(element, '[data-action-key="submit"]');
+      assert.isFalse(buttonEl.hasAttribute('hidden'));
+    });
+
+    test('buttons exist', async () => {
+      element._loading = false;
+      await flush();
+      const buttonEls = queryAll(element, 'gr-button');
+      const menuItems = element.$.moreActions.items;
+
+      // Total button number is one greater than the number of total actions
+      // due to the existence of the overflow menu trigger.
+      assert.equal(
+        buttonEls!.length + menuItems!.length,
+        element._allActionValues.length + 1
+      );
+      assert.isFalse(element.hidden);
+    });
+
+    test('delete buttons have explicit labels', async () => {
+      await flush();
+      const deleteItems = element.$.moreActions.items!.filter(item =>
+        item.id!.startsWith('delete')
+      );
+      assert.equal(deleteItems.length, 1);
+      assert.equal(deleteItems[0].name, 'Delete change');
+    });
+
+    test('get revision object from change', () => {
+      const revObj = {
+        ...createRevision(),
+        _number: 2 as PatchSetNum,
+        foo: 'bar',
+      };
+      const change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, 2 as PatchSetNum), revObj);
+    });
+
+    test('_actionComparator sort order', () => {
+      const actions = [
+        {label: '123', __type: ActionType.CHANGE, __key: 'review'},
+        {label: 'abc-ro', __type: ActionType.REVISION, __key: 'random'},
+        {label: 'abc', __type: ActionType.CHANGE, __key: 'random'},
+        {label: 'def', __type: ActionType.CHANGE, __key: 'random'},
+        {
+          label: 'def-p',
+          __type: ActionType.CHANGE,
+          __primary: true,
+          __key: 'random',
+        },
+      ];
+
+      const result = actions.slice();
+      result.reverse();
+      result.sort(element._actionComparator.bind(element));
+      assert.deepEqual(result, actions);
+    });
+
+    test('submit change', async () => {
+      const showSpy = sinon.spy(element, '_showActionDialog');
+      stubRestApi('getFromProjectLookup').returns(
+        Promise.resolve('test' as RepoName)
+      );
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+        },
+      };
+      element.latestPatchNum = 2 as PatchSetNum;
+
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+
+      await flush();
+      assert.isTrue(showSpy.calledWith(element.$.confirmSubmitDialog));
+    });
+
+    test('submit change, tap on icon', async () => {
+      const submitted = mockPromise();
+      sinon
+        .stub(element.$.confirmSubmitDialog, 'resetFocus')
+        .callsFake(() => submitted.resolve());
+      stubRestApi('getFromProjectLookup').returns(
+        Promise.resolve('test' as RepoName)
+      );
+      sinon.stub(element.$.overlay, 'open').returns(Promise.resolve());
+      element.change = {
+        ...createChangeViewChange(),
+        revisions: {
+          rev1: {...createRevision(), _number: 1 as PatchSetNum},
+          rev2: {...createRevision(), _number: 2 as PatchSetNum},
+        },
+      };
+      element.latestPatchNum = 2 as PatchSetNum;
+
+      const submitIcon = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"] iron-icon'
+      );
+      tap(submitIcon);
+      await submitted;
+    });
+
+    test('_handleSubmitConfirm', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(true);
+      element._handleSubmitConfirm();
+      assert.isTrue(fireStub.calledOnce);
+      assert.deepEqual(fireStub.lastCall.args, [
+        '/submit',
+        assertUIActionInfo(element.revisionActions.submit),
+        true,
+      ]);
+    });
+
+    test('_handleSubmitConfirm when not able to submit', () => {
+      const fireStub = sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_canSubmitChange').returns(false);
+      element._handleSubmitConfirm();
+      assert.isFalse(fireStub.called);
+    });
+
+    test('submit change with plugin hook', async () => {
+      sinon.stub(element, '_canSubmitChange').callsFake(() => false);
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      await flush();
+      const submitButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="submit"]'
+      );
+      tap(submitButton);
+      assert.equal(fireActionStub.callCount, 0);
+    });
+
+    test('chain state', () => {
+      assert.equal(element._hasKnownChainState, false);
+      element.hasParent = true;
+      assert.equal(element._hasKnownChainState, true);
+      element.hasParent = false;
+    });
+
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {
+        __key: 'rebase',
+        enabled: true,
+        __type: ActionType.CHANGE,
+        label: 'l',
+      };
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        true
+      );
+
+      action.__key = 'delete';
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        false
+      );
+
+      action.__key = 'rebase';
+      hasKnownChainState = true;
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        false
+      );
+
+      action.enabled = false;
+      assert.equal(
+        element._calculateDisabled(action, hasKnownChainState),
+        false
+      );
+    });
+
+    test('rebase change', async () => {
+      const fireActionStub = sinon.stub(element, '_fireAction');
+      const fetchChangesStub = sinon
+        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      await flush();
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      const rebaseAction = {
+        __key: 'rebase',
+        __type: 'revision',
+        __primary: false,
+        enabled: true,
+        label: 'Rebase',
+        method: HttpMethod.POST,
+        title: 'Rebase onto tip of branch or parent change',
+      };
+      assert.isTrue(fetchChangesStub.called);
+      element._handleRebaseConfirm(
+        new CustomEvent('', {detail: {base: '1234'}})
+      );
+      assert.deepEqual(fireActionStub.lastCall.args, [
+        '/rebase',
+        assertUIActionInfo(rebaseAction),
+        true,
+        {base: '1234'},
+      ]);
+    });
+
+    test('rebase change fires reload event', async () => {
+      const eventStub = sinon.stub(element, 'dispatchEvent');
+      element._handleResponse(
+        {__key: 'rebase', __type: ActionType.CHANGE, label: 'l'},
+        new Response()
+      );
+      await flush();
+      assert.isTrue(eventStub.called);
+      assert.equal(eventStub.lastCall.args[0].type, 'reload');
+    });
+
+    test("rebase dialog gets recent changes each time it's opened", async () => {
+      const fetchChangesStub = sinon
+        .stub(element.$.confirmRebase, 'fetchRecentChanges')
+        .returns(Promise.resolve([]));
+      element._hasKnownChainState = true;
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledOnce);
+
+      await flush();
+      element.$.confirmRebase.dispatchEvent(
+        new CustomEvent('cancel', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      tap(rebaseButton);
+      assert.isTrue(fetchChangesStub.calledTwice);
+    });
+
+    test('two dialogs are not shown at the same time', async () => {
+      element._hasKnownChainState = true;
+      await flush();
+      const rebaseButton = queryAndAssert(
+        element,
+        'gr-button[data-action-key="rebase"]'
+      );
+      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', () => {
+      const spy = sinon.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-opened', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(spy.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('fullscreen-overlay-closed shows content', () => {
+      const spy = sinon.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.dispatchEvent(
+        new CustomEvent('fullscreen-overlay-closed', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(spy.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    test('_setReviewOnRevert', () => {
+      const review = {labels: {Foo: 1, 'Bar-Baz': -2}};
+      const changeId = 1234 as NumericChangeId;
+      sinon
+        .stub(appContext.jsApiService, 'getReviewPostRevert')
+        .returns(review);
+      const saveStub = stubRestApi('saveChangeReview').returns(
+        Promise.resolve(new Response())
+      );
+      const setReviewOnRevert = element._setReviewOnRevert(changeId) as Promise<
+        undefined | Response
+      >;
+      return setReviewOnRevert.then((_res: Response | undefined) => {
+        assert.isTrue(saveStub.calledOnce);
+        assert.equal(saveStub.lastCall.args[0], changeId);
+        assert.deepEqual(saveStub.lastCall.args[2], review);
+      });
+    });
+
+    suite('change edits', () => {
+      test('disableEdit', async () => {
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.set('disableEdit', true);
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="deleteEdit"]')
+        );
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('shows confirm dialog for delete edit', async () => {
+        element.set('loggedIn', true);
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$.confirmDeleteEditDialog.hidden);
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteEditDialog'),
+            'gr-button[primary]'
+          )
+        );
+        await flush();
+
+        assert.equal(fireActionStub.lastCall.args[0], '/edit');
+      });
+
+      test('edit patchset is loaded, needs rebase', async () => {
+        element.set('loggedIn', true);
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.editBasedOnCurrentPatchSet = false;
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isOk(query(element, 'gr-button[data-action-key="rebaseEdit"]'));
+        assert.isOk(query(element, 'gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit patchset is loaded, does not need rebase', async () => {
+        element.set('loggedIn', true);
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.editBasedOnCurrentPatchSet = true;
+        await flush();
+
+        assert.isOk(query(element, 'gr-button[data-action-key="publishEdit"]'));
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isOk(query(element, 'gr-button[data-action-key="deleteEdit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit mode is loaded, no edit patchset', async () => {
+        element.set('loggedIn', true);
+        element.set('editMode', true);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="deleteEdit"]')
+        );
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('normal patch set', async () => {
+        element.set('loggedIn', true);
+        element.set('editMode', false);
+        element.set('editPatchsetLoaded', false);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        await flush();
+
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="publishEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="rebaseEdit"]')
+        );
+        assert.isNotOk(
+          query(element, 'gr-button[data-action-key="deleteEdit"]')
+        );
+        assert.isOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+      });
+
+      test('edit action', async () => {
+        element.set('loggedIn', true);
+        const editTapped = mockPromise();
+        element.addEventListener('edit-tap', () => {
+          editTapped.resolve();
+        });
+        element.set('editMode', true);
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        await flush();
+
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        assert.isOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.MERGED,
+        };
+        await flush();
+
+        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+        element.change = {
+          ...createChangeViewChange(),
+          status: ChangeStatus.NEW,
+        };
+        element.set('editMode', false);
+        await flush();
+
+        const editButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="edit"]'
+        );
+        tap(editButton);
+        await editTapped;
+      });
+    });
+
+    test('edit action not shown for logged out user', async () => {
+      element.set('loggedIn', false);
+      element.set('editMode', false);
+      element.set('editPatchsetLoaded', false);
+      element.change = {
+        ...createChangeViewChange(),
+        status: ChangeStatus.NEW,
+      };
+      await flush();
+
+      assert.isNotOk(
+        query(element, 'gr-button[data-action-key="publishEdit"]')
+      );
+      assert.isNotOk(query(element, 'gr-button[data-action-key="rebaseEdit"]'));
+      assert.isNotOk(query(element, 'gr-button[data-action-key="deleteEdit"]'));
+      assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
+      assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
+    });
+
+    suite('cherry-pick', () => {
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: HttpMethod.POST,
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmCherrypick.branch = 'master' as BranchName;
+        element._handleCherrypickConfirm();
+        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
+        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+
+        element._handleCherrypickConfirm();
+
+        const autogrowEl = queryAndAssert(
+          element.$.confirmCherrypick,
+          '#messageInput'
+        ) as IronAutogrowTextareaElement;
+        assert.equal(autogrowEl.value, 'foo message');
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick',
+          action,
+          true,
+          {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: false,
+          },
+        ]);
+      });
+
+      test('cherry pick even with conflicts', () => {
+        element._handleCherrypickTap();
+        const action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry pick',
+          method: HttpMethod.POST,
+          title: 'Cherry pick change to a different branch',
+        };
+
+        element.$.confirmCherrypick.branch = 'master' as BranchName;
+
+        // Add attributes that are used to determine the message.
+        element.$.confirmCherrypick.commitMessage = 'foo message';
+        element.$.confirmCherrypick.changeStatus = ChangeStatus.NEW;
+        element.$.confirmCherrypick.commitNum = '123' as CommitId;
+
+        element._handleCherrypickConflictConfirm();
+
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/cherrypick',
+          action,
+          true,
+          {
+            destination: 'master',
+            base: null,
+            message: 'foo message',
+            allow_conflicts: true,
+          },
+        ]);
+      });
+
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master' as BranchName;
+
+        element._handleCherrypickTap();
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
+
+      suite('cherry pick topics', () => {
+        const changes = [
+          {
+            ...createChangeViewChange(),
+            change_id: '12345678901234' as ChangeId,
+            topic: 'T' as TopicName,
+            subject: 'random',
+            project: 'A' as RepoName,
+            status: ChangeStatus.MERGED,
+          },
+          {
+            ...createChangeViewChange(),
+            change_id: '23456' as ChangeId,
+            topic: 'T' as TopicName,
+            subject: 'a'.repeat(100),
+            project: 'B' as RepoName,
+            status: ChangeStatus.NEW,
+          },
+        ];
+        setup(async () => {
+          stubRestApi('getChanges').returns(Promise.resolve(changes));
+          element._handleCherrypickTap();
+          await flush();
+          const radioButtons = queryAll(
+            element.$.confirmCherrypick,
+            "input[name='cherryPickOptions']"
+          );
+          assert.equal(radioButtons.length, 2);
+          tap(radioButtons[1]);
+          await flush();
+        });
+
+        test('cherry pick topic dialog is rendered', async () => {
+          const dialog = element.$.confirmCherrypick;
+          await flush();
+          const changesTable = queryAndAssert(dialog, 'table');
+          const headers = Array.from(changesTable.querySelectorAll('th'));
+          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++) {
+            assert.equal(headings[i].trim(), expectedHeadings[i]);
+          }
+          const changeRows = queryAll(changesTable, 'tbody > tr');
+          const change = Array.from(changeRows[0].querySelectorAll('td')).map(
+            e => e.innerText
+          );
+          const expectedChange = [
+            '',
+            '1234567890',
+            'MERGED',
+            'random',
+            'A',
+            'NOT STARTED',
+            '',
+          ];
+          for (let i = 0; i < change.length; i++) {
+            assert.equal(change[i].trim(), expectedChange[i]);
+          }
+        });
+
+        test('changes with duplicate project show an error', async () => {
+          const dialog = element.$.confirmCherrypick;
+          const error = queryAndAssert(
+            dialog,
+            '.error-message'
+          ) as HTMLSpanElement;
+          assert.equal(error.innerText, '');
+          dialog.updateChanges([
+            {
+              ...createChangeViewChange(),
+              change_id: '12345678901234' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'random',
+              project: 'A' as RepoName,
+            },
+            {
+              ...createChangeViewChange(),
+              change_id: '23456' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'a'.repeat(100),
+              project: 'A' as RepoName,
+            },
+          ]);
+          await flush();
+          assert.equal(
+            error.innerText,
+            'Two changes cannot be of the same' + ' project'
+          );
+        });
+      });
+    });
+
+    suite('move change', () => {
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        sinon.stub(window, 'alert');
+        element.actions = {
+          move: {
+            method: HttpMethod.POST,
+            label: 'Move',
+            title: 'Move the change',
+            enabled: true,
+          },
+        };
+      });
+
+      test('works', () => {
+        element._handleMoveTap();
+
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmMove.branch = 'master' as BranchName;
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 1);
+      });
+
+      test('branch name cleared when re-open move', () => {
+        const emptyBranchName = '';
+        element.$.confirmMove.branch = 'master' as BranchName;
+
+        element._handleMoveTap();
+        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+      });
+    });
+
+    test('custom actions', async () => {
+      // Add a button with the same key as a server-based one to ensure
+      // collisions are taken care of.
+      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      const keyTapped = mockPromise();
+      element.addEventListener(key + '-tap', async e => {
+        assert.equal(
+          (e as CustomEvent).detail.node.getAttribute('data-action-key'),
+          key
+        );
+        element.removeActionButton(key);
+        await flush();
+        assert.notOk(query(element, '[data-action-key="' + key + '"]'));
+        keyTapped.resolve();
+      });
+      await flush();
+      tap(queryAndAssert(element, '[data-action-key="' + key + '"]'));
+      await keyTapped;
+    });
+
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Rebasing...');
+
+      const button = queryAndAssert(
+        element,
+        '[data-action-key="' + key + '"]'
+      ) as GrButton;
+      assert.isTrue(button.hasAttribute('loading'));
+      assert.isTrue(button.disabled);
+
+      assert.isOk(cleanup);
+      assert.isFunction(cleanup);
+      cleanup();
+
+      assert.isFalse(button.hasAttribute('loading'));
+      assert.isFalse(button.disabled);
+      assert.isNotOk(element._actionLoadingMessage);
+    });
+
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
+      assert.equal(element._actionLoadingMessage, 'Cherry-picking...');
+      assert.include(element._disabledMenuActions, 'cherrypick');
+      assert.isFunction(cleanup);
+
+      cleanup();
+
+      assert.notOk(element._actionLoadingMessage);
+      assert.notInclude(element._disabledMenuActions, 'cherrypick');
+    });
+
+    suite('abandon change', () => {
+      let alertStub: sinon.SinonStub;
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        alertStub = sinon.stub(window, 'alert');
+        element.actions = {
+          abandon: {
+            method: HttpMethod.POST,
+            label: 'Abandon',
+            title: 'Abandon the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('abandon change with message', async () => {
+        const newAbandonMsg = 'Test Abandon Message';
+        element.$.confirmAbandonDialog.message = newAbandonMsg;
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
+
+        assert.equal(element.$.confirmAbandonDialog.message, newAbandonMsg);
+      });
+
+      test('abandon change with no message', async () => {
+        await flush();
+        const abandonButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(abandonButton);
+
+        assert.equal(element.$.confirmAbandonDialog.message, '');
+      });
+
+      test('works', () => {
+        element.$.confirmAbandonDialog.message = 'original message';
+        const restoreButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="abandon"]'
+        );
+        tap(restoreButton);
+
+        element.$.confirmAbandonDialog.message = 'foo message';
+        element._handleAbandonDialogConfirm();
+        assert.notOk(alertStub.called);
+
+        const action = {
+          __key: 'abandon',
+          __type: 'change',
+          __primary: false,
+          enabled: true,
+          label: 'Abandon',
+          method: HttpMethod.POST,
+          title: 'Abandon the change',
+        };
+        assert.deepEqual(fireActionStub.lastCall.args, [
+          '/abandon',
+          action,
+          false,
+          {
+            message: 'foo message',
+          },
+        ]);
+      });
+    });
+
+    suite('revert change', () => {
+      let fireActionStub: sinon.SinonStub;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.commitMessage = 'random commit message';
+        element.change!.current_revision = 'abcdef' as CommitId;
+        element.actions = {
+          revert: {
+            method: HttpMethod.POST,
+            label: 'Revert',
+            title: 'Revert the change',
+            enabled: true,
+          },
+        };
+        return element.reload();
+      });
+
+      test('revert change with plugin hook', async () => {
+        const newRevertMsg = 'Modified revert msg';
+        sinon
+          .stub(element.$.confirmRevertDialog, '_modifyRevertMsg')
+          .callsFake(() => newRevertMsg);
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+        };
+        stubRestApi('getChanges').returns(
+          Promise.resolve([
+            {
+              ...createChange(),
+              change_id: '12345678901234' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'random',
+            },
+            {
+              ...createChange(),
+              change_id: '23456' as ChangeId,
+              topic: 'T' as TopicName,
+              subject: 'a'.repeat(100),
+            },
+          ])
+        );
+        sinon
+          .stub(
+            element.$.confirmRevertDialog,
+            '_populateRevertSubmissionMessage'
+          )
+          .callsFake(() => 'original msg');
+        await flush();
+        const revertButton = queryAndAssert(
+          element,
+          'gr-button[data-action-key="revert"]'
+        );
+        tap(revertButton);
+        await flush();
+        assert.equal(element.$.confirmRevertDialog._message, newRevertMsg);
+      });
+
+      suite('revert change submitted together', () => {
+        let getChangesStub: sinon.SinonStub;
+        setup(() => {
+          element.change = {
+            ...createChangeViewChange(),
+            submission_id: '199 0' as ChangeSubmissionId,
+            current_revision: '2000' as CommitId,
+          };
+          getChangesStub = stubRestApi('getChanges').returns(
+            Promise.resolve([
+              {
+                ...createChange(),
+                change_id: '12345678901234' as ChangeId,
+                topic: 'T' as TopicName,
+                subject: 'random',
+              },
+              {
+                ...createChange(),
+                change_id: '23456' as ChangeId,
+                topic: 'T' as TopicName,
+                subject: 'a'.repeat(100),
+              },
+            ])
+          );
+        });
+
+        test('confirm revert dialog shows both options', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          await flush();
+          assert.equal(getChangesStub.args[0][1], 'submissionid: "199 0"');
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const revertSingleChangeLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSingleChange'
+          ) as HTMLLabelElement;
+          const revertSubmissionLabel = queryAndAssert(
+            confirmRevertDialog,
+            '.revertSubmission'
+          ) as HTMLLabelElement;
+          assert(
+            revertSingleChangeLabel.innerText.trim() === 'Revert single change'
+          );
+          assert(
+            revertSubmissionLabel.innerText.trim() ===
+              'Revert entire submission (2 Changes)'
+          );
+          let expectedMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          tap(radioInputs[0]);
+          await flush();
+          expectedMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, expectedMsg);
+        });
+
+        test('submit fails if message is not edited', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
+        });
+
+        test('message modification is retained on switching', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          await flush();
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          const revertSubmissionMsg =
+            'Revert submission 199 0' +
+            '\n\n' +
+            'Reason for revert: <INSERT REASONING HERE>' +
+            '\n' +
+            'Reverted Changes:' +
+            '\n' +
+            '1234567890:random' +
+            '\n' +
+            '23456:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa...' +
+            '\n';
+          const singleChangeMsg =
+            'Revert "random commit message"\n\nThis reverts ' +
+            'commit 2000.\n\nReason' +
+            ' for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, revertSubmissionMsg);
+          const newRevertMsg = revertSubmissionMsg + 'random';
+          const newSingleChangeMsg = singleChangeMsg + 'random';
+          confirmRevertDialog._message = newRevertMsg;
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, singleChangeMsg);
+          confirmRevertDialog._message = newSingleChangeMsg;
+          tap(radioInputs[1]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newRevertMsg);
+          tap(radioInputs[0]);
+          await flush();
+          assert.equal(confirmRevertDialog._message, newSingleChangeMsg);
+        });
+      });
+
+      suite('revert single change', () => {
+        setup(() => {
+          element.change = {
+            ...createChangeViewChange(),
+            submission_id: '199' as ChangeSubmissionId,
+            current_revision: '2000' as CommitId,
+          };
+          stubRestApi('getChanges').returns(
+            Promise.resolve([
+              {
+                ...createChange(),
+                change_id: '12345678901234' as ChangeId,
+                topic: 'T' as TopicName,
+                subject: 'random',
+              },
+            ])
+          );
+        });
+
+        test('submit fails if message is not edited', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          tap(revertButton);
+          const fireStub = sinon.stub(confirmRevertDialog, 'dispatchEvent');
+          await flush();
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.isTrue(confirmRevertDialog._showErrorMessage);
+          assert.isFalse(fireStub.called);
+        });
+
+        test('confirm revert dialog shows no radio button', async () => {
+          const revertButton = queryAndAssert(
+            element,
+            'gr-button[data-action-key="revert"]'
+          );
+          tap(revertButton);
+          await flush();
+          const confirmRevertDialog = element.$.confirmRevertDialog;
+          const radioInputs = queryAll(
+            confirmRevertDialog,
+            'input[name="revertOptions"]'
+          );
+          assert.equal(radioInputs.length, 0);
+          const msg =
+            'Revert "random commit message"\n\n' +
+            'This reverts commit 2000.\n\nReason ' +
+            'for revert: <INSERT REASONING HERE>\n';
+          assert.equal(confirmRevertDialog._message, msg);
+          const editedMsg = msg + 'hello';
+          confirmRevertDialog._message += 'hello';
+          const confirmButton = queryAndAssert(
+            queryAndAssert(element.$.confirmRevertDialog, 'gr-dialog'),
+            '#confirm'
+          );
+          tap(confirmButton);
+          await flush();
+          assert.equal(fireActionStub.getCall(0).args[0], '/revert');
+          assert.equal(fireActionStub.getCall(0).args[1].__key, 'revert');
+          assert.equal(fireActionStub.getCall(0).args[3].message, editedMsg);
+        });
+      });
+    });
+
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change!.is_private = false;
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        return element.reload();
+      });
+
+      test(
+        'make sure the mark private change button is not outside of the ' +
+          'overflow menu',
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private"]'));
+        }
+      );
+
+      test('private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private-change"]')
+        );
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.POST,
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change!.is_private = true;
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        return element.reload();
+      });
+
+      test(
+        'make sure the unmark private change button is not outside of the ' +
+          'overflow menu',
+        async () => {
+          await flush();
+          assert.isNotOk(query(element, '[data-action-key="private.delete"]'));
+        }
+      );
+
+      test('unmark the private change', async () => {
+        await flush();
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'private.delete', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="private.delete"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="private.delete-change"]')
+        );
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub: sinon.SinonStub;
+      let deleteAction: ActionInfo;
+
+      setup(() => {
+        fireActionStub = sinon.stub(element, '_fireAction');
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+        };
+        deleteAction = {
+          method: HttpMethod.DELETE,
+          label: 'Delete Change',
+          title: 'Delete change X_X',
+          enabled: true,
+        };
+        element.actions = {
+          '/': deleteAction,
+        };
+      });
+
+      test('does not delete on action', () => {
+        element._handleDeleteTap();
+        assert.isFalse(fireActionStub.called);
+      });
+
+      test('shows confirm dialog', async () => {
+        element._handleDeleteTap();
+        assert.isFalse(
+          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+        );
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteDialog'),
+            'gr-button[primary]'
+          )
+        );
+        await flush();
+        assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
+      });
+
+      test('hides delete confirm on cancel', async () => {
+        element._handleDeleteTap();
+        tap(
+          queryAndAssert(
+            queryAndAssert(element, '#confirmDeleteDialog'),
+            'gr-button:not([primary])'
+          )
+        );
+        await flush();
+        assert.isTrue(
+          (queryAndAssert(element, '#confirmDeleteDialog') as GrDialog).hidden
+        );
+        assert.isFalse(fireActionStub.called);
+      });
+    });
+
+    suite('ignore change', () => {
+      setup(async () => {
+        sinon.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.PUT,
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        await element.reload();
+        await flush();
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(query(element, '[data-action-key="ignore"]'));
+      });
+
+      test('ignoring change', async () => {
+        queryAndAssert(element.$.moreActions, 'span[data-id="ignore-change"]');
+        element.setActionOverflow(ActionType.CHANGE, 'ignore', false);
+        await flush();
+        queryAndAssert(element, '[data-action-key="ignore"]');
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="ignore-change"]')
+        );
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(async () => {
+        sinon.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: HttpMethod.PUT,
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = 2 as NumericChangeId;
+        element.latestPatchNum = 2 as PatchSetNum;
+
+        await element.reload();
+        await flush();
+      });
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(query(element, '[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', async () => {
+        assert.isOk(
+          query(element.$.moreActions, 'span[data-id="unignore-change"]')
+        );
+        element.setActionOverflow(ActionType.CHANGE, 'unignore', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="unignore"]'));
+        assert.isNotOk(
+          query(element.$.moreActions, 'span[data-id="unignore-change"]')
+        );
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              values: {
+                '-1': '',
+                ' 0': '',
+                '+1': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: ['-1', ' 0', '+1'],
+          },
+        };
+        await flush();
+      });
+
+      test('added when can approve', () => {
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+      });
+
+      test('hide quick approve', async () => {
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+        assert.isFalse(element._hideQuickApproveAction);
+
+        // Assert approve button gets removed from list of buttons.
+        element.hideQuickApproveAction();
+        await flush();
+        const approveButtonUpdated = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButtonUpdated);
+        assert.isTrue(element._hideQuickApproveAction);
+      });
+
+      test('is first in list of secondary actions', () => {
+        const approveButton =
+          element.$.secondaryActions.querySelector('gr-button');
+        assert.equal(approveButton!.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('not added when change is merged', async () => {
+        element.change = {
+          ...element.change!,
+          status: ChangeStatus.MERGED,
+        };
+
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when already approved', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              approved: {},
+              values: {},
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when label not permitted', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+          },
+          permitted_labels: {
+            bar: [],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('approves when tapped', async () => {
+        const fireActionStub = sinon.stub(element, '_fireAction');
+        tap(queryAndAssert(element, "gr-button[data-action-key='review']"));
+        await flush();
+        assert.isTrue(fireActionStub.called);
+        assert.isTrue(fireActionStub.calledWith('/review'));
+        const payload = fireActionStub.lastCall.args[3];
+        assert.deepEqual((payload as ReviewInput).labels, {foo: 1});
+      });
+
+      test('not added when multiple labels are required without code review', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('code review shown with multiple missing approval', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {values: {}},
+            bar: {values: {}},
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isOk(approveButton);
+      });
+
+      test('button label for missing approval', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            foo: {
+              values: {
+                ' 0': '',
+                '+1': '',
+              },
+            },
+            bar: {approved: {}, values: {}},
+          },
+          permitted_labels: {
+            foo: [' 0', '+1'],
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
+      });
+
+      test('no quick approve if score is not maximal for a label', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('approving label with a non-max score', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            bar: {
+              value: 1,
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            bar: [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "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', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = queryAndAssert(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotNull(approveButton);
+      });
+
+      test('not added when the user has already approved', async () => {
+        const vote = {
+          ...createApproval(),
+          _account_id: 123 as AccountId,
+          name: 'name',
+          value: 2,
+        };
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+              all: [vote],
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+
+      test('not added when user owns the change', async () => {
+        element.change = {
+          ...createChangeViewChange(),
+          current_revision: 'abc1234' as CommitId,
+          owner: createAccountWithId(123),
+          labels: {
+            'Code-Review': {
+              approved: {},
+              values: {
+                ' 0': '',
+                '+1': '',
+                '+2': '',
+              },
+            },
+          },
+          permitted_labels: {
+            'Code-Review': [' 0', '+1', '+2'],
+          },
+        };
+        await flush();
+        const approveButton = query(
+          element,
+          "gr-button[data-action-key='review']"
+        );
+        assert.isNotOk(approveButton);
+      });
+    });
+
+    test('adds download revision action', async () => {
+      const handler = sinon.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      await flush();
+
+      assert.isTrue(handler.called);
+    });
+
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sinon.stub(element, 'reload');
+      element.changeNum = 123 as NumericChangeId;
+      assert.isFalse(reloadStub.called);
+      element.latestPatchNum = 456 as PatchSetNum;
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_toSentenceCase', () => {
+      assert.equal(element._toSentenceCase('blah blah'), 'Blah blah');
+      assert.equal(element._toSentenceCase('BLAH BLAH'), 'Blah blah');
+      assert.equal(element._toSentenceCase('b'), 'B');
+      assert.equal(element._toSentenceCase(''), '');
+      assert.equal(element._toSentenceCase('!@#$%^&*()'), '!@#$%^&*()');
+    });
+
+    suite('setActionOverflow', () => {
+      test('move action from overflow', async () => {
+        assert.isNotOk(query(element, '[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+          element.$.moreActions!.items![0].id,
+          'cherrypick-revision'
+        );
+        element.setActionOverflow(ActionType.REVISION, 'cherrypick', false);
+        await flush();
+        assert.isOk(query(element, '[data-action-key="cherrypick"]'));
+        assert.notEqual(
+          element.$.moreActions!.items![0].id,
+          'cherrypick-revision'
+        );
+      });
+
+      test('move action to overflow', async () => {
+        assert.isOk(query(element, '[data-action-key="submit"]'));
+        element.setActionOverflow(ActionType.REVISION, 'submit', true);
+        await flush();
+        assert.isNotOk(query(element, '[data-action-key="submit"]'));
+        assert.strictEqual(
+          element.$.moreActions.items![3].id,
+          'submit-revision'
+        );
+      });
+
+      suite('_waitForChangeReachable', () => {
+        let clock: SinonFakeTimers;
+        setup(() => {
+          clock = sinon.useFakeTimers();
+        });
+
+        const makeGetChange = (numTries: number) => () => {
+          if (numTries === 1) {
+            return Promise.resolve({
+              ...createChangeViewChange(),
+              _number: 123 as NumericChangeId,
+            });
+          } else {
+            numTries--;
+            return Promise.resolve(null);
+          }
+        };
+
+        const tickAndFlush = async (repetitions: number) => {
+          for (let i = 1; i <= repetitions; i++) {
+            clock.tick(1000);
+            await flush();
+          }
+        };
+
+        test('succeed', async () => {
+          stubRestApi('getChange').callsFake(makeGetChange(5));
+          const promise = element._waitForChangeReachable(
+            123 as NumericChangeId
+          );
+          tickAndFlush(5);
+          const success = await promise;
+          assert.isTrue(success);
+        });
+
+        test('fail', async () => {
+          stubRestApi('getChange').callsFake(makeGetChange(6));
+          const promise = element._waitForChangeReachable(
+            123 as NumericChangeId
+          );
+          tickAndFlush(6);
+          const success = await promise;
+          assert.isFalse(success);
+        });
+      });
+    });
+
+    suite('_send', () => {
+      let cleanup: sinon.SinonStub;
+      const payload = {foo: 'bar'};
+      let onShowError: sinon.SinonStub;
+      let onShowAlert: sinon.SinonStub;
+      let getResponseObjectStub: sinon.SinonStub;
+
+      setup(() => {
+        cleanup = sinon.stub();
+        element.changeNum = 42 as NumericChangeId;
+        element.latestPatchNum = 12 as PatchSetNum;
+        element.change = {
+          ...createChangeViewChange(),
+          revisions: createRevisions(element.latestPatchNum as number),
+          messages: createChangeMessages(1),
+        };
+        element.change!._number = 42 as NumericChangeId;
+
+        onShowError = sinon.stub();
+        element.addEventListener('show-error', onShowError);
+        onShowAlert = sinon.stub();
+        element.addEventListener('show-alert', onShowAlert);
+      });
+
+      suite('happy path', () => {
+        let sendStub: sinon.SinonStub;
+        setup(() => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // element has latest info
+              revisions: createRevisions(element.latestPatchNum as number),
+              messages: createChangeMessages(1),
+            })
+          );
+          getResponseObjectStub = stubRestApi('getResponseObject');
+          sendStub = stubRestApi('executeChangeAction').returns(
+            Promise.resolve(new Response())
+          );
+          sinon.stub(GerritNav, 'navigateToChange');
+        });
+
+        test('change action', async () => {
+          await element._send(
+            HttpMethod.DELETE,
+            payload,
+            '/endpoint',
+            false,
+            cleanup,
+            {} as UIActionInfo
+          );
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(
+            sendStub.calledWith(
+              42,
+              HttpMethod.DELETE,
+              '/endpoint',
+              undefined,
+              payload
+            )
+          );
+        });
+
+        suite('show revert submission dialog', () => {
+          setup(() => {
+            element.change!.submission_id = '199' as ChangeSubmissionId;
+            element.change!.current_revision = '2000' as CommitId;
+            stubRestApi('getChanges').returns(
+              Promise.resolve([
+                {
+                  ...createChangeViewChange(),
+                  change_id: '12345678901234' as ChangeId,
+                  topic: 'T' as TopicName,
+                  subject: 'random',
+                },
+                {
+                  ...createChangeViewChange(),
+                  change_id: '23456' as ChangeId,
+                  topic: 'T' as TopicName,
+                  subject: 'a'.repeat(100),
+                },
+              ])
+            );
+          });
+        });
+
+        suite('single changes revert', () => {
+          let navigateToSearchQueryStub: sinon.SinonStub;
+          setup(() => {
+            getResponseObjectStub.returns(
+              Promise.resolve({revert_changes: [{change_id: 12345}]})
+            );
+            navigateToSearchQueryStub = sinon.stub(
+              GerritNav,
+              'navigateToSearchQuery'
+            );
+          });
+
+          test('revert submission single change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isTrue(navigateToSearchQueryStub.called);
+          });
+        });
+
+        suite('multiple changes revert', () => {
+          let showActionDialogStub: sinon.SinonStub;
+          let navigateToSearchQueryStub: sinon.SinonStub;
+          setup(() => {
+            getResponseObjectStub.returns(
+              Promise.resolve({
+                revert_changes: [
+                  {change_id: 12345, topic: 'T'},
+                  {change_id: 23456, topic: 'T'},
+                ],
+              })
+            );
+            showActionDialogStub = sinon.stub(element, '_showActionDialog');
+            navigateToSearchQueryStub = sinon.stub(
+              GerritNav,
+              'navigateToSearchQuery'
+            );
+          });
+
+          test('revert submission multiple change', async () => {
+            await element._send(
+              HttpMethod.POST,
+              {message: 'Revert submission'},
+              '/revert_submission',
+              false,
+              cleanup,
+              {} as UIActionInfo
+            );
+            await element._handleResponse(
+              {
+                __key: 'revert_submission',
+                __type: ActionType.CHANGE,
+                label: 'l',
+              },
+              new Response()
+            );
+            assert.isFalse(showActionDialogStub.called);
+            assert.isTrue(navigateToSearchQueryStub.calledWith('topic: T'));
+          });
+        });
+
+        test('revision action', async () => {
+          await element._send(
+            HttpMethod.DELETE,
+            payload,
+            '/endpoint',
+            true,
+            cleanup,
+            {} as UIActionInfo
+          );
+          assert.isFalse(onShowError.called);
+          assert.isTrue(cleanup.calledOnce);
+          assert.isTrue(
+            sendStub.calledWith(42, 'DELETE', '/endpoint', 12, payload)
+          );
+        });
+      });
+
+      suite('failure modes', () => {
+        test('non-latest', () => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // new patchset was uploaded
+              revisions: createRevisions(
+                (element.latestPatchNum as number) + 1
+              ),
+              messages: createChangeMessages(1),
+            })
+          );
+          const sendStub = stubRestApi('executeChangeAction');
+
+          return element
+            ._send(
+              HttpMethod.DELETE,
+              payload,
+              '/endpoint',
+              true,
+              cleanup,
+              {} as UIActionInfo
+            )
+            .then(() => {
+              assert.isTrue(onShowAlert.calledOnce);
+              assert.isFalse(onShowError.called);
+              assert.isTrue(cleanup.calledOnce);
+              assert.isFalse(sendStub.called);
+            });
+        });
+
+        test('send fails', () => {
+          stubRestApi('getChangeDetail').returns(
+            Promise.resolve({
+              ...createChangeViewChange(),
+              // element has latest info
+              revisions: createRevisions(element.latestPatchNum as number),
+              messages: createChangeMessages(1),
+            })
+          );
+          const sendStub = stubRestApi('executeChangeAction').callsFake(
+            (_num, _method, _patchNum, _endpoint, _payload, onErr) => {
+              onErr!();
+              return Promise.resolve(undefined);
+            }
+          );
+          const handleErrorStub = sinon.stub(element, '_handleResponseError');
+
+          return element
+            ._send(
+              HttpMethod.DELETE,
+              payload,
+              '/endpoint',
+              true,
+              cleanup,
+              {} as UIActionInfo
+            )
+            .then(() => {
+              assert.isFalse(onShowError.called);
+              assert.isTrue(cleanup.called);
+              assert.isTrue(sendStub.calledOnce);
+              assert.isTrue(handleErrorStub.called);
+            });
+        });
+      });
+    });
+
+    test('_handleAction reports', () => {
+      sinon.stub(element, '_fireAction');
+      sinon.stub(element, '_handleChangeAction');
+
+      const reportStub = stubReporting('reportInteraction');
+      element._handleAction(ActionType.CHANGE, 'key');
+      assert.isTrue(reportStub.called);
+      assert.equal(reportStub.lastCall.args[0], 'change-key');
+    });
+  });
+
+  suite('getChangeRevisionActions returns only some actions', () => {
+    let element: GrChangeActions;
+
+    let changeRevisionActions: ActionNameToActionInfoMap = {};
+
+    setup(() => {
+      stubRestApi('getChangeRevisionActions').returns(
+        Promise.resolve(changeRevisionActions)
+      );
+      stubRestApi('send').returns(Promise.reject(new Error('error')));
+
+      sinon
+        .stub(getPluginLoader(), 'awaitPluginsLoaded')
+        .returns(Promise.resolve());
+
+      element = basicFixture.instantiate();
+      // getChangeRevisionActions is not called without
+      // set the following properties
+      element.change = createChangeViewChange();
+      element.changeNum = 42 as NumericChangeId;
+      element.latestPatchNum = 2 as PatchSetNum;
+
+      stubRestApi('getRepoBranches').returns(Promise.resolve([]));
+      return element.reload();
+    });
+
+    test('confirmSubmitDialog and confirmRebase properties are changed', () => {
+      changeRevisionActions = {};
+      element.reload();
+      assert.strictEqual(element.$.confirmSubmitDialog.action, null);
+      assert.strictEqual(element.$.confirmRebase.rebaseOnCurrent, null);
+    });
+
+    test('_computeRebaseOnCurrent', () => {
+      const rebaseAction = {
+        enabled: true,
+        label: 'Rebase',
+        method: HttpMethod.POST,
+        title: 'Rebase onto tip of branch or parent change',
+      };
+
+      // When rebase is enabled initially, rebaseOnCurrent should be set to
+      // true.
+      assert.isTrue(element._computeRebaseOnCurrent(rebaseAction));
+
+      rebaseAction.enabled = false;
+
+      // When rebase is not enabled initially, rebaseOnCurrent should be set to
+      // false.
+      assert.isFalse(element._computeRebaseOnCurrent(rebaseAction));
+    });
+  });
+});
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 6749ed1..8b46cd8 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
@@ -15,9 +15,9 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-change-metadata-shared-styles';
 import '../../../styles/gr-change-view-integration-shared-styles';
-import '../../../styles/gr-voting-styles';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../../plugins/gr-external-style/gr-external-style';
@@ -28,6 +28,7 @@
 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 '../gr-submit-requirements/gr-submit-requirements';
 import '../gr-change-requirements/gr-change-requirements';
 import '../gr-commit-info/gr-commit-info';
 import '../gr-reviewer-list/gr-reviewer-list';
@@ -51,6 +52,7 @@
   AccountDetailInfo,
   AccountInfo,
   BranchName,
+  ChangeInfo,
   CommitId,
   CommitInfo,
   ElementPropertyDeepChange,
@@ -84,6 +86,9 @@
   AutocompleteQuery,
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
+import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {Interaction} from '../../../constants/reporting';
+import {KnownExperimentId} from '../../../services/flags/flags';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -137,6 +142,9 @@
   @property({type: Object})
   change?: ParsedChangeInfo;
 
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
   @property({type: Object, notify: true})
   labels?: LabelNameToInfoMap;
 
@@ -209,14 +217,21 @@
   @property({type: Object})
   queryTopic?: AutocompleteQuery;
 
+  @property({type: Boolean})
+  _isSubmitRequirementsUiEnabled = false;
+
   restApiService = appContext.restApiService;
 
   private readonly reporting = appContext.reportingService;
 
-  /** @override */
-  ready() {
+  private readonly flagsService = appContext.flagsService;
+
+  override ready() {
     super.ready();
     this.queryTopic = (input: string) => this._getTopicSuggestions(input);
+    this._isSubmitRequirementsUiEnabled = this.flagsService.isEnabled(
+      KnownExperimentId.SUBMIT_REQUIREMENTS_UI
+    );
   }
 
   @observe('change.labels')
@@ -505,7 +520,7 @@
     if (!this.change) {
       throw new Error('change must be set');
     }
-    const target = (dom(e) as EventApi).rootTarget as GrLinkedChip;
+    const target = e.composedPath()[0] as GrLinkedChip;
     target.disabled = true;
     this.restApiService
       .setChangeTopic(this.change._number)
@@ -577,6 +592,33 @@
     return rev.commit;
   }
 
+  _getRevertSectionTitle(
+    _change?: ParsedChangeInfo,
+    revertedChange?: ChangeInfo
+  ) {
+    return revertedChange?.status === ChangeStatus.MERGED
+      ? 'Revert Submitted As'
+      : 'Revert Created As';
+  }
+
+  _showRevertCreatedAs(change?: ParsedChangeInfo) {
+    if (!change?.messages) return false;
+    return getRevertCreatedChangeIds(change.messages).length > 0;
+  }
+
+  _computeRevertCommit(change?: ParsedChangeInfo, revertedChange?: ChangeInfo) {
+    if (revertedChange?.current_revision && revertedChange?.revisions) {
+      return {
+        commit: this._computeMergedCommitInfo(
+          revertedChange.current_revision,
+          revertedChange.revisions
+        ),
+      };
+    }
+    if (!change?.messages) return undefined;
+    return {commit: getRevertCreatedChangeIds(change.messages)?.[0]};
+  }
+
   _computeShowAllLabelText(showAllSections: boolean) {
     if (showAllSections) {
       return 'Show less';
@@ -587,7 +629,7 @@
 
   _onShowAllClick() {
     this._showAllSections = !this._showAllSections;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'metadata',
       toState: this._showAllSections ? 'Show all' : 'Show less',
     });
@@ -679,9 +721,9 @@
     }
     // Cannot use `this.$.ID` syntax because the element exists inside of a
     // dom-if.
-    (this.shadowRoot!.querySelector(
-      '.topicEditableLabel'
-    ) as GrEditableLabel).open();
+    (
+      this.shadowRoot!.querySelector('.topicEditableLabel') as GrEditableLabel
+    ).open();
   }
 
   _getReviewerSuggestionsProvider(change?: ParsedChangeInfo) {
@@ -710,6 +752,16 @@
           })
       );
   }
+
+  _showNewSubmitRequirements(change?: ParsedChangeInfo) {
+    if (!this._isSubmitRequirementsUiEnabled) return false;
+    return (change?.submit_requirements ?? []).length > 0;
+  }
+
+  _showNewSubmitRequirementWarning(change?: ParsedChangeInfo) {
+    if (!this._isSubmitRequirementsUiEnabled) return false;
+    return (change?.submit_requirements ?? []).length === 0;
+  }
 }
 
 declare global {
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 ed38d6f..25dab25 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
@@ -20,11 +20,15 @@
   <style include="gr-change-metadata-shared-styles">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
   </style>
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: table;
     }
-    gr-change-requirements {
+    gr-change-requirements,
+    gr-submit-requirements {
       --requirements-horizontal-padding: var(--metadata-horizontal-padding);
     }
     gr-editable-label {
@@ -33,16 +37,6 @@
     .webLink {
       display: block;
     }
-    /* CSS Mixins should be applied last. */
-    section.assignee {
-      @apply --change-metadata-assignee;
-    }
-    section.strategy {
-      @apply --change-metadata-strategy;
-    }
-    section.topic {
-      @apply --change-metadata-topic;
-    }
     gr-account-chip[disabled],
     gr-linked-chip[disabled] {
       opacity: 0;
@@ -103,7 +97,6 @@
       max-width: 285px;
     }
     .metadata-title {
-      font-weight: var(--font-weight-bold);
       color: var(--deemphasized-text-color);
       padding-left: var(--metadata-horizontal-padding);
     }
@@ -120,10 +113,14 @@
       --iron-icon-height: 18px;
       --iron-icon-width: 18px;
     }
+    .submit-requirement-error {
+      color: var(--deemphasized-text-color);
+      padding-left: var(--metadata-horizontal-padding);
+    }
   </style>
   <gr-external-style id="externalStyle" name="change-metadata">
     <div class="metadata-header">
-      <h3 class="metadata-title">Change Info</h3>
+      <h3 class="metadata-title heading-3">Change Info</h3>
       <gr-button link="" class="show-all-button" on-click="_onShowAllClick"
         >[[_computeShowAllLabelText(_showAllSections)]]
         <iron-icon
@@ -143,9 +140,9 @@
         <span class="title">Submitted</span>
         <span class="value">
           <gr-date-formatter
-            has-tooltip=""
+            withTooltip
             date-str="[[change.submitted]]"
-            show-yesterday=""
+            showYesterday=""
           ></gr-date-formatter>
         </span>
       </section>
@@ -163,21 +160,28 @@
       </span>
       <span class="value">
         <gr-date-formatter
-          has-tooltip=""
+          withTooltip
           date-str="[[change.updated]]"
-          show-yesterday=""
+          showYesterday
         ></gr-date-formatter>
       </span>
     </section>
     <section
       class$="[[_computeDisplayState(_showAllSections, change, _SECTION.OWNER)]]"
     >
-      <span class="title">Owner</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user created or uploaded the first patchset of this change."
+        >
+          Owner
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[change.owner]]"
           change="[[change]]"
-          highlight-attention
+          highlightAttention
         ></gr-account-chip>
         <template is="dom-if" if="[[_pushCertificateValidation]]">
           <gr-tooltip-content
@@ -194,17 +198,31 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.UPLOADER)]]">
-      <span class="title">Uploader</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user uploaded the patchset to Gerrit (typically by running the 'git push' command)."
+        >
+          Uploader
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.UPLOADER)]]"
           change="[[change]]"
-          highlight-attention
+          highlightAttention
         ></gr-account-chip>
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.AUTHOR)]]">
-      <span class="title">Author</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user wrote the code change."
+        >
+          Author
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.AUTHOR)]]"
@@ -213,7 +231,14 @@
       </span>
     </section>
     <section class$="[[_computeShowRoleClass(change, _CHANGE_ROLE.COMMITTER)]]">
-      <span class="title">Committer</span>
+      <span class="title">
+        <gr-tooltip-content
+          has-tooltip=""
+          title="This user committed the code change to the Git repository (typically to the local Git repo before uploading)."
+        >
+          Committer
+        </gr-tooltip-content>
+      </span>
       <span class="value">
         <gr-account-chip
           account="[[_getNonOwnerRole(change, _CHANGE_ROLE.COMMITTER)]]"
@@ -231,7 +256,6 @@
             id="assigneeValue"
             placeholder="Set assignee..."
             max-count="1"
-            skip-suggest-on-empty=""
             accounts="{{_assignee}}"
             readonly="[[_computeAssigneeReadOnly(_mutable, change)]]"
             suggestions-provider="[[_getReviewerSuggestionsProvider(change)]]"
@@ -335,8 +359,8 @@
               ></gr-commit-info>
               <gr-tooltip-content
                 id="parentNotCurrentMessage"
-                has-tooltip=""
-                show-icon=""
+                has-tooltip
+                show-icon
                 title$="[[_notCurrentMessage]]"
               ></gr-tooltip-content>
             </li>
@@ -358,6 +382,22 @@
         </span>
       </section>
     </template>
+    <template is="dom-if" if="[[_showRevertCreatedAs(change)]]">
+      <section
+        class$="[[_computeDisplayState(_showAllSections, change, _SECTION.REVERT_CREATED_AS)]]"
+      >
+        <span class="title"
+          >[[_getRevertSectionTitle(change, revertedChange)]]</span
+        >
+        <span class="value">
+          <gr-commit-info
+            change="[[change]]"
+            commit-info="[[_computeRevertCommit(change, revertedChange)]]"
+            server-config="[[serverConfig]]"
+          ></gr-commit-info>
+        </span>
+      </section>
+    </template>
     <section
       class$="topic [[_computeDisplayState(_showAllSections, change, _SECTION.TOPIC)]]"
     >
@@ -409,7 +449,6 @@
     <section
       class$="strategy [[_computeDisplayState(_showAllSections, change, _SECTION.STRATEGY)]]"
       hidden$="[[_computeHideStrategy(change)]]"
-      hidden=""
     >
       <span class="title">Strategy</span>
       <span class="value">[[_computeStrategy(change)]]</span>
@@ -444,11 +483,25 @@
       </span>
     </section>
     <div class="separatedSection">
-      <gr-change-requirements
-        change="{{change}}"
-        account="[[account]]"
-        mutable="[[_mutable]]"
-      ></gr-change-requirements>
+      <template is="dom-if" if="[[_showNewSubmitRequirements(change)]]">
+        <gr-submit-requirements
+          change="[[change]]"
+          account="[[account]]"
+          mutable="[[_mutable]]"
+        ></gr-submit-requirements>
+      </template>
+      <template is="dom-if" if="[[!_showNewSubmitRequirements(change)]]">
+        <gr-change-requirements
+          change="{{change}}"
+          account="[[account]]"
+          mutable="[[_mutable]]"
+        ></gr-change-requirements>
+      </template>
+      <template is="dom-if" if="[[_showNewSubmitRequirementWarning(change)]]">
+        <div class="submit-requirement-error">
+          New Submit Requirements don't work on this change.
+        </div>
+      </template>
     </div>
     <section
       id="webLinks"
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
index ab8e404..422c91b 100644
--- 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
@@ -57,14 +57,16 @@
   LabelValueToDescriptionMap,
   Hashtag,
 } from '../../../types/common';
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 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';
+import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {ParsedChangeInfo} from '../../../types/types';
+import {GrLinkedChip} from '../../shared/gr-linked-chip/gr-linked-chip';
+import {GrButton} from '../../shared/gr-button/gr-button';
 
 const basicFixture = fixtureFromElement('gr-change-metadata');
 
@@ -347,8 +349,11 @@
             },
             commit: {
               ...createCommit(),
-              author: {...createGitPerson(), email: 'jkl@def'},
-              committer: {...createGitPerson(), email: 'ghi@def'},
+              author: {...createGitPerson(), email: 'jkl@def' as EmailAddress},
+              committer: {
+                ...createGitPerson(),
+                email: 'ghi@def' as EmailAddress,
+              },
             },
           },
         },
@@ -398,7 +403,7 @@
         change!.revisions.rev1.uploader!.email = 'ghh@def' as EmailAddress;
         assert.deepEqual(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER),
-          {...createGitPerson(), email: 'ghi@def'}
+          {...createGitPerson(), email: 'ghi@def' as EmailAddress}
         );
       });
 
@@ -410,7 +415,8 @@
 
       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';
+        change!.revisions.rev1.commit!.committer.email =
+          'abc@def' as EmailAddress;
         assert.isNotOk(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.COMMITTER)
         );
@@ -428,13 +434,13 @@
       test('_getNonOwnerRole for author', () => {
         assert.deepEqual(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR),
-          {...createGitPerson(), email: 'jkl@def'}
+          {...createGitPerson(), email: 'jkl@def' as EmailAddress}
         );
       });
 
       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';
+        change!.revisions.rev1.commit!.author.email = 'abc@def' as EmailAddress;
         assert.isNotOk(
           element._getNonOwnerRole(change, element._CHANGE_ROLE.AUTHOR)
         );
@@ -628,14 +634,12 @@
   });
 
   test('_showAddTopic', () => {
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeMetadata,
-      'change'
-    > = {
-      base: {...createParsedChange()},
-      path: '',
-      value: undefined,
-    };
+    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));
@@ -645,14 +649,12 @@
   });
 
   test('_showTopicChip', () => {
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeMetadata,
-      'change'
-    > = {
-      base: {...createParsedChange()},
-      path: '',
-      value: undefined,
-    };
+    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));
@@ -662,14 +664,12 @@
   });
 
   test('_showCherryPickOf', () => {
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeMetadata,
-      'change'
-    > = {
-      base: {...createParsedChange()},
-      path: '',
-      value: undefined,
-    };
+    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;
@@ -692,7 +692,7 @@
           test: {
             all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
             default_value: 0,
-            values: ([] as unknown) as LabelValueToDescriptionMap,
+            values: [] as unknown as LabelValueToDescriptionMap,
           },
         },
         removable_reviewers: [],
@@ -710,25 +710,25 @@
       assert.isTrue(element._computeTopicReadOnly(mutable, change));
     });
 
-    test('topic read only hides delete button', () => {
+    test('topic read only hides delete button', async () => {
       element.account = createAccountDetailWithId();
       element.change = change;
-      flush();
-      const button = element!
-        .shadowRoot!.querySelector('gr-linked-chip')!
-        .shadowRoot!.querySelector('gr-button');
-      assert.isTrue(button?.hasAttribute('hidden'));
+      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
+      await flush();
+      const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
+      const button = queryAndAssert<GrButton>(chip, 'gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
     });
 
-    test('topic not read only does not hide delete button', () => {
+    test('topic not read only does not hide delete button', async () => {
       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'));
+      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:test');
+      await flush();
+      const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
+      const button = queryAndAssert<GrButton>(chip, 'gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
     });
   });
 
@@ -745,15 +745,15 @@
           test: {
             all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
             default_value: 0,
-            values: ([] as unknown) as LabelValueToDescriptionMap,
+            values: [] as unknown as LabelValueToDescriptionMap,
           },
         },
         removable_reviewers: [],
       };
     });
 
-    test('_computeHashtagReadOnly', () => {
-      flush();
+    test('_computeHashtagReadOnly', async () => {
+      await flush();
       let mutable = false;
       assert.isTrue(element._computeHashtagReadOnly(mutable, change));
       mutable = true;
@@ -764,27 +764,31 @@
       assert.isTrue(element._computeHashtagReadOnly(mutable, change));
     });
 
-    test('hashtag read only hides delete button', () => {
-      flush();
+    test('hashtag read only hides delete button', async () => {
+      await 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'));
+      sinon
+        .stub(GerritNav, 'getUrlForHashtag')
+        .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
+      await flush();
+      const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
+      const button = queryAndAssert<GrButton>(chip, 'gr-button');
+      assert.isTrue(button.hasAttribute('hidden'));
     });
 
-    test('hashtag not read only does not hide delete button', () => {
-      flush();
+    test('hashtag not read only does not hide delete button', async () => {
+      await 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'));
+      sinon
+        .stub(GerritNav, 'getUrlForHashtag')
+        .returns('/q/hashtag:test+(status:open%20OR%20status:merged)');
+      await flush();
+      const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
+      const button = queryAndAssert<GrButton>(chip, 'gr-button');
+      assert.isFalse(button.hasAttribute('hidden'));
     });
   });
 
@@ -798,7 +802,7 @@
           test: {
             all: [{_account_id: 1 as AccountId, name: 'bojack', value: 1}],
             default_value: 0,
-            values: ([] as unknown) as LabelValueToDescriptionMap,
+            values: [] as unknown as LabelValueToDescriptionMap,
           },
         },
         removable_reviewers: [],
@@ -881,13 +885,15 @@
       });
     });
 
-    test('topic removal', () => {
+    test('topic removal', async () => {
       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;
+      sinon.stub(GerritNav, 'getUrlForTopic').returns('/q/topic:the+new+topic');
+      await flush();
+      const chip = queryAndAssert<GrLinkedChip>(element, 'gr-linked-chip');
+      const remove = queryAndAssert(chip, '#remove');
       const topicChangedSpy = sinon.spy();
       element.addEventListener('topic-changed', topicChangedSpy);
       tap(remove);
@@ -900,8 +906,8 @@
       });
     });
 
-    test('changing hashtag', () => {
-      flush();
+    test('changing hashtag', async () => {
+      await flush();
       element._newHashtag = 'new hashtag' as Hashtag;
       const newHashtag: Hashtag[] = ['new hashtag' as Hashtag];
       const setChangeHashtagStub = stubRestApi('setChangeHashtag').returns(
@@ -919,13 +925,13 @@
     });
   });
 
-  test('editTopic', () => {
+  test('editTopic', async () => {
     element.account = createAccountDetailWithId();
     element.change = {
       ...createParsedChange(),
       actions: {topic: {enabled: true}},
     };
-    flush();
+    await flush();
 
     const label = element.shadowRoot!.querySelector(
       '.topicEditableLabel'
@@ -933,13 +939,13 @@
     assert.ok(label);
     const openStub = sinon.stub(label, 'open');
     element.editTopic();
-    flush();
+    await flush();
 
     assert.isTrue(openStub.called);
   });
 
   suite('plugin endpoints', () => {
-    test('endpoint params', done => {
+    test('endpoint params', async () => {
       element.change = createParsedChange();
       element.revision = createRevision();
       interface MetadataGrEndpointDecorator extends GrEndpointDecorator {
@@ -961,12 +967,10 @@
         'http://some/plugins/url.js'
       );
       getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl!.plugin, plugin);
-        assert.strictEqual(hookEl!.change, element.change);
-        assert.strictEqual(hookEl!.revision, element.revision);
-        done();
-      });
+      await flush();
+      assert.strictEqual(hookEl!.plugin, plugin!);
+      assert.strictEqual(hookEl!.change, element.change);
+      assert.strictEqual(hookEl!.revision, element.revision);
     });
   });
 });
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 1d7db85..725bb24 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
@@ -15,9 +15,9 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
+import '../../../styles/gr-font-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-icons/gr-icons';
-import '../../shared/gr-label/gr-label';
 import '../../shared/gr-label-info/gr-label-info';
 import '../../shared/gr-limited-text/gr-limited-text';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
@@ -35,6 +35,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {appContext} from '../../../services/app-context';
 import {labelCompare} from '../../../utils/label-util';
+import {Interaction} from '../../../constants/reporting';
 
 interface ChangeRequirement extends Requirement {
   satisfied: boolean;
@@ -47,7 +48,7 @@
   tooltip: string;
 }
 
-interface Label {
+export interface Label {
   labelName: string;
   labelInfo: LabelInfo;
   icon: string;
@@ -55,7 +56,7 @@
 }
 
 @customElement('gr-change-requirements')
-class GrChangeRequirements extends PolymerElement {
+export class GrChangeRequirements extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -181,7 +182,7 @@
 
   _handleShowHide() {
     this._showOptionalLabels = !this._showOptionalLabels;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'optional labels',
       toState: this._showOptionalLabels ? 'Show all' : 'Show less',
     });
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 d824d94..6991c03 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: table;
@@ -104,7 +107,7 @@
       padding-left: 0;
     }
   </style>
-  <h3 class="metadata-title">Submit requirements</h3>
+  <h3 class="metadata-title heading-3">Submit requirements</h3>
   <template is="dom-repeat" items="[[_requirements]]">
     <gr-endpoint-decorator
       class="submit-requirement-endpoints"
@@ -151,6 +154,7 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
+          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
@@ -201,6 +205,7 @@
           mutable="[[mutable]]"
           label="[[item.labelName]]"
           label-info="[[item.labelInfo]]"
+          showAlwaysOldUI
         ></gr-label-info>
       </div>
     </section>
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
index 89f7b89..ad8f72f 100644
--- 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
@@ -14,40 +14,46 @@
  * 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 {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {subscribe} from '../../lit/subscription-controller';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {appContext} from '../../../services/app-context';
 import {
+  allRunsLatestPatchsetLatestAttempt$,
+  aPluginHasRegistered$,
   CheckResult,
   CheckRun,
-  aPluginHasRegistered$,
-  someProvidersAreLoading$,
-  allRunsLatest$,
-  errorMessage$,
-  loginCallback$,
+  ErrorMessages,
+  errorMessagesLatest$,
+  loginCallbackLatest$,
+  someProvidersAreLoadingFirstTime$,
+  topLevelActionsLatest$,
 } from '../../../services/checks/checks-model';
-import {Category, Link, RunStatus} from '../../../api/checks';
+import {Action, Category, Link, RunStatus} from '../../../api/checks';
 import {fireShowPrimaryTab} from '../../../utils/event-util';
 import '../../shared/gr-avatar/gr-avatar';
+import '../../checks/gr-checks-action';
 import {
+  firstPrimaryLink,
   getResultsOf,
   hasCompletedWithoutResults,
+  hasResults,
   hasResultsOf,
-  iconForCategory,
-  iconForStatus,
+  iconFor,
   isRunning,
   isRunningOrHasCompleted,
+  isStatus,
+  labelFor,
 } from '../../../services/checks/checks-util';
 import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
 import {
   CommentThread,
-  isResolved,
-  isUnresolved,
   getFirstComment,
-  isRobotThread,
   hasHumanReply,
+  isResolved,
+  isRobotThread,
+  isUnresolved,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
 import {AccountInfo} from '../../../types/common';
@@ -55,6 +61,10 @@
 import {uniqueDefinedAvatar} from '../../../utils/account-util';
 import {PrimaryTab} from '../../../constants/constants';
 import {ChecksTabState, CommentTabState} from '../../../types/events';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {modifierPressed} from '../../../utils/dom-util';
+import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 export enum SummaryChipStyles {
   INFO = 'info',
@@ -63,8 +73,17 @@
   UNDEFINED = '',
 }
 
+function handleSpaceOrEnter(e: KeyboardEvent, handler: () => void) {
+  if (modifierPressed(e)) return;
+  // Only react to `return` and `space`.
+  if (e.keyCode !== 13 && e.keyCode !== 32) return;
+  e.preventDefault();
+  e.stopPropagation();
+  handler();
+}
+
 @customElement('gr-summary-chip')
-export class GrSummaryChip extends GrLitElement {
+export class GrSummaryChip extends LitElement {
   @property()
   icon = '';
 
@@ -76,9 +95,10 @@
 
   private readonly reporting = appContext.reportingService;
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
+      fontStyles,
       css`
         .summaryChip {
           color: var(--chip-color);
@@ -90,6 +110,10 @@
           border-radius: 12px;
           border: 1px solid gray;
           vertical-align: top;
+          /* centered position of 20px chips in 24px line-height inline flow */
+          vertical-align: top;
+          position: relative;
+          top: 2px;
         }
         iron-icon {
           width: var(--line-height-small);
@@ -104,6 +128,9 @@
           background: var(--warning-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .summaryChip.warning:focus-within {
+          background: var(--warning-background-focus);
+        }
         .summaryChip.warning iron-icon {
           color: var(--warning-foreground);
         }
@@ -115,6 +142,9 @@
           background: var(--gray-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .summaryChip.check:focus-within {
+          background: var(--gray-background-focus);
+        }
         .summaryChip.check iron-icon {
           color: var(--gray-foreground);
         }
@@ -122,7 +152,7 @@
     ];
   }
 
-  render() {
+  override render() {
     const chipClass = `summaryChip font-small ${this.styleType}`;
     const grIcon = this.icon ? `gr-icons:${this.icon}` : '';
     return html`<button class="${chipClass}" @click="${this.handleClick}">
@@ -144,19 +174,25 @@
 }
 
 @customElement('gr-checks-chip')
-export class GrChecksChip extends GrLitElement {
+export class GrChecksChip extends LitElement {
   @property()
-  icon = '';
+  statusOrCategory?: Category | RunStatus;
 
   @property()
   text = '';
 
-  static get styles() {
+  @property()
+  links: Link[] = [];
+
+  static override get styles() {
     return [
+      fontStyles,
       sharedStyles,
       css`
         :host {
           display: inline-block;
+          position: relative;
+          white-space: nowrap;
         }
         .checksChip {
           color: var(--chip-color);
@@ -167,7 +203,21 @@
             var(--spacing-s);
           border-radius: 12px;
           border: 1px solid gray;
+          /* centered position of 20px chips in 24px line-height inline flow */
           vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .checksChip.hoverFullLength {
+          position: absolute;
+          z-index: 1;
+          display: none;
+        }
+        .checksChip.hoverFullLength .text {
+          max-width: 400px;
+        }
+        :host(:hover) .checksChip.hoverFullLength {
+          display: inline-block;
         }
         .checksChip .text {
           display: inline-block;
@@ -191,6 +241,9 @@
           background: var(--error-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.error:focus-within {
+          background: var(--error-background-focus);
+        }
         .checksChip.error iron-icon {
           color: var(--error-foreground);
         }
@@ -202,6 +255,9 @@
           background: var(--warning-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.warning:focus-within {
+          background: var(--warning-background-focus);
+        }
         .checksChip.warning iron-icon {
           color: var(--warning-foreground);
         }
@@ -213,6 +269,9 @@
           background: var(--info-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.info-outline:focus-within {
+          background: var(--info-background-focus);
+        }
         .checksChip.info-outline iron-icon {
           color: var(--info-foreground);
         }
@@ -224,12 +283,13 @@
           background: var(--success-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.check-circle-outline:focus-within {
+          background: var(--success-background-focus);
+        }
         .checksChip.check-circle-outline iron-icon {
           color: var(--success-foreground);
         }
         .checksChip.timelapse {
-        }
-        .checksChip.timelapse {
           border-color: var(--gray-foreground);
           background: var(--gray-background);
         }
@@ -237,6 +297,9 @@
           background: var(--gray-background-hover);
           box-shadow: var(--elevation-level-1);
         }
+        .checksChip.timelapse:focus-within {
+          background: var(--gray-background-focus);
+        }
         .checksChip.timelapse iron-icon {
           color: var(--gray-foreground);
         }
@@ -244,25 +307,74 @@
     ];
   }
 
-  render() {
+  override render() {
     if (!this.text) return;
-    const chipClass = `checksChip font-small ${this.icon}`;
-    const grIcon = `gr-icons:${this.icon}`;
+    if (!this.statusOrCategory) return;
+    const icon = iconFor(this.statusOrCategory);
+    const label = labelFor(this.statusOrCategory);
+    const count = Number(this.text);
+    let ariaLabel = label;
+    if (!isNaN(count)) {
+      const type = isStatus(this.statusOrCategory) ? 'run' : 'result';
+      const plural = count > 1 ? 's' : '';
+      ariaLabel = `${this.text} ${label} ${type}${plural}`;
+    }
+    const chipClass = `checksChip font-small ${icon}`;
+    const chipClassFullLength = `${chipClass} hoverFullLength`;
+    const grIcon = `gr-icons:${icon}`;
+    // 15 is roughly the number of chars for the chip exceeding its 120px width.
     return html`
-      <div class="${chipClass}" role="button">
-        <iron-icon icon="${grIcon}"></iron-icon>
+      ${this.text.length > 15
+        ? html` ${this.renderChip(chipClassFullLength, ariaLabel, grIcon)}`
+        : ''}
+      ${this.renderChip(chipClass, ariaLabel, grIcon)}
+    `;
+  }
+
+  private renderChip(clazz: string, ariaLabel: string, icon: string) {
+    return html`
+      <div class="${clazz}" role="link" tabindex="0" aria-label="${ariaLabel}">
+        <iron-icon icon="${icon}"></iron-icon>
         <div class="text">${this.text}</div>
-        <slot></slot>
+        ${this.renderLinks()}
       </div>
     `;
   }
+
+  private renderLinks() {
+    return this.links.map(
+      link => html`
+        <a
+          href="${link.url}"
+          target="_blank"
+          @click="${this.onLinkClick}"
+          @keydown="${this.onLinkKeyDown}"
+          aria-label="Link to check details"
+          ><iron-icon class="launch" icon="gr-icons:launch"></iron-icon
+        ></a>
+      `
+    );
+  }
+
+  private onLinkKeyDown(e: KeyboardEvent) {
+    // Prevents onChipKeyDown() from reacting to <a> link keyboard events.
+    e.stopPropagation();
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents onChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
 }
 
-/** What is the maximum number of expanded checks chips? */
-const DETAILS_QUOTA = 3;
+/** What is the maximum number of detailed checks chips? */
+const DETAILS_QUOTA: Map<RunStatus | Category, number> = new Map();
+DETAILS_QUOTA.set(Category.ERROR, 7);
+DETAILS_QUOTA.set(Category.WARNING, 2);
+DETAILS_QUOTA.set(RunStatus.RUNNING, 2);
 
 @customElement('gr-change-summary')
-export class GrChangeSummary extends GrLitElement {
+export class GrChangeSummary extends LitElement {
   @property({type: Object})
   changeComments?: ChangeComments;
 
@@ -282,59 +394,95 @@
   someProvidersAreLoading = false;
 
   @property()
-  errorMessage?: string;
+  errorMessages: ErrorMessages = {};
 
   @property()
   loginCallback?: () => void;
 
-  /** Is reset when rendering beings and decreases while chips are rendered. */
-  private detailsQuota = DETAILS_QUOTA;
+  @property()
+  actions: Action[] = [];
+
+  private showAllChips = new Map<RunStatus | Category, boolean>();
+
+  private checksService = appContext.checksService;
 
   constructor() {
     super();
-    this.subscribe('runs', allRunsLatest$);
-    this.subscribe('showChecksSummary', aPluginHasRegistered$);
-    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
-    this.subscribe('errorMessage', errorMessage$);
-    this.subscribe('loginCallback', loginCallback$);
+    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+    subscribe(this, aPluginHasRegistered$, x => (this.showChecksSummary = x));
+    subscribe(
+      this,
+      someProvidersAreLoadingFirstTime$,
+      x => (this.someProvidersAreLoading = x)
+    );
+    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
+    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
+    subscribe(this, topLevelActionsLatest$, x => (this.actions = x));
   }
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
+      spinnerStyles,
       css`
         :host {
           display: block;
           color: var(--deemphasized-text-color);
-          max-width: 650px;
+          max-width: 625px;
           margin-bottom: var(--spacing-m);
         }
         .zeroState {
+          color: var(--deemphasized-text-color);
+        }
+        .loading.zeroState {
+          margin-right: var(--spacing-m);
+        }
+        div.error,
+        .login {
+          display: flex;
           color: var(--primary-text-color);
+          padding: 0 var(--spacing-s);
+          margin: var(--spacing-xs) 0;
+          width: 490px;
         }
         div.error {
           background-color: var(--error-background);
-          display: flex;
-          padding: var(--spacing-s);
         }
         div.error iron-icon {
           color: var(--error-foreground);
           width: 16px;
           height: 16px;
           position: relative;
-          top: 2px;
+          top: 4px;
           margin-right: var(--spacing-s);
         }
+        div.error .right {
+          overflow: hidden;
+        }
+        div.error .right .message {
+          overflow: hidden;
+          text-overflow: ellipsis;
+          white-space: nowrap;
+        }
+        .login {
+          justify-content: space-between;
+          background: var(--info-background);
+        }
+        .login iron-icon {
+          color: var(--info-foreground);
+        }
         .login gr-button {
           margin: -4px var(--spacing-s);
         }
         td.key {
           padding-right: var(--spacing-l);
-          padding-bottom: var(--spacing-m);
+          padding-bottom: var(--spacing-s);
+          line-height: calc(var(--line-height-normal) + var(--spacing-s));
         }
         td.value {
           padding-right: var(--spacing-l);
-          padding-bottom: var(--spacing-m);
+          padding-bottom: var(--spacing-s);
+          line-height: calc(var(--line-height-normal) + var(--spacing-s));
         }
         iron-icon.launch {
           color: var(--gray-foreground);
@@ -348,107 +496,216 @@
           vertical-align: top;
           margin-right: var(--spacing-xs);
         }
+        /* The basics of .loadingSpin are defined in shared styles. */
+        .loadingSpin {
+          width: calc(var(--line-height-normal) - 2px);
+          height: calc(var(--line-height-normal) - 2px);
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          /* Making up for the 2px reduced height above. */
+          top: 1px;
+        }
+        .actions {
+          margin-left: calc(0px - var(--spacing-m));
+          line-height: var(--line-height-normal);
+        }
+        .actions gr-checks-action,
+        .actions gr-dropdown {
+          vertical-align: top;
+          --gr-button-padding: 0 var(--spacing-m);
+        }
+        .actions #moreMessage {
+          display: none;
+        }
       `,
     ];
   }
 
-  renderChecksError() {
-    if (!this.errorMessage) return;
+  private renderActions() {
+    const actions = this.actions ?? [];
+    const summaryActions = actions.filter(a => a.summary).slice(0, 2);
+    if (summaryActions.length === 0) return;
+    const topActions = summaryActions.slice(0, 2);
+    const overflowActions = summaryActions.slice(2).map(action => {
+      return {...action, id: action.name};
+    });
+    const disabledActionIds = overflowActions
+      .filter(action => action.disabled)
+      .map(action => action.id);
+
     return html`
-      <div class="error zeroState">
-        <div class="left">
-          <iron-icon icon="gr-icons:error"></iron-icon>
-        </div>
-        <div class="right">
-          <div>Error while fetching check results</div>
-          <div>${this.errorMessage}</div>
-        </div>
+      <div class="actions">
+        ${topActions.map(this.renderAction)}
+        ${this.renderOverflow(overflowActions, disabledActionIds)}
       </div>
     `;
   }
 
-  renderChecksLogin() {
-    if (this.errorMessage || !this.loginCallback) return;
+  private renderAction(action?: Action) {
+    if (!action) return;
+    return html`<gr-checks-action .action="${action}"></gr-checks-action>`;
+  }
+
+  private handleAction(e: CustomEvent<Action>) {
+    this.checksService.triggerAction(e.detail);
+  }
+
+  private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
+    if (items.length === 0) return;
     return html`
-      <div class="login zeroState">
-        Not logged in
-        <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+      <gr-dropdown
+        id="moreActions"
+        link=""
+        vertical-offset="32"
+        horizontal-align="right"
+        @tap-item="${this.handleAction}"
+        .items="${items}"
+        .disabledIds="${disabledIds}"
+      >
+        <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
+        </iron-icon>
+        <span id="moreMessage">More</span>
+      </gr-dropdown>
+    `;
+  }
+
+  renderErrorMessages() {
+    return Object.entries(this.errorMessages).map(
+      ([plugin, message]) =>
+        html`
+          <div class="error zeroState">
+            <div class="left">
+              <iron-icon icon="gr-icons:error"></iron-icon>
+            </div>
+            <div class="right">
+              <div class="message" title="${message}">
+                Error while fetching results for ${plugin}: ${message}
+              </div>
+            </div>
+          </div>
+        `
+    );
+  }
+
+  renderChecksLogin() {
+    if (!this.loginCallback) return;
+    return html`
+      <div class="login">
+        <div class="left">
+          <iron-icon
+            class="info-outline"
+            icon="gr-icons:info-outline"
+          ></iron-icon>
+          Not logged in
+        </div>
+        <div class="right">
+          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+        </div>
       </div>
     `;
   }
 
   renderChecksZeroState() {
-    if (this.errorMessage || this.loginCallback) return;
+    if (Object.keys(this.errorMessages).length > 0) return;
+    if (this.loginCallback) return;
     if (this.runs.some(isRunningOrHasCompleted)) return;
-    const msg = this.someProvidersAreLoading ? 'Loading...' : 'No results';
-    return html`<div class="loading zeroState">${msg}</div>`;
+    const msg = this.someProvidersAreLoading ? 'Loading results' : 'No results';
+    return html`<span role="status" class="loading zeroState">${msg}</span>`;
   }
 
   renderChecksChipForCategory(category: Category) {
-    if (this.errorMessage || this.loginCallback) return;
-    const icon = iconForCategory(category);
-    const runs = this.runs.filter(run => hasResultsOf(run, category));
+    const runs = this.runs.filter(run => {
+      if (hasResultsOf(run, category)) return true;
+      return category === Category.SUCCESS && hasCompletedWithoutResults(run);
+    });
     const count = (run: CheckRun) => getResultsOf(run, category);
-    return this.renderChecksChip(icon, runs, category, count);
+    if (category === Category.SUCCESS || category === Category.INFO) {
+      return this.renderChecksChipsCollapsed(runs, category, count);
+    }
+    return this.renderChecksChipsExpanded(runs, category, count);
   }
 
-  renderChecksChipForStatus(
-    status: RunStatus,
-    filter: (run: CheckRun) => boolean
-  ) {
-    if (this.errorMessage || this.loginCallback) return;
-    const icon = iconForStatus(status);
-    const runs = this.runs.filter(filter);
-    return this.renderChecksChip(icon, runs, status, () => []);
+  renderChecksChipRunning() {
+    const runs = this.runs.filter(isRunning);
+    return this.renderChecksChipsExpanded(runs, RunStatus.RUNNING, () => []);
   }
 
-  renderChecksChip(
-    icon: string,
+  renderChecksChipsExpanded(
     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) => 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(
+    if (runs.length === 0) return;
+    const showAll = this.showAllChips.get(statusOrCategory) ?? false;
+    let count = showAll ? 999 : DETAILS_QUOTA.get(statusOrCategory) ?? 2;
+    if (count === runs.length - 1) count = runs.length;
+    const more = runs.length - count;
+    return html`${runs
+      .slice(0, count)
+      .map(run =>
+        this.renderChecksChipDetailed(run, statusOrCategory, resultFilter)
+      )}${this.renderChecksChipPlusMore(statusOrCategory, more)}`;
+  }
+
+  private renderChecksChipsCollapsed(
+    runs: CheckRun[],
+    statusOrCategory: RunStatus | Category,
+    resultFilter: (run: CheckRun) => CheckResult[]
+  ) {
+    const count = runs.reduce(
       (sum, run) => sum + (resultFilter(run).length || 1),
       0
     );
-    if (sum === 0) return;
+    if (count === 0) return;
+    const handler = () => this.onChipClick({statusOrCategory});
     return html`<gr-checks-chip
-      class="${icon}"
-      .icon="${icon}"
-      .text="${sum}"
-      @click="${() => this.onChipClick({statusOrCategory})}"
+      .statusOrCategory="${statusOrCategory}"
+      .text="${`${count}`}"
+      @click="${handler}"
+      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+    ></gr-checks-chip>`;
+  }
+
+  private renderChecksChipPlusMore(
+    statusOrCategory: RunStatus | Category,
+    count: number
+  ) {
+    if (count <= 0) return;
+    if (this.showAllChips.get(statusOrCategory) === true) return;
+    const handler = () => {
+      this.showAllChips.set(statusOrCategory, true);
+      this.requestUpdate();
+    };
+    return html`<gr-checks-chip
+      .statusOrCategory="${statusOrCategory}"
+      .text="+ ${count} more"
+      @click="${handler}"
+      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
+    ></gr-checks-chip>`;
+  }
+
+  private renderChecksChipDetailed(
+    run: CheckRun,
+    statusOrCategory: RunStatus | Category,
+    resultFilter: (run: CheckRun) => CheckResult[]
+  ) {
+    const allPrimaryLinks = resultFilter(run)
+      .map(firstPrimaryLink)
+      .filter(notUndefined);
+    const links = allPrimaryLinks.length === 1 ? allPrimaryLinks : [];
+    const text = `${run.checkName}`;
+    const tabState: ChecksTabState = {
+      checkName: run.checkName,
+      statusOrCategory,
+    };
+    const handler = () => this.onChipClick(tabState);
+    return html`<gr-checks-chip
+      .statusOrCategory="${statusOrCategory}"
+      .text="${text}"
+      .links="${links}"
+      @click="${handler}"
+      @keydown="${(e: KeyboardEvent) => handleSpaceOrEnter(e, handler)}"
     ></gr-checks-chip>`;
   }
 
@@ -458,13 +715,7 @@
     });
   }
 
-  private onLinkClick(e: MouseEvent) {
-    // Prevents onChipClick() from reacting to <a> link clicks.
-    e.stopPropagation();
-  }
-
-  render() {
-    this.detailsQuota = DETAILS_QUOTA;
+  override render() {
     const commentThreads =
       this.commentThreads?.filter(t => !isRobotThread(t) || hasHumanReply(t)) ??
       [];
@@ -473,23 +724,34 @@
     const countUnresolvedComments = unresolvedThreads.length;
     const unresolvedAuthors = this.getAccounts(unresolvedThreads);
     const draftCount = this.changeComments?.computeDraftCount() ?? 0;
+    const hasNonRunningChip = this.runs.some(
+      run => hasCompletedWithoutResults(run) || hasResults(run)
+    );
+    const hasRunningChip = this.runs.some(isRunning);
     return html`
       <div>
         <table>
           <tr ?hidden=${!this.showChecksSummary}>
             <td class="key">Checks</td>
             <td class="value">
-              ${this.renderChecksError()}${this.renderChecksLogin()}
-              ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
-                Category.ERROR
-              )}${this.renderChecksChipForCategory(
-                Category.WARNING
-              )}${this.renderChecksChipForCategory(
-                Category.INFO
-              )}${this.renderChecksChipForStatus(
-                RunStatus.COMPLETED,
-                hasCompletedWithoutResults
-              )}${this.renderChecksChipForStatus(RunStatus.RUNNING, isRunning)}
+              <div class="checksSummary">
+                ${this.renderChecksZeroState()}${this.renderChecksChipForCategory(
+                  Category.ERROR
+                )}${this.renderChecksChipForCategory(
+                  Category.WARNING
+                )}${this.renderChecksChipForCategory(
+                  Category.INFO
+                )}${this.renderChecksChipForCategory(
+                  Category.SUCCESS
+                )}${hasNonRunningChip && hasRunningChip
+                  ? html`<br />`
+                  : ''}${this.renderChecksChipRunning()}
+                <span
+                  class="loadingSpin"
+                  ?hidden="${!this.someProvidersAreLoading}"
+                ></span>
+                ${this.renderErrorMessages()}${this.renderChecksLogin()}${this.renderActions()}
+              </div>
             </td>
           </tr>
           <tr>
@@ -518,7 +780,7 @@
                   account =>
                     html`<gr-avatar
                       .account="${account}"
-                      image-size="32"
+                      imageSize="32"
                     ></gr-avatar>`
                 )}
                 ${countUnresolvedComments} unresolved</gr-summary-chip
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 180f2a2..b635d3f 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '@polymer/paper-tabs/paper-tabs';
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../diff/gr-comment-api/gr-comment-api';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
@@ -23,7 +24,6 @@
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-change-star/gr-change-star';
 import '../../shared/gr-change-status/gr-change-status';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-editable-content/gr-editable-content';
 import '../../shared/gr-linked-text/gr-linked-text';
 import '../../shared/gr-overlay/gr-overlay';
@@ -48,36 +48,48 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
+  ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {pluralize} from '../../../utils/string-util';
-import {windowLocationReload} from '../../../utils/dom-util';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {querySelectorAll, windowLocationReload} from '../../../utils/dom-util';
+import {
+  GeneratedWebLink,
+  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 {
+  ChangeStatus,
+  DefaultBase,
+  PrimaryTab,
+  SecondaryTab,
+} from '../../../constants/constants';
 
 import {NO_ROBOT_COMMENTS_THREADS_MSG} from '../../../constants/messages';
 import {appContext} from '../../../services/app-context';
-import {ChangeStatus} from '../../../constants/constants';
 import {
   computeAllPatchSets,
   computeLatestPatchNum,
-  fetchChangeUpdates,
   hasEditBasedOnCurrentPatchSet,
   hasEditPatchsetLoaded,
   PatchSet,
 } from '../../../utils/patch-set-util';
-import {changeStatuses} from '../../../utils/change-util';
 import {
   changeIsAbandoned,
   changeIsMerged,
   changeIsOpen,
+  changeStatuses,
+  isCc,
+  isInvolved,
+  isOwner,
+  isReviewer,
 } from '../../../utils/change-util';
 import {EventType as PluginEventType} from '../../../api/plugin';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, observe, property} from '@polymer/decorators';
 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';
@@ -87,59 +99,58 @@
 import {GrChangeActions} from '../gr-change-actions/gr-change-actions';
 import {
   AccountDetailInfo,
-  ChangeInfo,
-  NumericChangeId,
-  PatchRange,
   ActionNameToActionInfoMap,
-  CommitId,
-  PatchSetNum,
-  ParentPatchSetNum,
-  EditPatchSetNum,
-  ServerInfo,
-  ConfigInfo,
-  PreferencesInfo,
-  CommitInfo,
-  RevisionInfo,
-  EditInfo,
-  LabelNameToInfoMap,
-  UrlEncodedCommentId,
-  QuickLabelInfo,
   ApprovalInfo,
-  ElementPropertyDeepChange,
+  BasePatchSetNum,
   ChangeId,
+  ChangeInfo,
+  CommitId,
+  CommitInfo,
+  ConfigInfo,
+  EditInfo,
+  EditPatchSetNum,
+  LabelNameToInfoMap,
+  NumericChangeId,
+  ParentPatchSetNum,
+  PatchRange,
+  PatchSetNum,
+  PreferencesInfo,
+  QuickLabelInfo,
   RelatedChangeAndCommitInfo,
   RelatedChangesInfo,
-  BasePatchSetNum,
+  RevisionInfo,
+  ServerInfo,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
-import {GrReplyDialog, FocusTarget} from '../gr-reply-dialog/gr-reply-dialog';
+import {FocusTarget, GrReplyDialog} from '../gr-reply-dialog/gr-reply-dialog';
 import {GrIncludedInDialog} from '../gr-included-in-dialog/gr-included-in-dialog';
 import {GrDownloadDialog} from '../gr-download-dialog/gr-download-dialog';
 import {GrChangeMetadata} from '../gr-change-metadata/gr-change-metadata';
 import {
-  GrCommentApi,
   ChangeComments,
+  GrCommentApi,
 } from '../../diff/gr-comment-api/gr-comment-api';
 import {assertIsDefined, hasOwnProperty} from '../../../utils/common-util';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {
   CommentThread,
-  UIDraft,
-  DraftInfo,
   isDraftThread,
   isRobot,
+  isUnresolved,
+  UIDraft,
 } from '../../../utils/comment-util';
 import {
   PolymerDeepPropertyChange,
-  PolymerSpliceChange,
   PolymerSplice,
+  PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
 import {AppElementChangeViewParams} from '../../gr-app-types';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {
-  GrFileList,
   DEFAULT_NUM_FILES_SHOWN,
+  GrFileList,
 } from '../gr-file-list/gr-file-list';
 import {
   ChangeViewState,
@@ -148,43 +159,56 @@
   ParsedChangeInfo,
 } from '../../../types/types';
 import {
-  CustomKeyboardEvent,
+  CloseFixPreviewEvent,
   EditableContentSaveEvent,
+  EventType,
   OpenFixPreviewEvent,
   ShowAlertEventDetail,
   SwitchTabEvent,
-  ThreadListModifiedEvent,
   TabState,
-  EventType,
-  CloseFixPreviewEvent,
 } 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 {
   fireAlert,
+  fireDialogChange,
   fireEvent,
   firePageError,
-  fireDialogChange,
+  fireReload,
   fireTitleChange,
 } from '../../../utils/event-util';
-import {GerritView} from '../../../services/router/router-model';
+import {GerritView, routerView$} from '../../../services/router/router-model';
 import {takeUntil} from 'rxjs/operators';
 import {aPluginHasRegistered$} from '../../../services/checks/checks-model';
 import {Subject} from 'rxjs';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-import {Timing} from '../../../constants/reporting';
+import {debounce, DelayedTask, throttleWrap} from '../../../utils/async-util';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
+import {getRevertCreatedChangeIds} from '../../../utils/message-util';
+import {
+  changeComments$,
+  drafts$,
+} from '../../../services/comments/comments-model';
+import {
+  getAddedByReason,
+  getRemovedByReason,
+  hasAttention,
+} from '../../../utils/attention-set-util';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
 
-const MIN_LINES_FOR_COMMIT_COLLAPSE = 17;
+const MIN_LINES_FOR_COMMIT_COLLAPSE = 18;
 
 const REVIEWERS_REGEX = /^(R|CC)=/gm;
 const MIN_CHECK_INTERVAL_SECS = 0;
 
 const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
+const ACCIDENTAL_STARRING_LIMIT_MS = 10 * 1000;
+
 const TRAILING_WHITESPACE_REGEX = /[ \t]+$/gm;
 
-const MSG_PREFIX = '#message-';
+const PREFIX = '#message-';
 
 const ReloadToastMessage = {
   NEWER_REVISION: 'A newer patch set has been uploaded',
@@ -223,8 +247,11 @@
 
 export type ChangeViewPatchRange = Partial<PatchRange>;
 
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = KeyboardShortcutMixin(PolymerElement);
+
 @customElement('gr-change-view')
-export class GrChangeView extends KeyboardShortcutMixin(PolymerElement) {
+export class GrChangeView extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -268,21 +295,9 @@
   @property({type: Boolean})
   hasParent?: boolean;
 
-  @property({type: Object})
-  keyEventTarget = document.body;
-
   @property({type: Boolean})
   disableEdit = false;
 
-  @property({type: Boolean})
-  disableDiffPrefs = false;
-
-  @property({
-    type: Boolean,
-    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-  })
-  _diffPrefsDisabled?: boolean;
-
   @property({type: Array})
   _commentThreads?: CommentThread[];
 
@@ -406,7 +421,7 @@
 
   @property({
     type: String,
-    computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
+    computed: '_computeReplyButtonLabel(_diffDrafts, _canStartReview)',
   })
   _replyButtonLabel = 'Reply';
 
@@ -426,7 +441,7 @@
     type: String,
     computed: '_computeChangeStatusChips(_change, _mergeable, _submitEnabled)',
   })
-  _changeStatuses?: string[];
+  _changeStatuses?: ChangeStates[];
 
   /** If false, then the "Show more" button was used to expand. */
   @property({type: Boolean})
@@ -503,43 +518,89 @@
   _activeTabs: string[] = [PrimaryTab.FILES, SecondaryTab.CHANGE_LOG];
 
   @property({type: Boolean})
+  unresolvedOnly = false;
+
+  @property({type: Boolean})
   _showAllRobotComments = false;
 
   @property({type: Boolean})
   _showRobotCommentsButton = false;
 
-  _throttledToggleChangeStar?: EventListener;
+  _throttledToggleChangeStar?: (e: KeyboardEvent) => void;
 
   @property({type: Boolean})
   _showChecksTab = false;
 
+  @property({type: Boolean})
+  private isViewCurrent = false;
+
   @property({type: String})
   _tabState?: TabState;
 
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
+  @property({type: String})
+  scrollCommentId?: UrlEncodedCommentId;
+
+  @property({
+    type: Array,
+    computed: '_computeResolveWeblinks(_change, _commitInfo, _serverConfig)',
+  })
+  resolveWeblinks?: GeneratedWebLink[];
+
   restApiService = appContext.restApiService;
 
-  checksService = appContext.checksService;
+  private readonly commentsService = appContext.commentsService;
 
-  keyboardShortcuts() {
-    return {
-      [Shortcut.SEND_REPLY]: null, // DOC_ONLY binding
-      [Shortcut.EMOJI_DROPDOWN]: null, // DOC_ONLY binding
-      [Shortcut.REFRESH_CHANGE]: '_handleRefreshChange',
-      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialogShortcut',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_CHANGE_STAR]: '_throttledToggleChangeStar',
-      [Shortcut.UP_TO_DASHBOARD]: '_handleUpToDashboard',
-      [Shortcut.EXPAND_ALL_MESSAGES]: '_handleExpandAllMessages',
-      [Shortcut.COLLAPSE_ALL_MESSAGES]: '_handleCollapseAllMessages',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleOpenDiffPrefsShortcut',
-      [Shortcut.EDIT_TOPIC]: '_handleEditTopic',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
-    };
+  private readonly shortcuts = appContext.shortcutsService;
+
+  private replyDialogResizeObserver?: ResizeObserver;
+
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.SEND_REPLY, _ => {}), // docOnly
+      listen(Shortcut.EMOJI_DROPDOWN, _ => {}), // docOnly
+      listen(Shortcut.REFRESH_CHANGE, _ => fireReload(this, true)),
+      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
+      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
+        this._handleOpenDownloadDialog()
+      ),
+      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
+      listen(Shortcut.TOGGLE_CHANGE_STAR, e => {
+        if (this._throttledToggleChangeStar) {
+          this._throttledToggleChangeStar(e);
+        }
+      }),
+      listen(Shortcut.UP_TO_DASHBOARD, _ => this._determinePageBack()),
+      listen(Shortcut.EXPAND_ALL_MESSAGES, _ =>
+        this._handleExpandAllMessages()
+      ),
+      listen(Shortcut.COLLAPSE_ALL_MESSAGES, _ =>
+        this._handleCollapseAllMessages()
+      ),
+      listen(Shortcut.OPEN_DIFF_PREFS, _ =>
+        this._handleOpenDiffPrefsShortcut()
+      ),
+      listen(Shortcut.EDIT_TOPIC, _ => this.$.metadata.editTopic()),
+      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
+      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
+        this._handleDiffAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+        this._handleDiffBaseAgainstLeft()
+      ),
+      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+        this._handleDiffRightAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+        this._handleDiffBaseAgainstLatest()
+      ),
+      listen(Shortcut.OPEN_SUBMIT_DIALOG, _ => this._handleOpenSubmitDialog()),
+      listen(Shortcut.TOGGLE_ATTENTION_SET, _ =>
+        this._handleToggleAttentionSet()
+      ),
+    ];
   }
 
   disconnected$ = new Subject();
@@ -548,12 +609,24 @@
 
   private scrollTask?: DelayedTask;
 
-  /** @override */
-  ready() {
+  private lastStarredTimestamp?: number;
+
+  override ready() {
     super.ready();
     aPluginHasRegistered$.pipe(takeUntil(this.disconnected$)).subscribe(b => {
       this._showChecksTab = b;
     });
+    routerView$.pipe(takeUntil(this.disconnected$)).subscribe(view => {
+      this.isViewCurrent = view === GerritView.CHANGE;
+    });
+    drafts$.pipe(takeUntil(this.disconnected$)).subscribe(drafts => {
+      this._diffDrafts = {...drafts};
+    });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
   }
 
   constructor() {
@@ -573,23 +646,13 @@
       this._handleShowBackgroundContent()
     );
 
-    this.addEventListener('diff-comments-modified', () =>
-      this._handleReloadCommentThreads()
-    );
-
-    this.addEventListener(
-      'thread-list-modified',
-      (e: ThreadListModifiedEvent) => this._handleReloadDiffComments(e)
-    );
-
     this.addEventListener('open-reply-dialog', () => this._openReplyDialog());
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleChangeStar = this._throttleWrap(e =>
-      this._handleToggleChangeStar(e as CustomKeyboardEvent)
+    this._throttledToggleChangeStar = throttleWrap<KeyboardEvent>(_ =>
+      this._handleToggleChangeStar()
     );
     this._getServerConfig().then(config => {
       this._serverConfig = config;
@@ -606,30 +669,28 @@
       this._setDiffViewMode();
     });
 
+    this.replyDialogResizeObserver = new ResizeObserver(() =>
+      this.$.replyOverlay.center()
+    );
+    this.replyDialogResizeObserver.observe(this.$.replyDialog);
+
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
-        this._dynamicTabHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-tab-header'
-        );
-        this._dynamicTabContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-tab-content'
-        );
+        this._dynamicTabHeaderEndpoints =
+          getPluginEndpoints().getDynamicEndpoints('change-view-tab-header');
+        this._dynamicTabContentEndpoints =
+          getPluginEndpoints().getDynamicEndpoints('change-view-tab-content');
         if (
           this._dynamicTabContentEndpoints.length !==
           this._dynamicTabHeaderEndpoints.length
         ) {
-          console.warn('Different number of tab headers and tab content.');
+          this.reporting.error(new Error('Mismatch of headers and content.'));
         }
       })
       .then(() => this._initActiveTabs(this.params));
 
-    this.addEventListener('comment-save', e => this._handleCommentSave(e));
-    this.addEventListener('comment-refresh', () => this._reloadDrafts());
-    this.addEventListener('comment-discard', e =>
-      this._handleCommentDiscard(e)
-    );
-    this.addEventListener('change-message-deleted', () => this._reload());
+    this.addEventListener('change-message-deleted', () => fireReload(this));
     this.addEventListener('editable-content-save', e =>
       this._handleCommitMessageSave(e)
     );
@@ -638,28 +699,21 @@
     );
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
     this.addEventListener('close-fix-preview', e => this._onCloseFixPreview(e));
-    window.addEventListener('scroll', this.handleScroll);
     document.addEventListener('visibilitychange', this.handleVisibilityChange);
 
     this.addEventListener(EventType.SHOW_PRIMARY_TAB, e =>
       this._setActivePrimaryTab(e)
     );
-    this.addEventListener('show-secondary-tab', e =>
-      this._setActiveSecondaryTab(e)
-    );
     this.addEventListener('reload', e => {
-      e.stopPropagation();
-      this._reload(
+      this.loadData(
         /* isLocationChange= */ false,
         /* clearPatchset= */ e.detail && e.detail.clearPatchset
       );
     });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.disconnected$.next();
-    window.removeEventListener('scroll', this.handleScroll);
     document.removeEventListener(
       'visibilitychange',
       this.handleVisibilityChange
@@ -704,15 +758,10 @@
   }
 
   _onCloseFixPreview(e: CloseFixPreviewEvent) {
-    if (e.detail.fixApplied) this._reload();
+    if (e.detail.fixApplied) fireReload(this);
   }
 
-  _handleToggleDiffMode(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleToggleDiffMode() {
     if (this.viewState.diffMode === DiffViewMode.SIDE_BY_SIDE) {
       this.$.fileListHeader.setDiffViewMode(DiffViewMode.UNIFIED);
     } else {
@@ -735,7 +784,8 @@
       activeTabName?: string;
       activeTabIndex?: number;
       scrollIntoView?: boolean;
-    }
+    },
+    src?: string
   ) {
     if (!paperTabs) return;
     const {activeTabName, activeTabIndex, scrollIntoView} = activeDetails;
@@ -755,7 +805,7 @@
       }
     }
     if (activeIndex === -1) {
-      console.warn('tab not found with given info', activeDetails);
+      this.reporting.error(new Error(`tab not found for ${activeDetails}`));
       return;
     }
     const tabName = tabs[activeIndex].dataset['name'];
@@ -765,7 +815,7 @@
     if (paperTabs.selected !== activeIndex) {
       // paperTabs.selected is undefined during rendering
       if (paperTabs.selected !== undefined) {
-        this.reporting.reportInteraction('show-tab', {tabName});
+        this.reporting.reportInteraction(Interaction.SHOW_TAB, {tabName, src});
       }
       paperTabs.selected = activeIndex;
     }
@@ -776,14 +826,17 @@
    * Changes active primary tab.
    */
   _setActivePrimaryTab(e: SwitchTabEvent) {
-    const primaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
-      '#primaryTabs'
+    const primaryTabs =
+      this.shadowRoot!.querySelector<PaperTabsElement>('#primaryTabs');
+    const activeTabName = this._setActiveTab(
+      primaryTabs,
+      {
+        activeTabName: e.detail.tab,
+        activeTabIndex: e.detail.value,
+        scrollIntoView: e.detail.scrollIntoView,
+      },
+      (e.composedPath()?.[0] as Element | undefined)?.tagName
     );
-    const activeTabName = this._setActiveTab(primaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
     if (activeTabName) {
       this._activeTabs = [activeTabName, this._activeTabs[1]];
 
@@ -792,12 +845,10 @@
         activeTabName
       );
       if (pluginIndex !== -1) {
-        this._selectedTabPluginEndpoint = this._dynamicTabContentEndpoints[
-          pluginIndex
-        ];
-        this._selectedTabPluginHeader = this._dynamicTabHeaderEndpoints[
-          pluginIndex
-        ];
+        this._selectedTabPluginEndpoint =
+          this._dynamicTabContentEndpoints[pluginIndex];
+        this._selectedTabPluginHeader =
+          this._dynamicTabHeaderEndpoints[pluginIndex];
       } else {
         this._selectedTabPluginEndpoint = '';
         this._selectedTabPluginHeader = '';
@@ -806,21 +857,28 @@
     this._tabState = e.detail.tabState;
   }
 
-  /**
-   * Changes active secondary tab.
-   */
-  _setActiveSecondaryTab(e: SwitchTabEvent) {
-    const secondaryTabs = this.shadowRoot!.querySelector<PaperTabsElement>(
-      '#secondaryTabs'
-    );
-    const activeTabName = this._setActiveTab(secondaryTabs, {
-      activeTabName: e.detail.tab,
-      activeTabIndex: e.detail.value,
-      scrollIntoView: e.detail.scrollIntoView,
-    });
-    if (activeTabName) {
-      this._activeTabs = [this._activeTabs[0], activeTabName];
+  _onPaperTabClick(e: MouseEvent) {
+    let target = e.target as HTMLElement | null;
+    let tabName: string | undefined;
+    // target can be slot child of papertab, so we search for tabName in parents
+    do {
+      tabName = target?.dataset?.['name'];
+      if (tabName) break;
+      target = target?.parentElement as HTMLElement | null;
+    } while (target);
+
+    if (tabName === PrimaryTab.COMMENT_THREADS) {
+      // Show unresolved threads by default only if they are present
+      const hasUnresolvedThreads =
+        (this._commentThreads ?? []).filter(thread => isUnresolved(thread))
+          .length > 0;
+      if (hasUnresolvedThreads) this.unresolvedOnly = true;
     }
+
+    this.reporting.reportInteraction(Interaction.SHOW_TAB, {
+      tabName,
+      src: 'paper-tab-click',
+    });
   }
 
   _handleCommitMessageSave(e: EditableContentSaveEvent) {
@@ -913,6 +971,14 @@
     }, {} as {[patchset: string]: number});
   }
 
+  /**
+   * Returns `this` as the visibility observer target for the keyboard shortcut
+   * mixin to decide whether shortcuts should be enabled or not.
+   */
+  _computeObserverTarget() {
+    return this;
+  }
+
   _computeText(patch: RevisionInfo, commentThreads: CommentThread[]) {
     const commentCount = this._robotCommentCountPerPatchSet(commentThreads);
     const commentCnt = commentCount[patch._number] || 0;
@@ -976,30 +1042,6 @@
     );
   }
 
-  _handleReloadCommentThreads() {
-    // Get any new drafts that have been saved in the diff view and show
-    // in the comment thread view.
-    this._reloadDrafts().then(() => {
-      this._commentThreads = this._changeComments?.getAllThreadsForChange();
-      flush();
-    });
-  }
-
-  _handleReloadDiffComments(
-    e: CustomEvent<{rootId: UrlEncodedCommentId; path: string}>
-  ) {
-    // Keeps the file list counts updated.
-    this._reloadDrafts().then(() => {
-      // Get any new drafts that have been saved in the thread view and show
-      // in the diff view.
-      this.$.fileList.reloadCommentsForThreadWithRootId(
-        e.detail.rootId,
-        e.detail.path
-      );
-      flush();
-    });
-  }
-
   _computeTotalCommentCounts(
     unresolvedCount: number,
     changeComments: ChangeComments
@@ -1018,79 +1060,6 @@
     );
   }
 
-  _handleCommentSave(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) return;
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    if (!diffDrafts[draft.path]) {
-      diffDrafts[draft.path] = [draft];
-      this._diffDrafts = diffDrafts;
-      return;
-    }
-    for (let i = 0; i < diffDrafts[draft.path].length; i++) {
-      if (diffDrafts[draft.path][i].id === draft.id) {
-        diffDrafts[draft.path][i] = draft;
-        this._diffDrafts = diffDrafts;
-        return;
-      }
-    }
-    diffDrafts[draft.path].push(draft);
-    diffDrafts[draft.path].sort(
-      (c1, c2) =>
-        // No line number means that it’s a file comment. Sort it above the
-        // others.
-        (c1.line || -1) - (c2.line || -1)
-    );
-    this._diffDrafts = diffDrafts;
-  }
-
-  _handleCommentDiscard(e: CustomEvent<{comment: DraftInfo}>) {
-    const draft = e.detail.comment;
-    if (!draft.__draft || !draft.path) {
-      return;
-    }
-
-    if (!this._diffDrafts || !this._diffDrafts[draft.path]) {
-      return;
-    }
-    let index = -1;
-    for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
-      if (this._diffDrafts[draft.path][i].id === draft.id) {
-        index = i;
-        break;
-      }
-    }
-    if (index === -1) {
-      // It may be a draft that hasn’t been added to _diffDrafts since it was
-      // never saved.
-      return;
-    }
-
-    if (!this._patchRange)
-      throw new Error('missing required _patchRange property');
-    draft.patch_set = draft.patch_set || this._patchRange.patchNum;
-
-    // The use of path-based notification helpers (set, push) can’t be used
-    // because the paths could contain dots in them. A new object must be
-    // created to satisfy Polymer’s dirty checking.
-    // https://github.com/Polymer/polymer/issues/3127
-    const diffDrafts = {...this._diffDrafts};
-    diffDrafts[draft.path].splice(index, 1);
-    if (diffDrafts[draft.path].length === 0) {
-      delete diffDrafts[draft.path];
-    }
-    this._diffDrafts = diffDrafts;
-  }
-
   _handleReplyTap(e: MouseEvent) {
     e.preventDefault();
     this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
@@ -1158,7 +1127,7 @@
       {once: true}
     );
     this.$.replyOverlay.cancel();
-    this._reload();
+    fireReload(this);
   }
 
   _handleReplyCancel() {
@@ -1182,22 +1151,11 @@
     this._openReplyDialog(target);
   }
 
-  readonly handleScroll = () => {
-    this.scrollTask = debounce(
-      this.scrollTask,
-      () => (this.viewState.scrollTop = document.body.scrollTop),
-      150
-    );
-  };
-
   _setShownFiles(e: CustomEvent<{length: number}>) {
     this._shownFileCount = e.detail.length;
   }
 
-  _expandAllDiffs(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _expandAllDiffs() {
     this.$.fileList.expandAllDiffs();
   }
 
@@ -1205,9 +1163,41 @@
     this.$.fileList.collapseAllDiffs();
   }
 
+  /**
+   * ChangeView is never re-used for different changes. It is safer and simpler
+   * to just re-create another change view when the user switches to a new
+   * change page. Thus we need a reliable way to detect that the change view
+   * does not match the current change number anymore.
+   *
+   * If this method returns true, then the change view should not do anything
+   * anymore. The app element makes sure that an obsolete change view is not
+   * shown anymore, so if the change view is still and doing some update to
+   * itself, then that is not dangerous. But for example it should not call
+   * navigateToChange() anymore. That would very likely cause erroneous
+   * behavior.
+   */
+  private isChangeObsolete() {
+    // While this._changeNum is undefined the change view is fresh and has just
+    // not updated it to params.changeNum yet. Not obsolete in that case.
+    if (this._changeNum === undefined) return false;
+    // this.params reflects the current state of the URL. If this._changeNum
+    // does not match it anymore, then this view must be considered obsolete.
+    return this._changeNum !== this.params?.changeNum;
+  }
+
   _paramsChanged(value: AppElementChangeViewParams) {
     if (value.view !== GerritView.CHANGE) {
       this._initialLoadComplete = false;
+      querySelectorAll(this, 'gr-overlay').forEach(overlay =>
+        (overlay as GrOverlay).close()
+      );
+      return;
+    }
+
+    if (this.isChangeObsolete()) {
+      // Tell the app element that we are not going to handle the new change
+      // number and that they have to create a new change view.
+      fireEvent(this, EventType.RECREATE_CHANGE_VIEW);
       return;
     }
 
@@ -1215,13 +1205,14 @@
       this.restApiService.setInProjectLookup(value.changeNum, value.project);
     }
 
+    if (value.basePatchNum === undefined)
+      value.basePatchNum = ParentPatchSetNum;
+
     const patchChanged =
       this._patchRange &&
       value.patchNum !== undefined &&
-      value.basePatchNum !== undefined &&
       (this._patchRange.patchNum !== value.patchNum ||
         this._patchRange.basePatchNum !== value.basePatchNum);
-    const changeChanged = this._changeNum !== value.changeNum;
 
     let rightPatchNumChanged =
       this._patchRange &&
@@ -1230,15 +1221,20 @@
 
     const patchRange: ChangeViewPatchRange = {
       patchNum: value.patchNum,
-      basePatchNum: value.basePatchNum || ParentPatchSetNum,
+      basePatchNum: value.basePatchNum,
     };
 
     this.$.fileList.collapseAllDiffs();
     this._patchRange = patchRange;
+    this.scrollCommentId = value.commentId;
+
+    const patchKnown =
+      !patchRange.patchNum ||
+      (this._allPatchSets ?? []).some(ps => ps.num === patchRange.patchNum);
 
     // If the change has already been loaded and the parameter change is only
     // in the patch range, then don't do a full reload.
-    if (!changeChanged && patchChanged) {
+    if (this._changeNum !== undefined && patchChanged && patchKnown) {
       if (!patchRange.patchNum) {
         patchRange.patchNum = computeLatestPatchNum(this._allPatchSets);
         rightPatchNumChanged = true;
@@ -1251,7 +1247,7 @@
 
     this._initialLoadComplete = false;
     this._changeNum = value.changeNum;
-    this._reload(true).then(() => {
+    this.loadData(true).then(() => {
       this._performPostLoadTasks();
     });
 
@@ -1266,6 +1262,8 @@
     let primaryTab = PrimaryTab.FILES;
     if (params && params.queryMap && params.queryMap.has('tab')) {
       primaryTab = params.queryMap.get('tab') as PrimaryTab;
+    } else if (params && 'commentId' in params) {
+      primaryTab = PrimaryTab.COMMENT_THREADS;
     }
     this._setActivePrimaryTab(
       new CustomEvent('initActiveTab', {
@@ -1274,13 +1272,6 @@
         },
       })
     );
-    this._setActiveSecondaryTab(
-      new CustomEvent('initActiveTab', {
-        detail: {
-          tab: SecondaryTab.CHANGE_LOG,
-        },
-      })
-    );
   }
 
   _sendShowChangeEvent() {
@@ -1301,11 +1292,7 @@
     this._sendShowChangeEvent();
 
     setTimeout(() => {
-      if (this.viewState.scrollTop) {
-        document.documentElement.scrollTop = document.body.scrollTop = this.viewState.scrollTop;
-      } else {
-        this._maybeScrollToMessage(window.location.hash);
-      }
+      this._maybeScrollToMessage(window.location.hash);
       this._initialLoadComplete = true;
     });
   }
@@ -1349,7 +1336,7 @@
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
-    const hash = MSG_PREFIX + e.detail.id;
+    const hash = PREFIX + e.detail.id;
     const url = GerritNav.getUrlForChange(
       this._change,
       this._patchRange.patchNum,
@@ -1361,8 +1348,8 @@
   }
 
   _maybeScrollToMessage(hash: string) {
-    if (hash.startsWith(MSG_PREFIX) && this.messagesList) {
-      this.messagesList.scrollToMessage(hash.substr(MSG_PREFIX.length));
+    if (hash.startsWith(PREFIX) && this.messagesList) {
+      this.messagesList.scrollToMessage(hash.substr(PREFIX.length));
     }
   }
 
@@ -1411,13 +1398,6 @@
 
       if (this.viewState.showReplyDialog) {
         this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
-        // TODO(kaspern@): Find a better signal for when to call center.
-        setTimeout(() => {
-          this.$.replyOverlay.center();
-        }, 100);
-        setTimeout(() => {
-          this.$.replyOverlay.center();
-        }, 1000);
         this.set('viewState.showReplyDialog', false);
       }
     });
@@ -1432,7 +1412,6 @@
 
   _resetFileListViewState() {
     this.set('viewState.selectedFileIndex', 0);
-    this.set('viewState.scrollTop', 0);
     if (
       !!this.viewState.changeNum &&
       this.viewState.changeNum !== this._changeNum
@@ -1487,7 +1466,8 @@
     const parentCount = hasOwnProperty(parentCounts, 1) ? parentCounts[1] : 1;
 
     const preferFirst =
-      this._prefs && this._prefs.default_base_for_merges === 'FIRST_PARENT';
+      this._prefs &&
+      this._prefs.default_base_for_merges === DefaultBase.FIRST_PARENT;
 
     if (parentCount > 1 && preferFirst && !patchRange.patchNum) {
       return -1;
@@ -1500,29 +1480,14 @@
     return GerritNav.getUrlForChange(change);
   }
 
-  _computeShowCommitInfo(
-    changeStatuses: string[],
-    current_revision: RevisionInfo
-  ) {
-    return (
-      changeStatuses.length === 1 &&
-      changeStatuses[0] === 'Merged' &&
-      current_revision
-    );
-  }
-
   _computeReplyButtonLabel(
-    changeRecord?: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > | null,
+    drafts?: {[path: string]: UIDraft[]},
     canStartReview?: boolean
   ) {
-    if (changeRecord === undefined || canStartReview === undefined) {
+    if (drafts === undefined || canStartReview === undefined) {
       return 'Reply';
     }
 
-    const drafts = (changeRecord && changeRecord.base) || {};
     const draftCount = Object.keys(drafts).reduce(
       (count, file) => count + drafts[file].length,
       0
@@ -1535,43 +1500,61 @@
     return label;
   }
 
-  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
+  _handleOpenReplyDialog() {
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
         fireEvent(this, 'show-auth-required');
         return;
       }
-
-      e.preventDefault();
       this._openReplyDialog(this.$.replyDialog.FocusTarget.ANY);
     });
   }
 
-  _handleOpenDownloadDialogShortcut(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this._handleOpenDownloadDialog();
+  _handleOpenSubmitDialog() {
+    if (!this._submitEnabled) return;
+    this.$.actions.showSubmitDialog();
   }
 
-  _handleEditTopic(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
+  _handleToggleAttentionSet() {
+    if (!this._change || !this._account?._account_id) return;
+    if (!this._loggedIn || !isInvolved(this._change, this._account)) return;
+    if (!this._change.attention_set) this._change.attention_set = {};
+    if (hasAttention(this._account, this._change)) {
+      const reason = getRemovedByReason(this._account, this._serverConfig);
+      if (this._change.attention_set)
+        delete this._change.attention_set[this._account._account_id];
+      fireAlert(this, 'Removing you from the attention set ...');
+      this.restApiService
+        .removeFromAttentionSet(
+          this._change._number,
+          this._account._account_id,
+          reason
+        )
+        .then(() => {
+          fireEvent(this, 'hide-alert');
+        });
+    } else {
+      const reason = getAddedByReason(this._account, this._serverConfig);
+      fireAlert(this, 'Adding you to the attention set ...');
+      this._change.attention_set[this._account._account_id!] = {
+        account: this._account,
+        reason,
+        reason_account: this._account,
+      };
+      this.restApiService
+        .addToAttentionSet(
+          this._change._number,
+          this._account._account_id,
+          reason
+        )
+        .then(() => {
+          fireEvent(this, 'hide-alert');
+        });
     }
-
-    e.preventDefault();
-    this.$.metadata.editTopic();
+    this._change = {...this._change};
   }
 
-  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _handleDiffAgainstBase() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1582,10 +1565,7 @@
     GerritNav.navigateToChange(this._change, this._patchRange.patchNum);
   }
 
-  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _handleDiffBaseAgainstLeft() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1596,10 +1576,7 @@
     GerritNav.navigateToChange(this._change, this._patchRange.basePatchNum);
   }
 
-  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _handleDiffAgainstLatest() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1615,10 +1592,7 @@
     );
   }
 
-  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _handleDiffRightAgainstLatest() {
     assertIsDefined(this._change, '_change');
     const latestPatchNum = computeLatestPatchNum(this._allPatchSets);
     if (!this._patchRange)
@@ -1634,10 +1608,7 @@
     );
   }
 
-  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  _handleDiffBaseAgainstLatest() {
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
       throw new Error('missing required _patchRange property');
@@ -1652,63 +1623,24 @@
     GerritNav.navigateToChange(this._change, latestPatchNum);
   }
 
-  _handleRefreshChange(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
-    e.preventDefault();
-    this._reload(/* isLocationChange= */ false, /* clearPatchset= */ true);
-  }
-
-  _handleToggleChangeStar(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
+  _handleToggleChangeStar() {
     this.$.changeStar.toggleStar();
   }
 
-  _handleUpToDashboard(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this._determinePageBack();
-  }
-
-  _handleExpandAllMessages(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleExpandAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(true);
     }
   }
 
-  _handleCollapseAllMessages(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleCollapseAllMessages() {
     if (this.messagesList) {
       this.messagesList.handleExpandCollapse(false);
     }
   }
 
-  _handleOpenDiffPrefsShortcut(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._diffPrefsDisabled) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleOpenDiffPrefsShortcut() {
+    if (!this._loggedIn) return;
     this.$.fileList.openDiffPrefs();
   }
 
@@ -1731,7 +1663,7 @@
           labelDict.approved &&
           labelDict.approved._account_id === removed._account_id
         ) {
-          this._reload();
+          fireReload(this);
           return;
         }
       }
@@ -1765,8 +1697,6 @@
       // the following code should be executed no matter open succeed or not
       this._resetReplyOverlayFocusStops();
       this.$.replyDialog.open(section);
-      flush();
-      this.$.replyOverlay.center();
     });
     fireDialogChange(this, {opened: true});
     this._changeViewAriaHidden = true;
@@ -1815,7 +1745,7 @@
       changeIsOpen(change)
     ) {
       fireAlert(this, 'Change edit not found. Please create a change edit.');
-      GerritNav.navigateToChange(change);
+      fireReload(this, true);
       return;
     }
 
@@ -1828,7 +1758,7 @@
         this,
         'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
       );
-      GerritNav.navigateToChange(change);
+      fireReload(this, true);
       return;
     }
 
@@ -1866,6 +1796,32 @@
     }
   }
 
+  computeRevertSubmitted(change?: ChangeInfo | ParsedChangeInfo) {
+    if (!change?.messages) return;
+    Promise.all(
+      getRevertCreatedChangeIds(change.messages).map(changeId =>
+        this.restApiService.getChange(changeId)
+      )
+    ).then(changes => {
+      // if a change is deleted then getChanges returns null for that changeId
+      changes = changes.filter(
+        change => change && change.status !== ChangeStatus.ABANDONED
+      );
+      if (!changes.length) return;
+      const submittedRevert = changes.find(
+        change => change?.status === ChangeStatus.MERGED
+      );
+      if (!this._changeStatuses) return;
+      if (submittedRevert) {
+        this.revertedChange = submittedRevert;
+        this.push('_changeStatuses', ChangeStates.REVERT_SUBMITTED);
+      } else {
+        if (changes[0]) this.revertedChange = changes[0];
+        this.push('_changeStatuses', ChangeStates.REVERT_CREATED);
+      }
+    });
+  }
+
   _getChangeDetail() {
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
@@ -1888,10 +1844,10 @@
         // TODO(TS): code needs second thought,
         // it might be that nulls were assigned to trigger some bindings
         if (!change.topic) {
-          change.topic = (null as unknown) as undefined;
+          change.topic = null as unknown as undefined;
         }
         if (!change.reviewer_updates) {
-          change.reviewer_updates = (null as unknown) as undefined;
+          change.reviewer_updates = null as unknown as undefined;
         }
         const latestRevisionSha = this._getLatestRevisionSHA(change);
         if (!latestRevisionSha)
@@ -1910,8 +1866,9 @@
         // Slice returns a number as a string, convert to an int.
         this._lineHeight = Number(lineHeight.slice(0, lineHeight.length - 2));
 
-        this._change = change;
         this.changeService.updateChange(change);
+        this._change = change;
+        this.computeRevertSubmitted(change);
         if (
           !this._patchRange ||
           !this._patchRange.patchNum ||
@@ -2028,10 +1985,6 @@
       });
   }
 
-  _reloadDraftsWithCallback(e: CustomEvent<{resolve: () => void}>) {
-    return this._reloadDrafts().then(() => e.detail.resolve());
-  }
-
   /**
    * Fetches a new changeComment object, and data for all types of comments
    * (comments, robot comments, draft comments) is requested.
@@ -2041,38 +1994,18 @@
     // a new change being loaded and then paired with outdated comments.
     this._changeComments = undefined;
     this._commentThreads = undefined;
-    this._diffDrafts = undefined;
     this._draftCommentThreads = undefined;
     this._robotCommentThreads = undefined;
     if (!this._changeNum)
       throw new Error('missing required changeNum property');
 
-    return this.$.commentAPI
-      .loadAll(this._changeNum, this._patchRange?.patchNum)
-      .then(comments => {
-        this._recomputeComments(comments);
-      });
+    this.commentsService.loadAll(this._changeNum, this._patchRange?.patchNum);
   }
 
-  /**
-   * Fetches a new changeComment object, but only updated data for drafts is
-   * requested.
-   *
-   * TODO(taoalpha): clean up this and _reloadComments, as single comment
-   * can be a thread so it does not make sense to only update drafts
-   * without updating threads
-   */
-  _reloadDrafts() {
-    if (!this._changeNum)
-      throw new Error('missing required changeNum property');
-    return this.$.commentAPI
-      .reloadDrafts(this._changeNum)
-      .then(comments => this._recomputeComments(comments));
-  }
-
-  _recomputeComments(comments: ChangeComments) {
+  @observe('_changeComments')
+  changeCommentsChanged(comments?: ChangeComments) {
+    if (!comments) return;
     this._changeComments = comments;
-    this._diffDrafts = {...this._changeComments.drafts};
     this._commentThreads = this._changeComments.getAllThreadsForChange();
     this._draftCommentThreads = this._commentThreads
       .filter(isDraftThread)
@@ -2100,10 +2033,11 @@
    * Some non-core data loading may still be in-flight when the core data
    * promise resolves.
    */
-  _reload(isLocationChange?: boolean, clearPatchset?: boolean) {
+  loadData(isLocationChange?: boolean, clearPatchset?: boolean): Promise<void> {
+    if (this.isChangeObsolete()) return Promise.resolve();
     if (clearPatchset && this._change) {
       GerritNav.navigateToChange(this._change);
-      return Promise.resolve([]);
+      return Promise.resolve();
     }
     this._loading = true;
     this.reporting.time(Timing.CHANGE_RELOAD);
@@ -2116,7 +2050,6 @@
     // 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.
@@ -2128,7 +2061,11 @@
       .then(() => {
         this.reporting.timeEnd(Timing.CHANGE_RELOAD);
         if (isLocationChange) {
-          this.reporting.changeDisplayed();
+          this.reporting.changeDisplayed({
+            isOwner: isOwner(this._change, this._account),
+            isReviewer: isReviewer(this._change, this._account),
+            isCc: isCc(this._change, this._account),
+          });
         }
       });
 
@@ -2139,10 +2076,7 @@
     });
     allDataPromises.push(projectConfigLoaded);
 
-    // Resolves when change comments have loaded (comments, drafts and robot
-    // comments).
-    const commentsLoaded = this._reloadComments();
-    allDataPromises.push(commentsLoaded);
+    this._reloadComments();
 
     let coreDataPromise;
 
@@ -2160,20 +2094,12 @@
         loadingFlagSet,
       ]);
 
-      // Promise resolves when mergeability information has loaded.
-      const mergeabilityLoaded = detailAndPatchResourcesLoaded.then(() =>
+      // _getChangeDetail triggers reload of change actions already.
+
+      // The core data is loaded when mergeability is known.
+      coreDataPromise = detailAndPatchResourcesLoaded.then(() =>
         this._getMergeability()
       );
-      allDataPromises.push(mergeabilityLoaded);
-
-      // Promise resovles when the change actions have loaded.
-      const actionsLoaded = detailAndPatchResourcesLoaded.then(() =>
-        this.$.actions.reload()
-      );
-      allDataPromises.push(actionsLoaded);
-
-      // The core data is loaded when both mergeability and actions are known.
-      coreDataPromise = Promise.all([mergeabilityLoaded, actionsLoaded]);
     } else {
       // Resolves when the file list has loaded.
       const fileListReload = loadingFlagSet.then(() =>
@@ -2190,16 +2116,12 @@
       });
       allDataPromises.push(latestCommitMessageLoaded);
 
-      // Promise resolves when mergeability information has loaded.
-      const mergeabilityLoaded = loadingFlagSet.then(() =>
-        this._getMergeability()
-      );
-      allDataPromises.push(mergeabilityLoaded);
-
       // Core data is loaded when mergeability has been loaded.
-      coreDataPromise = Promise.all([mergeabilityLoaded]);
+      coreDataPromise = loadingFlagSet.then(() => this._getMergeability());
     }
 
+    allDataPromises.push(coreDataPromise);
+
     if (isLocationChange) {
       this._editingCommitMessage = false;
       const relatedChangesLoaded = coreDataPromise.then(() => {
@@ -2227,6 +2149,7 @@
     }
 
     Promise.all(allDataPromises).then(() => {
+      // Loading of commments data is no longer part of this reporting
       this.reporting.timeEnd(Timing.CHANGE_DATA);
       if (isLocationChange) {
         this.reporting.changeFullyLoaded();
@@ -2269,7 +2192,7 @@
     return Promise.all(promises);
   }
 
-  _getMergeability() {
+  _getMergeability(): Promise<void> {
     if (!this._change) {
       this._mergeable = null;
       return Promise.resolve();
@@ -2305,7 +2228,25 @@
       });
   }
 
-  _computeCanStartReview(change: ChangeInfo) {
+  _computeResolveWeblinks(
+    change?: ChangeInfo,
+    commitInfo?: CommitInfo,
+    config?: ServerInfo
+  ) {
+    if (!change || !commitInfo || !config) {
+      return [];
+    }
+    return GerritNav.getResolveConflictsWeblinks(
+      change.project,
+      commitInfo.commit,
+      {
+        weblinks: commitInfo.resolve_conflicts_web_links,
+        config,
+      }
+    );
+  }
+
+  _computeCanStartReview(change: ChangeInfo): boolean {
     return !!(
       change.actions &&
       change.actions.ready &&
@@ -2313,10 +2254,6 @@
     );
   }
 
-  _computeReplyDisabled() {
-    return false;
-  }
-
   _computeChangePermalinkAriaLabel(changeNum: NumericChangeId) {
     return `Change ${changeNum}`;
   }
@@ -2325,7 +2262,7 @@
    * Returns the text to be copied when
    * click the copy icon next to change subject
    */
-  _computeCopyTextForTitle(change: ChangeInfo) {
+  _computeCopyTextForTitle(change: ChangeInfo): string {
     return (
       `${change._number}: ${change.subject} | ` +
       `${location.protocol}//${location.host}` +
@@ -2351,9 +2288,13 @@
     }
 
     this._updateCheckTimerHandle = window.setTimeout(() => {
+      if (!this.isViewCurrent) {
+        this._startUpdateCheckTimer();
+        return;
+      }
       assertIsDefined(this._change, '_change');
       const change = this._change;
-      fetchChangeUpdates(change, this.restApiService).then(result => {
+      this.changeService.fetchChangeUpdates(change).then(result => {
         let toastMessage = null;
         if (!result.isLatest) {
           toastMessage = ReloadToastMessage.NEWER_REVISION;
@@ -2375,7 +2316,12 @@
         // reply, or the change might have been reloaded, or it could be in the
         // process of being reloaded.
         const changeWasReloaded = change !== this._change;
-        if (!toastMessage || this._loading || changeWasReloaded) {
+        if (
+          !toastMessage ||
+          this._loading ||
+          changeWasReloaded ||
+          !this.isViewCurrent
+        ) {
           this._startUpdateCheckTimer();
           return;
         }
@@ -2389,12 +2335,7 @@
               dismissOnNavigation: true,
               showDismiss: true,
               action: 'Reload',
-              callback: () => {
-                this._reload(
-                  /* isLocationChange= */ false,
-                  /* clearPatchset= */ true
-                );
-              },
+              callback: () => fireReload(this, true),
             },
             composed: true,
             bubbles: true,
@@ -2455,9 +2396,10 @@
 
   _handleFileActionTap(e: CustomEvent<{path: string; action: string}>) {
     e.preventDefault();
-    const controls = this.$.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
-      '#editControls'
-    );
+    const controls =
+      this.$.fileListHeader.shadowRoot!.querySelector<GrEditControls>(
+        '#editControls'
+      );
     if (!controls) throw new Error('Missing edit controls');
     assertIsDefined(this._change, '_change');
     if (!this._patchRange)
@@ -2553,13 +2495,24 @@
   }
 
   _handleToggleStar(e: CustomEvent<{change: ChangeInfo; starred: boolean}>) {
+    if (e.detail.starred) {
+      this.reporting.reportInteraction('change-starred-from-change-view');
+      this.lastStarredTimestamp = Date.now();
+    } else {
+      if (
+        this.lastStarredTimestamp &&
+        Date.now() - this.lastStarredTimestamp < ACCIDENTAL_STARRING_LIMIT_MS
+      ) {
+        this.reporting.reportInteraction('change-accidentally-starred');
+      }
+    }
     this.restApiService.saveChangeStarred(
       e.detail.change._number,
       e.detail.starred
     );
   }
 
-  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo) {
+  _getRevisionInfo(change: ChangeInfo | ParsedChangeInfo): RevisionInfoClass {
     return new RevisionInfoClass(change);
   }
 
@@ -2570,10 +2523,6 @@
     return currentRevision && revisions && revisions[currentRevision];
   }
 
-  _computeDiffPrefsDisabled(disableDiffPrefs: boolean, loggedIn: boolean) {
-    return disableDiffPrefs || !loggedIn;
-  }
-
   /**
    * Wrapper for using in the element template and computed properties
    */
@@ -2584,7 +2533,7 @@
   /**
    * Wrapper for using in the element template and computed properties
    */
-  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+  _hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]): boolean {
     return hasEditBasedOnCurrentPatchSet(allPatchSets);
   }
 
@@ -2596,7 +2545,7 @@
       ChangeViewPatchRange,
       ChangeViewPatchRange
     >
-  ) {
+  ): boolean {
     const patchRange = patchRangeRecord.base;
     if (!patchRange) {
       return false;
@@ -2616,6 +2565,10 @@
       '#relatedChanges'
     );
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
 
 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 6d8c1ec..645b835 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     .container:not(.loading) {
       background-color: var(--background-color-tertiary);
@@ -40,7 +43,6 @@
       margin-right: var(--spacing-l);
     }
     gr-change-status {
-      display: initial;
       margin-left: var(--spacing-s);
     }
     gr-change-status:first-child {
@@ -100,7 +102,7 @@
       font-size: var(--font-size-mono);
       line-height: var(--line-height-mono);
       margin-right: var(--spacing-l);
-      margin-bottom: var(--spacing-m);
+      margin-bottom: var(--spacing-l);
       /* Account for border and padding and rounding errors. */
       max-width: calc(72ch + 2px + 2 * var(--spacing-m) + 0.4px);
     }
@@ -113,8 +115,7 @@
       --collapsed-max-height: 300px;
     }
     .changeStatuses,
-    .commitActions,
-    .statusText {
+    .commitActions {
       align-items: center;
       display: flex;
     }
@@ -199,8 +200,7 @@
       width: 100%;
     }
     gr-change-summary {
-      /* temporary for old checks status */
-      margin-bottom: var(--spacing-m);
+      margin-left: var(--spacing-m);
     }
     @media screen and (max-width: 75em) {
       .relatedChanges {
@@ -302,6 +302,9 @@
     .show-robot-comments {
       margin: var(--spacing-m);
     }
+    .patchInfo gr-thread-list::part(threads) {
+      padding: var(--spacing-l);
+    }
   </style>
   <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
   <!-- TODO(taoalpha): remove on-show-checks-table,
@@ -323,8 +326,10 @@
           <div class="changeStatuses">
             <template is="dom-repeat" items="[[_changeStatuses]]" as="status">
               <gr-change-status
-                max-width="100"
+                change="[[_change]]"
+                reverted-change="[[revertedChange]]"
                 status="[[status]]"
+                resolve-weblinks="[[resolveWeblinks]]"
               ></gr-change-status>
             </template>
           </div>
@@ -345,13 +350,14 @@
           <span class="headerSubject">[[_change.subject]]</span>
           <gr-copy-clipboard
             class="changeCopyClipboard"
-            hide-input=""
+            hideInput=""
             text="[[_computeCopyTextForTitle(_change)]]"
           >
           </gr-copy-clipboard>
         </div>
         <!-- end headerTitle -->
-        <div class="commitActions" hidden$="[[!_loggedIn]]">
+        <!-- always show gr-change-actions regardless if logged in or not -->
+        <div class="commitActions">
           <gr-change-actions
             id="actions"
             change="[[_change]]"
@@ -369,9 +375,11 @@
             edit-mode="[[_editMode]]"
             edit-based-on-current-patch-set="[[_hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
             private-by-default="[[_projectConfig.private_by_default]]"
+            logged-in="[[_loggedIn]]"
             on-edit-tap="_handleEditTap"
             on-stop-edit-tap="_handleStopEditTap"
             on-download-tap="_handleOpenDownloadDialog"
+            on-included-tap="_handleOpenIncludedInDialog"
             comment-threads="[[_commentThreads]]"
           ></gr-change-actions>
         </div>
@@ -384,6 +392,7 @@
           <gr-change-metadata
             id="metadata"
             change="{{_change}}"
+            reverted-change="[[revertedChange]]"
             account="[[_account]]"
             revision="[[_selectedRevision]]"
             commit-info="[[_commitInfo]]"
@@ -428,6 +437,7 @@
                   ></gr-linked-text>
                 </gr-editable-content>
               </div>
+              <h3 class="assistive-tech-only">Comments and Checks Summary</h3>
               <gr-change-summary
                 change-comments="[[_changeComments]]"
                 comment-threads="[[_commentThreads]]"
@@ -460,21 +470,28 @@
 
     <h2 class="assistive-tech-only">Files and Comments tabs</h2>
     <paper-tabs id="primaryTabs" on-selected-changed="_setActivePrimaryTab">
-      <paper-tab data-name$="[[_constants.PrimaryTab.FILES]]">Files</paper-tab>
       <paper-tab
+        on-click="_onPaperTabClick"
+        data-name$="[[_constants.PrimaryTab.FILES]]"
+        ><span>Files</span></paper-tab
+      >
+      <paper-tab
+        on-click="_onPaperTabClick"
         data-name$="[[_constants.PrimaryTab.COMMENT_THREADS]]"
         class="commentThreads"
       >
         <gr-tooltip-content
-          has-tooltip=""
+          has-tooltip
           title$="[[_computeTotalCommentCounts(_change.unresolved_comment_count, _changeComments)]]"
         >
           <span>Comments</span></gr-tooltip-content
         >
       </paper-tab>
       <template is="dom-if" if="[[_showChecksTab]]">
-        <paper-tab data-name$="[[_constants.PrimaryTab.CHECKS]]"
-          >Checks</paper-tab
+        <paper-tab
+          data-name$="[[_constants.PrimaryTab.CHECKS]]"
+          on-click="_onPaperTabClick"
+          ><span>Checks</span></paper-tab
         >
       </template>
       <template
@@ -491,8 +508,11 @@
           </gr-endpoint-decorator>
         </paper-tab>
       </template>
-      <paper-tab data-name$="[[_constants.PrimaryTab.FINDINGS]]">
-        Findings
+      <paper-tab
+        data-name$="[[_constants.PrimaryTab.FINDINGS]]"
+        on-click="_onPaperTabClick"
+      >
+        <span>Findings</span>
       </paper-tab>
     </paper-tabs>
 
@@ -519,10 +539,9 @@
           patch-num="{{_patchRange.patchNum}}"
           base-patch-num="{{_patchRange.basePatchNum}}"
           files-expanded="[[_filesExpanded]]"
-          diff-prefs-disabled="[[_diffPrefsDisabled]]"
+          diff-prefs-disabled="[[!_loggedIn]]"
           on-open-diff-prefs="_handleOpenDiffPrefs"
           on-open-download-dialog="_handleOpenDownloadDialog"
-          on-open-included-in-dialog="_handleOpenIncludedInDialog"
           on-expand-diffs="_expandAllDiffs"
           on-collapse-diffs="_collapseAllDiffs"
         >
@@ -535,7 +554,6 @@
           change-num="[[_changeNum]]"
           patch-range="{{_patchRange}}"
           change-comments="[[_changeComments]]"
-          revisions="[[_change.revisions]]"
           selected-index="{{viewState.selectedFileIndex}}"
           diff-view-mode="[[viewState.diffMode]]"
           edit-mode="[[_editMode]]"
@@ -544,7 +562,7 @@
           file-list-increment="{{_numFilesShown}}"
           on-files-shown-changed="_setShownFiles"
           on-file-action-tap="_handleFileActionTap"
-          on-reload-drafts="_reloadDraftsWithCallback"
+          observer-target="[[_computeObserverTarget()]]"
         >
         </gr-file-list>
       </div>
@@ -552,14 +570,17 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.COMMENT_THREADS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Comments</h3>
         <gr-thread-list
           threads="[[_commentThreads]]"
           change="[[_change]]"
           change-num="[[_changeNum]]"
           logged-in="[[_loggedIn]]"
+          account="[[_account]]"
           comment-tab-state="[[_tabState.commentTab]]"
           only-show-robot-comments-with-human-reply=""
-          unresolved-only
+          unresolved-only="[[unresolvedOnly]]"
+          scroll-comment-id="[[scrollCommentId]]"
           show-comment-context
         ></gr-thread-list>
       </template>
@@ -567,6 +588,7 @@
         is="dom-if"
         if="[[_isTabActive(_constants.PrimaryTab.CHECKS, _activeTabs)]]"
       >
+        <h3 class="assistive-tech-only">Checks</h3>
         <gr-checks-tab
           id="checksTab"
           tab-state="[[_tabState.checksTab]]"
@@ -588,7 +610,7 @@
           change="[[_change]]"
           change-num="[[_changeNum]]"
           logged-in="[[_loggedIn]]"
-          hide-toggle-buttons
+          hide-dropdown
           empty-thread-msg="[[_messages.NO_ROBOT_COMMENTS_THREADS_MSG]]"
         >
         </gr-thread-list>
@@ -621,7 +643,7 @@
       </gr-endpoint-param>
     </gr-endpoint-decorator>
 
-    <paper-tabs id="secondaryTabs" on-selected-changed="_setActiveSecondaryTab">
+    <paper-tabs id="secondaryTabs">
       <paper-tab
         data-name$="[[_constants.SecondaryTab.CHANGE_LOG]]"
         class="changeLog"
@@ -631,24 +653,19 @@
     </paper-tabs>
     <section class="changeLog">
       <h2 class="assistive-tech-only">Change Log</h2>
-      <template
-        is="dom-if"
-        if="[[_isTabActive(_constants.SecondaryTab.CHANGE_LOG, _activeTabs)]]"
-      >
-        <gr-messages-list
-          class="hideOnMobileOverlay"
-          change="[[_change]]"
-          change-num="[[_changeNum]]"
-          labels="[[_change.labels]]"
-          messages="[[_change.messages]]"
-          reviewer-updates="[[_change.reviewer_updates]]"
-          change-comments="[[_changeComments]]"
-          project-name="[[_change.project]]"
-          show-reply-buttons="[[_loggedIn]]"
-          on-message-anchor-tap="_handleMessageAnchorTap"
-          on-reply="_handleMessageReply"
-        ></gr-messages-list>
-      </template>
+      <gr-messages-list
+        class="hideOnMobileOverlay"
+        change="[[_change]]"
+        change-num="[[_changeNum]]"
+        labels="[[_change.labels]]"
+        messages="[[_change.messages]]"
+        reviewer-updates="[[_change.reviewer_updates]]"
+        change-comments="[[_changeComments]]"
+        project-name="[[_change.project]]"
+        show-reply-buttons="[[_loggedIn]]"
+        on-message-anchor-tap="_handleMessageAnchorTap"
+        on-reply="_handleMessageReply"
+      ></gr-messages-list>
     </section>
   </div>
 
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 d8aebcb..6cbe59c 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
@@ -24,8 +24,8 @@
   DefaultBase,
   DiffViewMode,
   HttpMethod,
+  MessageTag,
   PrimaryTab,
-  SecondaryTab,
 } from '../../../constants/constants';
 import {GrEditConstants} from '../../edit/gr-edit-constants';
 import {_testOnly_resetEndpoints} from '../../shared/gr-js-api-interface/gr-plugin-endpoints';
@@ -35,11 +35,7 @@
 import {EventType, PluginApi} from '../../../api/plugin';
 
 import 'lodash/lodash';
-import {
-  stubRestApi,
-  TestKeyboardShortcutBinder,
-} from '../../../test/test-utils';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {mockPromise, stubRestApi} from '../../../test/test-utils';
 import {
   createAppElementChangeViewParams,
   createApproval,
@@ -58,6 +54,7 @@
   createAccountWithIdNameAndEmail,
   createChangeViewChange,
   createRelatedChangeAndCommitInfo,
+  createAccountDetailWithId,
 } from '../../../test/test-data-generators';
 import {ChangeViewPatchRange, GrChangeView} from './gr-change-view';
 import {
@@ -70,13 +67,13 @@
   CommitInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   GitRef,
   NumericChangeId,
   ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   RelatedChangeAndCommitInfo,
+  ReviewInputTag,
   RevisionInfo,
   RevisionPatchSetNum,
   RobotId,
@@ -89,20 +86,14 @@
 } from '@polymer/iron-test-helpers/mock-interactions';
 import {GrEditControls} from '../../edit/gr-edit-controls/gr-edit-controls';
 import {AppElementChangeViewParams} from '../../gr-app-types';
-import {SinonFakeTimers, SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
-import {CustomKeyboardEvent} from '../../../types/events';
-import {
-  CommentThread,
-  DraftInfo,
-  UIDraft,
-  UIRobot,
-} from '../../../utils/comment-util';
-import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
+import {CommentThread, UIRobot} from '../../../utils/comment-util';
 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';
+import {ChangeStates} from '../../shared/gr-change-status/gr-change-status';
 
 const pluginApi = _testOnly_initGerritPluginApi();
 const fixture = fixtureFromElement('gr-change-view');
@@ -114,27 +105,6 @@
     typeof GerritNav.navigateToChange
   >;
 
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEND_REPLY, 'ctrl+enter');
-    kb.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r');
-    kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-    kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-    kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-    kb.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's');
-    kb.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    kb.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    kb.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    kb.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
-  const TEST_SCROLL_TOP_PX = 100;
-
   const ROBOT_COMMENTS_LIMIT = 10;
 
   // TODO: should have a mock service to generate VALID fake data
@@ -372,12 +342,14 @@
         },
       })
     );
-    stubRestApi('getAccount').returns(Promise.resolve(undefined));
+    stubRestApi('getAccount').returns(
+      Promise.resolve(createAccountDetailWithId(5))
+    );
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = fixture.instantiate();
-    element._changeNum = 1 as NumericChangeId;
+    element._changeNum = TEST_NUMERIC_CHANGE_ID;
     sinon.stub(element.$.actions, 'reload').returns(Promise.resolve());
     getPluginLoader().loadPlugins([]);
     pluginApi.install(
@@ -396,10 +368,8 @@
     );
   });
 
-  teardown(done => {
-    flush(() => {
-      done();
-    });
+  teardown(async () => {
+    await flush();
   });
 
   test('_handleMessageAnchorTap', () => {
@@ -428,8 +398,7 @@
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffAgainstBase(new CustomEvent('') as CustomKeyboardEvent);
+    element._handleDiffAgainstBase();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
@@ -445,15 +414,12 @@
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffAgainstLatest(
-      new CustomEvent('') as CustomKeyboardEvent
-    );
+    element._handleDiffAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
     assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 1 as PatchSetNum);
+    assert.equal(args[2], 1 as BasePatchSetNum);
   });
 
   test('_handleDiffBaseAgainstLeft', () => {
@@ -465,10 +431,7 @@
       patchNum: 3 as RevisionPatchSetNum,
       basePatchNum: 1 as BasePatchSetNum,
     };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffBaseAgainstLeft(
-      new CustomEvent('') as CustomKeyboardEvent
-    );
+    element._handleDiffBaseAgainstLeft();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[0], element._change);
@@ -484,14 +447,11 @@
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffRightAgainstLatest(
-      new CustomEvent('') as CustomKeyboardEvent
-    );
+    element._handleDiffRightAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[1], 10 as PatchSetNum);
-    assert.equal(args[2], 3 as PatchSetNum);
+    assert.equal(args[2], 3 as BasePatchSetNum);
   });
 
   test('_handleDiffBaseAgainstLatest', () => {
@@ -503,22 +463,47 @@
       basePatchNum: 1 as BasePatchSetNum,
       patchNum: 3 as RevisionPatchSetNum,
     };
-    sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-    element._handleDiffBaseAgainstLatest(
-      new CustomEvent('') as CustomKeyboardEvent
-    );
+    element._handleDiffBaseAgainstLatest();
     assert(navigateToChangeStub.called);
     const args = navigateToChangeStub.getCall(0).args;
     assert.equal(args[1], 10 as PatchSetNum);
     assert.isNotOk(args[2]);
   });
 
+  test('toggle attention set status', async () => {
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: createRevisions(10),
+    };
+    const addToAttentionSetStub = stubRestApi('addToAttentionSet').returns(
+      Promise.resolve(new Response())
+    );
+
+    const removeFromAttentionSetStub = stubRestApi(
+      'removeFromAttentionSet'
+    ).returns(Promise.resolve(new Response()));
+    element._patchRange = {
+      basePatchNum: 1 as BasePatchSetNum,
+      patchNum: 3 as RevisionPatchSetNum,
+    };
+
+    assert.isNotOk(element._change.attention_set);
+    await element._getLoggedIn();
+    await element.restApiService.getAccount();
+    element._handleToggleAttentionSet();
+    assert.isTrue(addToAttentionSetStub.called);
+    assert.isFalse(removeFromAttentionSetStub.called);
+
+    element._handleToggleAttentionSet();
+    assert.isTrue(removeFromAttentionSetStub.called);
+  });
+
   suite('plugins adding to file tab', () => {
-    setup(done => {
-      element._changeNum = 1 as NumericChangeId;
+    setup(async () => {
+      element._changeNum = TEST_NUMERIC_CHANGE_ID;
       // Resolving it here instead of during setup() as other tests depend
       // on flush() not being called during setup.
-      flush(() => done());
+      await flush();
     });
 
     test('plugin added tab shows up as a dynamic endpoint', () => {
@@ -534,19 +519,17 @@
       assert.equal(paperTabs[2].dataset.name, 'change-view-tab-header-url');
     });
 
-    test('_setActivePrimaryTab switched tab correctly', done => {
+    test('_setActivePrimaryTab switched tab correctly', async () => {
       element._setActivePrimaryTab(
         new CustomEvent('', {
           detail: {tab: 'change-view-tab-header-url'},
         })
       );
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
+      await flush();
+      assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
     });
 
-    test('show-primary-tab switched primary tab correctly', done => {
+    test('show-primary-tab switched primary tab correctly', async () => {
       element.dispatchEvent(
         new CustomEvent('show-primary-tab', {
           composed: true,
@@ -556,13 +539,11 @@
           },
         })
       );
-      flush(() => {
-        assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
-        done();
-      });
+      await flush();
+      assert.equal(element._activeTabs[0], 'change-view-tab-header-url');
     });
 
-    test('param change should switch primary tab correctly', done => {
+    test('param change should switch primary tab correctly', async () => {
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
       const queryMap = new Map<string, string>();
       queryMap.set('tab', PrimaryTab.FINDINGS);
@@ -572,13 +553,11 @@
         ...element.params,
         queryMap,
       };
-      flush(() => {
-        assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
-        done();
-      });
+      await flush();
+      assert.equal(element._activeTabs[0], PrimaryTab.FINDINGS);
     });
 
-    test('invalid param change should not switch primary tab', done => {
+    test('invalid param change should not switch primary tab', async () => {
       assert.equal(element._activeTabs[0], PrimaryTab.FILES);
       const queryMap = new Map<string, string>();
       queryMap.set('tab', 'random');
@@ -588,22 +567,18 @@
         ...element.params,
         queryMap,
       };
-      flush(() => {
-        assert.equal(element._activeTabs[0], PrimaryTab.FILES);
-        done();
-      });
+      await flush();
+      assert.equal(element._activeTabs[0], PrimaryTab.FILES);
     });
 
-    test('switching tab sets _selectedTabPluginEndpoint', done => {
+    test('switching tab sets _selectedTabPluginEndpoint', async () => {
       const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
       tap(paperTabs.querySelectorAll('paper-tab')[2]);
-      flush(() => {
-        assert.equal(
-          element._selectedTabPluginEndpoint,
-          'change-view-tab-content-url'
-        );
-        done();
-      });
+      await flush();
+      assert.equal(
+        element._selectedTabPluginEndpoint,
+        'change-view-tab-content-url'
+      );
     });
   });
 
@@ -660,28 +635,24 @@
       );
     });
 
-    test('A fires an error event when not logged in', done => {
+    test('A fires an error event when not logged in', async () => {
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isFalse(element.$.replyOverlay.opened);
-        assert.isTrue(loggedInErrorSpy.called);
-        done();
-      });
+      await flush();
+      assert.isFalse(element.$.replyOverlay.opened);
+      assert.isTrue(loggedInErrorSpy.called);
     });
 
-    test('shift A does not open reply overlay', done => {
+    test('shift A does not open reply overlay', async () => {
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
       pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-      flush(() => {
-        assert.isFalse(element.$.replyOverlay.opened);
-        done();
-      });
+      await flush();
+      assert.isFalse(element.$.replyOverlay.opened);
     });
 
-    test('A toggles overlay when logged in', done => {
+    test('A toggles overlay when logged in', async () => {
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
       element._change = {
         ...createChangeViewChange(),
@@ -702,19 +673,17 @@
       const openSpy = sinon.spy(element, '_openReplyDialog');
 
       pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isTrue(element.$.replyOverlay.opened);
-        element.$.replyOverlay.close();
-        assert.isFalse(element.$.replyOverlay.opened);
-        assert(
-          openSpy.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.ANY
-          ),
-          '_openReplyDialog should have been passed ANY'
-        );
-        assert.equal(openSpy.callCount, 1);
-        done();
-      });
+      await flush();
+      assert.isTrue(element.$.replyOverlay.opened);
+      element.$.replyOverlay.close();
+      assert.isFalse(element.$.replyOverlay.opened);
+      assert(
+        openSpy.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.ANY
+        ),
+        '_openReplyDialog should have been passed ANY'
+      );
+      assert.equal(openSpy.callCount, 1);
     });
 
     test('fullscreen-overlay-opened hides content', () => {
@@ -792,65 +761,24 @@
       assert.isTrue(handleCollapse.called);
     });
 
-    test('X should expand all messages', done => {
-      flush(() => {
-        const handleExpand = sinon.stub(
-          element.messagesList!,
-          'handleExpandCollapse'
-        );
-        pressAndReleaseKeyOn(element, 88, null, 'x');
-        assert(handleExpand.calledWith(true));
-        done();
-      });
-    });
-
-    test('Z should collapse all messages', done => {
-      flush(() => {
-        const handleExpand = sinon.stub(
-          element.messagesList!,
-          'handleExpandCollapse'
-        );
-        pressAndReleaseKeyOn(element, 90, null, 'z');
-        assert(handleExpand.calledWith(false));
-        done();
-      });
-    });
-
-    test('reload event from reply dialog is processed', () => {
-      const handleReloadStub = sinon.stub(element, '_reload');
-      element.$.replyDialog.dispatchEvent(
-        new CustomEvent('reload', {
-          detail: {clearPatchset: true},
-          bubbles: true,
-          composed: true,
-        })
+    test('X should expand all messages', async () => {
+      await flush();
+      const handleExpand = sinon.stub(
+        element.messagesList!,
+        'handleExpandCollapse'
       );
-      assert.isTrue(handleReloadStub.called);
+      pressAndReleaseKeyOn(element, 88, null, 'x');
+      assert(handleExpand.calledWith(true));
     });
 
-    test('shift + R should fetch and navigate to the latest patch set', done => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._patchRange = {
-        basePatchNum: ParentPatchSetNum,
-        patchNum: 1 as RevisionPatchSetNum,
-      };
-      element._change = {
-        ...createChangeViewChange(),
-        revisions: {
-          rev1: createRevision(),
-        },
-        current_revision: 'rev1' as CommitId,
-        status: ChangeStatus.NEW,
-        labels: {},
-        actions: {},
-      };
-
-      const reloadChangeStub = sinon.stub(element, '_reload');
-      pressAndReleaseKeyOn(element, 82, 'shift', 'r');
-      flush(() => {
-        assert.isTrue(reloadChangeStub.called);
-        done();
-      });
+    test('Z should collapse all messages', async () => {
+      await flush();
+      const handleExpand = sinon.stub(
+        element.messagesList!,
+        'handleExpandCollapse'
+      );
+      pressAndReleaseKeyOn(element, 90, null, 'z');
+      assert(handleExpand.calledWith(false));
     });
 
     test('d should open download overlay', () => {
@@ -867,211 +795,31 @@
         'open'
       );
       element._loggedIn = false;
-      element.disableDiffPrefs = true;
       pressAndReleaseKeyOn(element, 188, null, ',');
       assert.isFalse(stub.called);
 
       element._loggedIn = true;
       pressAndReleaseKeyOn(element, 188, null, ',');
-      assert.isFalse(stub.called);
-
-      element.disableDiffPrefs = false;
-      pressAndReleaseKeyOn(element, 188, null, ',');
       assert.isTrue(stub.called);
     });
 
     test('m should toggle diff mode', () => {
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const setModeStub = sinon.stub(
         element.$.fileListHeader,
         'setDiffViewMode'
       );
-      const e = {preventDefault: () => {}} as CustomKeyboardEvent;
       flush();
 
       element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
-      element._handleToggleDiffMode(e);
+      element._handleToggleDiffMode();
       assert.isTrue(setModeStub.calledWith(DiffViewMode.UNIFIED));
 
       element.viewState.diffMode = DiffViewMode.UNIFIED;
-      element._handleToggleDiffMode(e);
+      element._handleToggleDiffMode();
       assert.isTrue(setModeStub.calledWith(DiffViewMode.SIDE_BY_SIDE));
     });
   });
 
-  suite('reloading drafts', () => {
-    let reloadStub: SinonStubbedMember<
-      typeof element.$.commentAPI.reloadDrafts
-    >;
-    const drafts: {[path: string]: UIDraft[]} = {
-      'testfile.txt': [
-        {
-          patch_set: 5 as PatchSetNum,
-          id: 'dd2982f5_c01c9e6a' as UrlEncodedCommentId,
-          line: 1,
-          updated: '2017-11-08 18:47:45.000000000' as Timestamp,
-          message: 'test',
-          unresolved: true,
-        },
-      ],
-    };
-    setup(() => {
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      reloadStub = sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts,
-          getAllThreadsForChange: () => [] as CommentThread[],
-          computeDraftCount: () => 1,
-        } as ChangeComments)
-      );
-      element._changeNum = 1 as NumericChangeId;
-    });
-
-    test('drafts are reloaded when reload-drafts fired', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.isTrue(reloadStub.called);
-              assert.deepEqual(element._diffDrafts, drafts);
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-
-    test('drafts are reloaded when comment-refresh fired', () => {
-      element.dispatchEvent(
-        new CustomEvent('comment-refresh', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadStub.called);
-    });
-  });
-
-  suite('_recomputeComments', () => {
-    setup(() => {
-      element._changeNum = TEST_NUMERIC_CHANGE_ID;
-      element._change = createChangeViewChange();
-      flush();
-      // Fake computeDraftCount as its required for ChangeComments,
-      // see gr-comment-api#reloadDrafts.
-      sinon.stub(element.$.commentAPI, 'reloadDrafts').returns(
-        Promise.resolve({
-          drafts: {},
-          getAllThreadsForChange: () => THREADS,
-          computeDraftCount: () => 0,
-        } as ChangeComments)
-      );
-      element._change = createChangeViewChange();
-      element._changeNum = element._change._number;
-    });
-
-    test('draft threads should be a new copy with correct states', done => {
-      element.$.fileList.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {
-            resolve: () => {
-              assert.equal(element._draftCommentThreads!.length, 2);
-              assert.equal(
-                element._draftCommentThreads![0].rootId,
-                THREADS[0].rootId
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments,
-                THREADS[0].comments
-              );
-              assert.notEqual(
-                element._draftCommentThreads![0].comments[0],
-                THREADS[0].comments[0]
-              );
-              assert.isTrue(
-                element
-                  ._draftCommentThreads![0].comments.slice(0, 2)
-                  .every(c => c.collapsed === true)
-              );
-
-              assert.isTrue(
-                element._draftCommentThreads![0].comments[2].collapsed === false
-              );
-              done();
-            },
-          },
-          composed: true,
-          bubbles: true,
-        })
-      );
-    });
-  });
-
-  test('diff comments modified', () => {
-    const reloadThreadsSpy = sinon.spy(element, '_handleReloadCommentThreads');
-    return element._reloadComments().then(() => {
-      element.dispatchEvent(
-        new CustomEvent('diff-comments-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadThreadsSpy.called);
-    });
-  });
-
-  test('thread list modified', () => {
-    const reloadDiffSpy = sinon.spy(element, '_handleReloadDiffComments');
-    element._activeTabs = [PrimaryTab.COMMENT_THREADS, SecondaryTab.CHANGE_LOG];
-    flush();
-
-    return element._reloadComments().then(() => {
-      element.threadList!.dispatchEvent(
-        new CustomEvent('thread-list-modified', {
-          composed: true,
-          bubbles: true,
-        })
-      );
-      assert.isTrue(reloadDiffSpy.called);
-
-      let draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(1);
-      assert.equal(
-        element._computeTotalCommentCounts(5, element._changeComments!),
-        '5 unresolved, 1 draft'
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        '1 draft'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(0);
-      assert.equal(
-        element._computeTotalCommentCounts(0, element._changeComments!),
-        ''
-      );
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved'
-      );
-      draftStub.restore();
-      draftStub = sinon
-        .stub(element._changeComments!, 'computeDraftCount')
-        .returns(2);
-      assert.equal(
-        element._computeTotalCommentCounts(1, element._changeComments!),
-        '1 unresolved, 2 drafts'
-      );
-      draftStub.restore();
-    });
-  });
-
   suite('thread list and change log tabs', () => {
     setup(() => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
@@ -1102,14 +850,14 @@
         '#relatedChanges'
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
-      sinon.stub(element, '_reload').returns(Promise.resolve([]));
+      sinon.stub(element, 'loadData').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
       element.params = createAppElementChangeViewParams();
     });
   });
 
   suite('Findings comment tab', () => {
-    setup(done => {
+    setup(async () => {
       element._changeNum = TEST_NUMERIC_CHANGE_ID;
       element._change = {
         ...createChangeViewChange(),
@@ -1123,11 +871,10 @@
         current_revision: 'rev4' as CommitId,
       };
       element._commentThreads = THREADS;
+      await flush();
       const paperTabs = element.shadowRoot!.querySelector('#primaryTabs')!;
       tap(paperTabs.querySelectorAll('paper-tab')[3]);
-      flush(() => {
-        done();
-      });
+      await flush();
     });
 
     test('robot comments count per patchset', () => {
@@ -1164,12 +911,10 @@
       );
     });
 
-    test('changing patchsets resets robot comments', done => {
+    test('changing patchsets resets robot comments', async () => {
       element.set('_change.current_revision', 'rev3');
-      flush(() => {
-        assert.equal(element._robotCommentThreads!.length, 1);
-        done();
-      });
+      await flush();
+      assert.equal(element._robotCommentThreads!.length, 1);
     });
 
     test('Show more button is hidden', () => {
@@ -1177,15 +922,13 @@
     });
 
     suite('robot comments show more button', () => {
-      setup(done => {
+      setup(async () => {
         const arr = [];
         for (let i = 0; i <= 30; i++) {
           arr.push(...THREADS);
         }
         element._commentThreads = arr;
-        flush(() => {
-          done();
-        });
+        await flush();
       });
 
       test('Show more button is rendered', () => {
@@ -1196,12 +939,10 @@
         );
       });
 
-      test('Clicking show more button renders all comments', done => {
+      test('Clicking show more button renders all comments', async () => {
         tap(element.shadowRoot!.querySelector('.show-robot-comments')!);
-        flush(() => {
-          assert.equal(element._robotCommentThreads!.length, 62);
-          done();
-        });
+        await flush();
+        assert.equal(element._robotCommentThreads!.length, 62);
       });
     });
   });
@@ -1223,14 +964,12 @@
     assert.isTrue(openDialogStub.called);
   });
 
-  test('fetches the server config on attached', done => {
-    flush(() => {
-      assert.equal(
-        element._serverConfig!.user.anonymous_coward_name,
-        'test coward name'
-      );
-      done();
-    });
+  test('fetches the server config on attached', async () => {
+    await flush();
+    assert.equal(
+      element._serverConfig!.user.anonymous_coward_name,
+      'test coward name'
+    );
   });
 
   test('_changeStatuses', () => {
@@ -1256,15 +995,153 @@
       },
     };
     element._mergeable = true;
-    const expectedStatuses = ['Merged', 'WIP'];
+    const expectedStatuses = [ChangeStates.MERGED, ChangeStates.WIP];
     assert.deepEqual(element._changeStatuses, expectedStatuses);
     flush();
-    const statusChips = element.shadowRoot!.querySelectorAll(
-      'gr-change-status'
-    );
+    const statusChips =
+      element.shadowRoot!.querySelectorAll('gr-change-status');
     assert.equal(statusChips.length, 2);
   });
 
+  suite('ChangeStatus revert', () => {
+    test('do not show any chip if no revert created', async () => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      await flush();
+      element.computeRevertSubmitted(element._change);
+      await flush();
+      assert.isFalse(
+        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+      );
+      assert.isFalse(
+        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+      );
+    });
+
+    test('do not show any chip if all reverts are abandoned', async () => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.ABANDONED,
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      await flush();
+      element.computeRevertSubmitted(element._change);
+      await flush();
+      assert.isFalse(
+        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+      );
+      assert.isFalse(
+        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+      );
+    });
+
+    test('show revert created if no revert is merged', async () => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      change.messages[1].message = 'Created a revert of this change as 23456';
+      change.messages[1].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      await flush();
+      element.computeRevertSubmitted(element._change);
+      await flush();
+      assert.isFalse(
+        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+      );
+      assert.isTrue(
+        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+      );
+    });
+
+    test('show revert submitted if revert is merged', async () => {
+      const change = {
+        ...createChange(),
+        messages: createChangeMessages(2),
+      };
+      change.messages[0].message = 'Created a revert of this change as 12345';
+      change.messages[0].tag = MessageTag.TAG_REVERT as ReviewInputTag;
+      const getChangeStub = stubRestApi('getChange');
+      getChangeStub.onFirstCall().returns(
+        Promise.resolve({
+          ...createChange(),
+          status: ChangeStatus.MERGED,
+        })
+      );
+      getChangeStub.onSecondCall().returns(
+        Promise.resolve({
+          ...createChange(),
+        })
+      );
+      element._change = change;
+      element._mergeable = true;
+      element._submitEnabled = true;
+      await flush();
+      element.computeRevertSubmitted(element._change);
+      await flush();
+      assert.isFalse(
+        element._changeStatuses?.includes(ChangeStates.REVERT_CREATED)
+      );
+      assert.isTrue(
+        element._changeStatuses?.includes(ChangeStates.REVERT_SUBMITTED)
+      );
+    });
+  });
+
   test('diff preferences open when open-diff-prefs is fired', () => {
     const overlayOpenStub = sinon.stub(element.$.fileList, 'openDiffPrefs');
     element.$.fileListHeader.dispatchEvent(
@@ -1330,7 +1207,7 @@
     };
     element._change = change;
     flush();
-    const reloadStub = sinon.stub(element, '_reload');
+    const reloadStub = sinon.stub(element, 'loadData');
     element.splice('_change.labels.test.all', 0, 1);
     assert.isFalse(reloadStub.called);
     change.labels.test.all.push(vote);
@@ -1345,66 +1222,18 @@
   test('reply button has updated count when there are drafts', () => {
     const getLabel = element._computeReplyButtonLabel;
 
-    assert.equal(getLabel(null, false), 'Reply');
-    assert.equal(getLabel(null, true), 'Start Review');
+    assert.equal(getLabel(undefined, false), 'Reply');
+    assert.equal(getLabel(undefined, true), 'Reply');
 
-    const changeRecord: ElementPropertyDeepChange<
-      GrChangeView,
-      '_diffDrafts'
-    > = {base: undefined, path: '', value: undefined};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
+    let drafts = {};
+    assert.equal(getLabel(drafts, false), 'Reply');
 
-    changeRecord.base = {};
-    assert.equal(getLabel(changeRecord, false), 'Reply');
-
-    changeRecord.base = {
+    drafts = {
       'file1.txt': [{}],
       'file2.txt': [{}, {}],
     };
-    assert.equal(getLabel(changeRecord, false), 'Reply (3)');
-    assert.equal(getLabel(changeRecord, true), 'Start Review (3)');
-  });
-
-  test('comment events properly update diff drafts', () => {
-    element._patchRange = {
-      basePatchNum: ParentPatchSetNum,
-      patchNum: 2 as RevisionPatchSetNum,
-    };
-    const draft: DraftInfo = {
-      __draft: true,
-      id: 'id1' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hello',
-    };
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    draft.patch_set = undefined;
-    draft.message = 'hello, there';
-    element._handleCommentSave(new CustomEvent('', {detail: {comment: draft}}));
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-    const draft2: DraftInfo = {
-      __draft: true,
-      id: 'id2' as UrlEncodedCommentId,
-      path: '/foo/bar.txt',
-      message: 'hola',
-    };
-    element._handleCommentSave(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    draft2.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft, draft2]});
-    draft.patch_set = undefined;
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft}})
-    );
-    draft.patch_set = 2 as PatchSetNum;
-    assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft2]});
-    element._handleCommentDiscard(
-      new CustomEvent('', {detail: {comment: draft2}})
-    );
-    assert.deepEqual(element._diffDrafts, {});
+    assert.equal(getLabel(drafts, false), 'Reply (3)');
+    assert.equal(getLabel(drafts, true), 'Start Review (3)');
   });
 
   test('change num change', () => {
@@ -1463,17 +1292,15 @@
     assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
   });
 
-  test('diffMode defaults to side by side without preferences', done => {
+  test('diffMode defaults to side by side without preferences', async () => {
     stubRestApi('getPreferences').returns(Promise.resolve(createPreferences()));
     // No user prefs or diff view mode set.
 
-    element._setDiffViewMode()!.then(() => {
-      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-      done();
-    });
+    await element._setDiffViewMode()!;
+    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
   });
 
-  test('diffMode defaults to preference when not already set', done => {
+  test('diffMode defaults to preference when not already set', async () => {
     stubRestApi('getPreferences').returns(
       Promise.resolve({
         ...createPreferences(),
@@ -1481,13 +1308,11 @@
       })
     );
 
-    element._setDiffViewMode()!.then(() => {
-      assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
-      done();
-    });
+    await element._setDiffViewMode()!;
+    assert.equal(element.viewState.diffMode, DiffViewMode.UNIFIED);
   });
 
-  test('existing diffMode overrides preference', done => {
+  test('existing diffMode overrides preference', async () => {
     element.viewState.diffMode = DiffViewMode.SIDE_BY_SIDE;
     stubRestApi('getPreferences').returns(
       Promise.resolve({
@@ -1495,16 +1320,14 @@
         default_diff_view: DiffViewMode.UNIFIED,
       })
     );
-    element._setDiffViewMode()!.then(() => {
-      assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
-      done();
-    });
+    await element._setDiffViewMode()!;
+    assert.equal(element.viewState.diffMode, DiffViewMode.SIDE_BY_SIDE);
   });
 
-  test('don’t reload entire page when patchRange changes', () => {
+  test('don’t reload entire page when patchRange changes', async () => {
     const reloadStub = sinon
-      .stub(element, '_reload')
-      .callsFake(() => Promise.resolve([]));
+      .stub(element, 'loadData')
+      .callsFake(() => Promise.resolve());
     const reloadPatchDependentStub = sinon
       .stub(element, '_reloadPatchNumDependentResources')
       .callsFake(() => Promise.resolve([undefined, undefined, undefined]));
@@ -1516,21 +1339,30 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._paramsChanged(value);
+    element.params = value;
+    await flush();
     assert.isTrue(reloadStub.calledOnce);
 
     element._initialLoadComplete = true;
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+    };
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element._paramsChanged(value);
+    element.params = {...value};
+    await flush();
     assert.isFalse(reloadStub.calledTwice);
     assert.isTrue(reloadPatchDependentStub.calledOnce);
     assert.isTrue(collapseStub.calledTwice);
   });
 
-  test('reload ported comments when patchNum changes', () => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
+  test('reload ported comments when patchNum changes', async () => {
+    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
     sinon.stub(element, '_getCommitInfo');
     sinon.stub(element.$.fileList, 'reload');
     flush();
@@ -1545,41 +1377,67 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element._paramsChanged(value);
+    element.params = value;
+    await flush();
 
     element._initialLoadComplete = true;
+    element._change = {
+      ...createChangeViewChange(),
+      revisions: {
+        rev1: createRevision(1),
+        rev2: createRevision(2),
+      },
+    };
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element._paramsChanged(value);
+    element.params = {...value};
+    await flush();
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
   });
 
-  test('reload entire page when patchRange doesnt change', () => {
+  test('reload entire page when patchRange doesnt change', async () => {
     const reloadStub = sinon
-      .stub(element, '_reload')
-      .callsFake(() => Promise.resolve([]));
+      .stub(element, 'loadData')
+      .callsFake(() => Promise.resolve());
     const collapseStub = sinon.stub(element.$.fileList, 'collapseAllDiffs');
-    const value: AppElementChangeViewParams = createAppElementChangeViewParams();
-    element._paramsChanged(value);
+    const value: AppElementChangeViewParams =
+      createAppElementChangeViewParams();
+    element.params = value;
+    await flush();
     assert.isTrue(reloadStub.calledOnce);
     element._initialLoadComplete = true;
-    element._paramsChanged(value);
+    element.params = {...value};
+    await flush();
     assert.isTrue(reloadStub.calledTwice);
     assert.isTrue(collapseStub.calledTwice);
   });
 
-  test('related changes are not updated after other action', done => {
-    sinon.stub(element, '_reload').callsFake(() => Promise.resolve([]));
-    flush();
+  test('do not handle new change numbers', async () => {
+    const recreateSpy = sinon.spy();
+    element.addEventListener('recreate-change-view', recreateSpy);
+
+    const value: AppElementChangeViewParams =
+      createAppElementChangeViewParams();
+    element.params = value;
+    await flush();
+    assert.isFalse(recreateSpy.calledOnce);
+
+    value.changeNum = 555111333 as NumericChangeId;
+    element.params = {...value};
+    await flush();
+    assert.isTrue(recreateSpy.calledOnce);
+  });
+
+  test('related changes are not updated after other action', async () => {
+    sinon.stub(element, 'loadData').callsFake(() => Promise.resolve());
+    await flush();
     const relatedChanges = element.shadowRoot!.querySelector(
       '#relatedChanges'
     ) as GrRelatedChangesList;
     sinon.stub(relatedChanges, 'reload');
-    element._reload(true).then(() => {
-      assert.isFalse(navigateToChangeStub.called);
-      done();
-    });
+    await element.loadData(true);
+    assert.isFalse(navigateToChangeStub.called);
   });
 
   test('_computeCopyTextForTitle', () => {
@@ -1661,7 +1519,7 @@
     assert.equal(putStub.lastCall.args[1], '\n\n\n\n\n\n\n\n');
   });
 
-  test('topic is coalesced to null', done => {
+  test('topic is coalesced to null', async () => {
     sinon.stub(element, '_changeChanged');
     stubRestApi('getChangeDetail').returns(
       Promise.resolve({
@@ -1672,13 +1530,11 @@
       })
     );
 
-    element._getChangeDetail().then(() => {
-      assert.isNull(element._change!.topic);
-      done();
-    });
+    await element._getChangeDetail();
+    assert.isNull(element._change!.topic);
   });
 
-  test('commit sha is populated from getChangeDetail', done => {
+  test('commit sha is populated from getChangeDetail', async () => {
     sinon.stub(element, '_changeChanged');
     stubRestApi('getChangeDetail').callsFake(() =>
       Promise.resolve({
@@ -1689,10 +1545,8 @@
       })
     );
 
-    element._getChangeDetail().then(() => {
-      assert.equal('foo', element._commitInfo!.commit);
-      done();
-    });
+    await element._getChangeDetail();
+    assert.equal('foo', element._commitInfo!.commit);
   });
 
   test('edit is added to change', () => {
@@ -1776,43 +1630,39 @@
     assert.equal(element._getBasePatchNum(_change2, _patchRange), 'PARENT');
   });
 
-  test('_openReplyDialog called with `ANY` when coming from tap event', done => {
-    flush(() => {
-      const openStub = sinon.stub(element, '_openReplyDialog');
-      tap(element.$.replyBtn);
-      assert(
-        openStub.lastCall.calledWithExactly(
-          element.$.replyDialog.FocusTarget.ANY
-        ),
-        '_openReplyDialog should have been passed ANY'
-      );
-      assert.equal(openStub.callCount, 1);
-      done();
-    });
+  test('_openReplyDialog called with `ANY` when coming from tap event', async () => {
+    await flush();
+    const openStub = sinon.stub(element, '_openReplyDialog');
+    tap(element.$.replyBtn);
+    assert(
+      openStub.lastCall.calledWithExactly(
+        element.$.replyDialog.FocusTarget.ANY
+      ),
+      '_openReplyDialog should have been passed ANY'
+    );
+    assert.equal(openStub.callCount, 1);
   });
 
   test(
     '_openReplyDialog called with `BODY` when coming from message reply' +
       'event',
-    done => {
-      flush(() => {
-        const openStub = sinon.stub(element, '_openReplyDialog');
-        element.messagesList!.dispatchEvent(
-          new CustomEvent('reply', {
-            detail: {message: {message: 'text'}},
-            composed: true,
-            bubbles: true,
-          })
-        );
-        assert(
-          openStub.lastCall.calledWithExactly(
-            element.$.replyDialog.FocusTarget.BODY
-          ),
-          '_openReplyDialog should have been passed BODY'
-        );
-        assert.equal(openStub.callCount, 1);
-        done();
-      });
+    async () => {
+      await flush();
+      const openStub = sinon.stub(element, '_openReplyDialog');
+      element.messagesList!.dispatchEvent(
+        new CustomEvent('reply', {
+          detail: {message: {message: 'text'}},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert(
+        openStub.lastCall.calledWithExactly(
+          element.$.replyDialog.FocusTarget.BODY
+        ),
+        '_openReplyDialog should have been passed BODY'
+      );
+      assert.equal(openStub.callCount, 1);
     }
   );
 
@@ -1854,7 +1704,7 @@
     assert.isNull(element._getUrlParameter('test'));
   });
 
-  test('revert dialog opened with revert param', done => {
+  test('revert dialog opened with revert param', async () => {
     const awaitPluginsLoadedStub = sinon
       .stub(getPluginLoader(), 'awaitPluginsLoaded')
       .callsFake(() => Promise.resolve());
@@ -1880,45 +1730,15 @@
       return param;
     });
 
-    sinon.stub(element.$.actions, 'showRevertDialog').callsFake(done);
+    const promise = mockPromise();
+
+    sinon
+      .stub(element.$.actions, 'showRevertDialog')
+      .callsFake(() => promise.resolve());
 
     element._maybeShowRevertDialog();
     assert.isTrue(awaitPluginsLoadedStub.called);
-  });
-
-  suite('scroll related tests', () => {
-    test('document scrolling calls function to set scroll height', done => {
-      const originalHeight = document.body.scrollHeight;
-      const scrollStub = sinon.stub(element, 'handleScroll').callsFake(() => {
-        assert.isTrue(scrollStub.called);
-        document.body.style.height = `${originalHeight}px`;
-        scrollStub.restore();
-        done();
-      });
-      document.body.style.height = '10000px';
-      element.handleScroll();
-    });
-
-    test('scrollTop is set correctly', () => {
-      element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
-
-      sinon.stub(element, '_reload').callsFake(() => {
-        // When element is reloaded, ensure that the history
-        // state has the scrollTop set earlier. This will then
-        // be reset.
-        assert.isTrue(element.viewState.scrollTop === TEST_SCROLL_TOP_PX);
-        return Promise.resolve([]);
-      });
-
-      // simulate reloading component, which is done when route
-      // changes to match a regex of change view type.
-      element._paramsChanged({...createAppElementChangeViewParams()});
-    });
-
-    test('scrollTop is reset when new change is loaded', () => {
-      element._resetFileListViewState();
-      assert.equal(element.viewState.scrollTop, 0);
-    });
+    await promise;
   });
 
   suite('reply dialog tests', () => {
@@ -1941,7 +1761,7 @@
       );
     });
 
-    test('show reply dialog on open-reply-dialog event', done => {
+    test('show reply dialog on open-reply-dialog event', async () => {
       const openReplyDialogStub = sinon.stub(element, '_openReplyDialog');
       element.dispatchEvent(
         new CustomEvent('open-reply-dialog', {
@@ -1950,10 +1770,8 @@
           detail: {},
         })
       );
-      flush(() => {
-        assert.isTrue(openReplyDialogStub.calledOnce);
-        done();
-      });
+      await flush();
+      assert.isTrue(openReplyDialogStub.calledOnce);
     });
 
     test('reply from comment adds quote text', () => {
@@ -1992,10 +1810,10 @@
       const div = document.createElement('div');
       element.$.replyDialog.draft = '> quote text\n\n some draft text';
       element.$.replyDialog.quote = '> quote text\n\n';
-      const e = ({
+      const e = {
         target: div,
         preventDefault: sinon.spy(),
-      } as unknown) as MouseEvent;
+      } as unknown as MouseEvent;
       element._handleReplyTap(e);
       assert.equal(
         element.$.replyDialog.draft,
@@ -2005,13 +1823,11 @@
     });
   });
 
-  test('reply button is disabled until server config is loaded', done => {
+  test('reply button is disabled until server config is loaded', async () => {
     assert.isTrue(element._replyDisabled);
     // fetches the server config on attached
-    flush(() => {
-      assert.isFalse(element._replyDisabled);
-      done();
-    });
+    await flush();
+    assert.isFalse(element._replyDisabled);
   });
 
   test('header class computation', () => {
@@ -2019,19 +1835,17 @@
     assert.equal(element._computeHeaderClass(true), 'header editMode');
   });
 
-  test('_maybeScrollToMessage', done => {
-    flush(() => {
-      const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
+  test('_maybeScrollToMessage', async () => {
+    await flush();
+    const scrollStub = sinon.stub(element.messagesList!, 'scrollToMessage');
 
-      element._maybeScrollToMessage('');
-      assert.isFalse(scrollStub.called);
-      element._maybeScrollToMessage('message');
-      assert.isFalse(scrollStub.called);
-      element._maybeScrollToMessage('#message-TEST');
-      assert.isTrue(scrollStub.called);
-      assert.equal(scrollStub.lastCall.args[0], 'TEST');
-      done();
-    });
+    element._maybeScrollToMessage('');
+    assert.isFalse(scrollStub.called);
+    element._maybeScrollToMessage('message');
+    assert.isFalse(scrollStub.called);
+    element._maybeScrollToMessage('#message-TEST');
+    assert.isTrue(scrollStub.called);
+    assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
   test('topic update reloads related changes', () => {
@@ -2290,94 +2104,93 @@
       };
     });
 
-    test('edit exists in revisions', done => {
+    test('edit exists in revisions', async () => {
+      const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
         assert.equal(args.length, 2);
         assert.equal(args[1], EditPatchSetNum); // patchNum
-        done();
+        promise.resolve();
       });
 
       element.set('_change.revisions.rev2', {
         _number: EditPatchSetNum,
       });
-      flush();
+      await flush();
 
       fireEdit();
+      await promise;
     });
 
-    test('no edit exists in revisions, non-latest patchset', done => {
+    test('no edit exists in revisions, non-latest patchset', async () => {
+      const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
         assert.equal(args.length, 4);
         assert.equal(args[1], 1 as PatchSetNum); // patchNum
         assert.equal(args[3], true); // opt_isEdit
-        done();
+        promise.resolve();
       });
 
       element.set('_change.revisions.rev2', {_number: 2});
       element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
-      flush();
+      await flush();
 
       fireEdit();
+      await promise;
     });
 
-    test('no edit exists in revisions, latest patchset', done => {
+    test('no edit exists in revisions, latest patchset', async () => {
+      const promise = mockPromise();
       sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
         assert.equal(args.length, 4);
         // No patch should be specified when patchNum == latest.
         assert.isNotOk(args[1]); // patchNum
         assert.equal(args[3], true); // opt_isEdit
-        done();
+        promise.resolve();
       });
 
       element.set('_change.revisions.rev2', {_number: 2});
       element._patchRange = {patchNum: 2 as RevisionPatchSetNum};
-      flush();
+      await flush();
 
       fireEdit();
+      await promise;
     });
   });
 
-  test('_handleStopEditTap', done => {
+  test('_handleStopEditTap', async () => {
     element._change = {
       ...createChangeViewChange(),
     };
     sinon.stub(element.$.metadata, '_computeLabelNames');
     navigateToChangeStub.restore();
+    const promise = mockPromise();
     sinon.stub(GerritNav, 'navigateToChange').callsFake((...args) => {
       assert.equal(args.length, 2);
       assert.equal(args[1], 1 as PatchSetNum); // patchNum
-      done();
+      promise.resolve();
     });
 
     element._patchRange = {patchNum: 1 as RevisionPatchSetNum};
     element.$.actions.dispatchEvent(
       new CustomEvent('stop-edit-tap', {bubbles: false})
     );
+    await promise;
   });
 
   suite('plugin endpoints', () => {
-    test('endpoint params', done => {
+    test('endpoint params', async () => {
       element._change = {...createChangeViewChange(), labels: {}};
       element._selectedRevision = createRevision();
-      let hookEl: HTMLElement;
-      let plugin: PluginApi;
-      pluginApi.install(
-        p => {
-          plugin = p;
-          plugin
-            .hook('change-view-integration')
-            .getLastAttached()
-            .then(el => (hookEl = el));
-        },
-        '0.1',
-        'http://some/plugins/url.js'
-      );
-      flush(() => {
-        assert.strictEqual((hookEl as any).plugin, plugin);
-        assert.strictEqual((hookEl as any).change, element._change);
-        assert.strictEqual((hookEl as any).revision, element._selectedRevision);
-        done();
-      });
+      const promise = mockPromise();
+      pluginApi.install(promise.resolve, '0.1', 'http://some/plugins/url.js');
+      await flush();
+      const plugin: PluginApi = (await promise) as PluginApi;
+      const hookEl = await plugin
+        .hook('change-view-integration')
+        .getLastAttached();
+      assert.strictEqual((hookEl as any).plugin, plugin);
+      assert.strictEqual((hookEl as any).change, element._change);
+      assert.strictEqual((hookEl as any).revision, element._selectedRevision);
     });
   });
 
@@ -2417,23 +2230,6 @@
     });
   });
 
-  test('_paramsChanged sets in projectLookup', () => {
-    flush();
-    const relatedChanges = element.shadowRoot!.querySelector(
-      '#relatedChanges'
-    ) as GrRelatedChangesList;
-    sinon.stub(relatedChanges, 'reload');
-    sinon.stub(element, '_reload').returns(Promise.resolve([]));
-    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 never, TEST_PROJECT_NAME as never));
-  });
-
   test('_handleToggleStar called when star is tapped', () => {
     element._change = {
       ...createChangeViewChange(),
@@ -2456,7 +2252,6 @@
       };
       sinon.stub(element, '_getChangeDetail').returns(Promise.resolve(false));
       sinon.stub(element, '_getProjectConfig').returns(Promise.resolve());
-      sinon.stub(element, '_reloadComments').returns(Promise.resolve());
       sinon.stub(element, '_getMergeability').returns(Promise.resolve());
       sinon.stub(element, '_getLatestCommitMessage').returns(Promise.resolve());
       sinon
@@ -2464,7 +2259,7 @@
         .returns(Promise.resolve([undefined, undefined, undefined]));
     });
 
-    test("don't report changeDisplayed on reply", done => {
+    test("don't report changeDisplayed on reply", async () => {
       const changeDisplayStub = sinon.stub(
         appContext.reportingService,
         'changeDisplayed'
@@ -2474,14 +2269,12 @@
         'changeFullyLoaded'
       );
       element._handleReplySent();
-      flush(() => {
-        assert.isFalse(changeDisplayStub.called);
-        assert.isFalse(changeFullyLoadedStub.called);
-        done();
-      });
+      await flush();
+      assert.isFalse(changeDisplayStub.called);
+      assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on _paramsChanged', done => {
+    test('report changeDisplayed on _paramsChanged', async () => {
       const changeDisplayStub = sinon.stub(
         appContext.reportingService,
         'changeDisplayed'
@@ -2490,16 +2283,14 @@
         appContext.reportingService,
         'changeFullyLoaded'
       );
-      element._paramsChanged({
+      element.params = {
         ...createAppElementChangeViewParams(),
-        changeNum: 101 as NumericChangeId,
+        changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
-      });
-      flush(() => {
-        assert.isTrue(changeDisplayStub.called);
-        assert.isTrue(changeFullyLoadedStub.called);
-        done();
-      });
+      };
+      await flush();
+      assert.isTrue(changeDisplayStub.called);
+      assert.isTrue(changeFullyLoadedStub.called);
     });
   });
 
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
index 3ba12b43..0ce3b07 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.ts
@@ -14,13 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
+
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-commit-info_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property, computed} from '@polymer/decorators';
 import {ChangeInfo, CommitInfo, ServerInfo} from '../../../types/common';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,11 +29,7 @@
 }
 
 @customElement('gr-commit-info')
-export class GrCommitInfo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCommitInfo extends LitElement {
   // TODO(TS): can not use `?` here as @computed require dependencies as
   // not optional
   @property({type: Object})
@@ -47,7 +43,48 @@
   @property({type: Object})
   serverConfig: ServerInfo | undefined;
 
-  @computed('change', 'commitInfo', 'serverConfig')
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .container {
+          align-items: center;
+          display: flex;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <div class="container">
+      <a
+        target="_blank"
+        rel="noopener"
+        href="${this.computeCommitLink(
+          this._webLink,
+          this.change,
+          this.commitInfo,
+          this.serverConfig
+        )}"
+        >${this._computeShortHash(
+          this.change,
+          this.commitInfo,
+          this.serverConfig
+        )}</a
+      >
+      <gr-copy-clipboard
+        hastooltip
+        .buttonTitle="${'Copy full SHA to clipboard'}"
+        hideinput
+        .text="${this.commitInfo?.commit}"
+      >
+      </gr-copy-clipboard>
+    </div>`;
+  }
+
+  /**
+   * Used only within the tests.
+   */
   get _showWebLink(): boolean {
     if (!this.change || !this.commitInfo || !this.serverConfig) {
       return false;
@@ -61,7 +98,6 @@
     return !!weblink && !!weblink.url;
   }
 
-  @computed('change', 'commitInfo', 'serverConfig')
   get _webLink(): string | undefined {
     if (!this.change || !this.commitInfo || !this.serverConfig) {
       return '';
@@ -81,6 +117,18 @@
     });
   }
 
+  computeCommitLink(
+    webLink?: string,
+    change?: ChangeInfo,
+    commitInfo?: CommitInfo,
+    serverConfig?: ServerInfo
+  ) {
+    if (webLink) return webLink;
+    const hash = this._computeShortHash(change, commitInfo, serverConfig);
+    if (hash === undefined) return '';
+    return GerritNav.getUrlForSearchQuery(hash);
+  }
+
   _computeShortHash(
     change?: ChangeInfo,
     commitInfo?: CommitInfo,
@@ -90,7 +138,7 @@
       return '';
     }
 
-    const {name} = this._getWeblink(change, commitInfo, serverConfig) || {};
-    return name;
+    const weblink = this._getWeblink(change, commitInfo, serverConfig);
+    return weblink?.name ?? '';
   }
 }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
deleted file mode 100644
index df0bb4a..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_html.ts
+++ /dev/null
@@ -1,43 +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">
-    .container {
-      align-items: center;
-      display: flex;
-    }
-  </style>
-  <div class="container">
-    <template is="dom-if" if="[[_showWebLink]]">
-      <a target="_blank" rel="noopener" href$="[[_webLink]]"
-        >[[_computeShortHash(change, commitInfo, serverConfig)]]</a
-      >
-    </template>
-    <template is="dom-if" if="[[!_showWebLink]]">
-      [[_computeShortHash(change, commitInfo, serverConfig)]]
-    </template>
-    <gr-copy-clipboard
-      has-tooltip=""
-      button-title="Copy full SHA to clipboard"
-      hide-input=""
-      text="[[commitInfo.commit]]"
-    >
-    </gr-copy-clipboard>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
deleted file mode 100644
index ffaed23..0000000
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.js
+++ /dev/null
@@ -1,117 +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 '../../core/gr-router/gr-router.js';
-import './gr-commit-info.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-
-const basicFixture = fixtureFromElement('gr-commit-info');
-
-suite('gr-commit-info tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('weblinks use GerritNav interface', () => {
-    const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
-        .returns([{name: 'stubb', url: '#s'}]);
-    element.change = {};
-    element.commitInfo = {};
-    element.serverConfig = {};
-    assert.isTrue(weblinksStub.called);
-  });
-
-  test('no web link when unavailable', () => {
-    element.commitInfo = {};
-    element.serverConfig = {};
-    element.change = {labels: [], project: ''};
-
-    assert.isNotOk(element._showWebLink);
-  });
-
-  test('use web link when available', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.change = {labels: [], project: ''};
-    element.commitInfo =
-        {commit: 'commitsha', web_links: [{name: 'gitweb', url: 'link-url'}]};
-    element.serverConfig = {};
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'link-url');
-  });
-
-  test('does not relativize web links that begin with scheme', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.change = {labels: [], project: ''};
-    element.commitInfo = {
-      commit: 'commitsha',
-      web_links: [{name: 'gitweb', url: 'https://link-url'}],
-    };
-    element.serverConfig = {};
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-  });
-
-  test('ignore web links that are neither gitweb nor gitiles', () => {
-    const router = document.createElement('gr-router');
-    sinon.stub(GerritNav, '_generateWeblinks').callsFake(
-        router._generateWeblinks.bind(router));
-
-    element.change = {project: 'project-name'};
-    element.commitInfo = {
-      commit: 'commit-sha',
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-        {
-          name: 'gitiles',
-          url: 'https://link-url',
-        },
-      ],
-    };
-    element.serverConfig = {};
-
-    assert.isOk(element._showWebLink);
-    assert.equal(element._webLink, 'https://link-url');
-
-    // Remove gitiles link.
-    element.commitInfo = {
-      commit: 'commit-sha',
-      web_links: [
-        {
-          name: 'ignore',
-          url: 'ignore',
-        },
-      ],
-    };
-    assert.isNotOk(element._showWebLink);
-    assert.isNotOk(element._webLink);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
new file mode 100644
index 0000000..cb6c9e4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.ts
@@ -0,0 +1,134 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import '../../core/gr-router/gr-router';
+import './gr-commit-info';
+import {GrCommitInfo} from './gr-commit-info';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  createChange,
+  createCommit,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {CommitId, RepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-commit-info');
+
+suite('gr-commit-info tests', () => {
+  let element: GrCommitInfo;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('weblinks use GerritNav interface', async () => {
+    const weblinksStub = sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .returns([{name: 'stubb', url: '#s'}]);
+    element.change = createChange();
+    element.commitInfo = createCommit();
+    element.serverConfig = createServerInfo();
+    await flush();
+    assert.isTrue(weblinksStub.called);
+  });
+
+  test('no web link when unavailable', () => {
+    element.commitInfo = createCommit();
+    element.serverConfig = createServerInfo();
+    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+
+    assert.isNotOk(element._showWebLink);
+  });
+
+  test('use web link when available', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+    element.commitInfo = {
+      ...createCommit(),
+      commit: 'commitsha' as CommitId,
+      web_links: [{name: 'gitweb', url: 'link-url'}],
+    };
+    element.serverConfig = createServerInfo();
+
+    assert.isOk(element._showWebLink);
+    assert.equal(element._webLink, 'link-url');
+  });
+
+  test('does not relativize web links that begin with scheme', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.change = {...createChange(), labels: {}, project: '' as RepoName};
+    element.commitInfo = {
+      ...createCommit(),
+      commit: 'commitsha' as CommitId,
+      web_links: [{name: 'gitweb', url: 'https://link-url'}],
+    };
+    element.serverConfig = createServerInfo();
+
+    assert.isOk(element._showWebLink);
+    assert.equal(element._webLink, 'https://link-url');
+  });
+
+  test('ignore web links that are neither gitweb nor gitiles', () => {
+    const router = document.createElement('gr-router');
+    sinon
+      .stub(GerritNav, '_generateWeblinks')
+      .callsFake(router._generateWeblinks.bind(router));
+
+    element.change = {...createChange(), project: 'project-name' as RepoName};
+    element.commitInfo = {
+      ...createCommit(),
+      commit: 'commit-sha' as CommitId,
+      web_links: [
+        {
+          name: 'ignore',
+          url: 'ignore',
+        },
+        {
+          name: 'gitiles',
+          url: 'https://link-url',
+        },
+      ],
+    };
+    element.serverConfig = createServerInfo();
+
+    assert.isOk(element._showWebLink);
+    assert.equal(element._webLink, 'https://link-url');
+
+    // Remove gitiles link.
+    element.commitInfo = {
+      ...createCommit(),
+      commit: 'commit-sha' as CommitId,
+      web_links: [
+        {
+          name: 'ignore',
+          url: 'ignore',
+        },
+      ],
+    };
+    assert.isNotOk(element._showWebLink);
+    assert.isNotOk(element._webLink);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
index 0954b7f..df537e0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.ts
@@ -19,9 +19,9 @@
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-confirm-abandon-dialog_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 export interface GrConfirmAbandonDialog {
   $: {
@@ -36,9 +36,7 @@
 }
 
 @customElement('gr-confirm-abandon-dialog')
-export class GrConfirmAbandonDialog extends KeyboardShortcutMixin(
-  PolymerElement
-) {
+export class GrConfirmAbandonDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -56,22 +54,35 @@
    */
 
   @property({type: String})
-  message?: string;
+  message = '';
 
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+        this._confirm()
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+        this._confirm()
+      )
+    );
   }
 
   resetFocus() {
     this.$.messageInput.textarea.focus();
   }
 
-  _handleEnterKey() {
-    this._confirm();
-  }
-
   _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
deleted file mode 100644
index 14d16f5..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.js
+++ /dev/null
@@ -1,62 +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 './gr-confirm-abandon-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
-
-suite('gr-confirm-abandon-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sinon.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sinon.spy(element, '_handleConfirmTap');
-    sinon.spy(element, '_confirm');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._confirm.called);
-    assert.isTrue(element._confirm.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sinon.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sinon.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
new file mode 100644
index 0000000..08dda14
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog_test.ts
@@ -0,0 +1,66 @@
+/**
+ * @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';
+import './gr-confirm-abandon-dialog';
+import {GrConfirmAbandonDialog} from './gr-confirm-abandon-dialog';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-abandon-dialog');
+
+suite('gr-confirm-abandon-dialog tests', () => {
+  let element: GrConfirmAbandonDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    const confirmTapSpy = sinon.spy(element, '_handleConfirmTap');
+    const confirmSpy = sinon.spy(element, '_confirm');
+    queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(confirmTapSpy.called);
+    assert.isTrue(confirmSpy.called);
+    assert.isTrue(confirmSpy.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    const cancelTapSpy = sinon.spy(element, '_handleCancelTap');
+    queryAndAssert<GrDialog>(element, 'gr-dialog').dispatchEvent(
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(cancelTapSpy.called);
+    assert.isTrue(cancelTapSpy.calledOnce);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
deleted file mode 100644
index c98353b..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.js
+++ /dev/null
@@ -1,61 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-cherrypick-conflict-dialog.js';
-
-const basicFixture =
-    fixtureFromElement('gr-confirm-cherrypick-conflict-dialog');
-
-suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('_handleConfirmTap', () => {
-    const confirmHandler = sinon.stub();
-    element.addEventListener('confirm', confirmHandler);
-    sinon.spy(element, '_handleConfirmTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('confirm', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(confirmHandler.called);
-    assert.isTrue(confirmHandler.calledOnce);
-    assert.isTrue(element._handleConfirmTap.called);
-    assert.isTrue(element._handleConfirmTap.calledOnce);
-  });
-
-  test('_handleCancelTap', () => {
-    const cancelHandler = sinon.stub();
-    element.addEventListener('cancel', cancelHandler);
-    sinon.spy(element, '_handleCancelTap');
-    element.shadowRoot
-        .querySelector('gr-dialog').dispatchEvent(
-            new CustomEvent('cancel', {
-              composed: true, bubbles: true,
-            }));
-    assert.isTrue(cancelHandler.called);
-    assert.isTrue(cancelHandler.calledOnce);
-    assert.isTrue(element._handleCancelTap.called);
-    assert.isTrue(element._handleCancelTap.calledOnce);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
new file mode 100644
index 0000000..f811619
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-conflict-dialog/gr-confirm-cherrypick-conflict-dialog_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {queryAndAssert} from '../../../utils/common-util';
+import {fireEvent} from '../../../utils/event-util';
+import './gr-confirm-cherrypick-conflict-dialog';
+import {GrConfirmCherrypickConflictDialog} from './gr-confirm-cherrypick-conflict-dialog';
+
+const basicFixture = fixtureFromElement(
+  'gr-confirm-cherrypick-conflict-dialog'
+);
+
+suite('gr-confirm-cherrypick-conflict-dialog tests', () => {
+  let element: GrConfirmCherrypickConflictDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('_handleConfirmTap', () => {
+    const confirmHandler = sinon.stub();
+    element.addEventListener('confirm', confirmHandler);
+    const confirmTapStub = sinon.spy(element, '_handleConfirmTap');
+    fireEvent(queryAndAssert(element, 'gr-dialog'), 'confirm');
+    assert.isTrue(confirmHandler.called);
+    assert.isTrue(confirmHandler.calledOnce);
+    assert.isTrue(confirmTapStub.called);
+    assert.isTrue(confirmTapStub.calledOnce);
+  });
+
+  test('_handleCancelTap', () => {
+    const cancelHandler = sinon.stub();
+    element.addEventListener('cancel', cancelHandler);
+    const cancelTapStub = sinon.spy(element, '_handleCancelTap');
+    fireEvent(queryAndAssert(element, 'gr-dialog'), 'cancel');
+    assert.isTrue(cancelHandler.called);
+    assert.isTrue(cancelHandler.calledOnce);
+    assert.isTrue(cancelTapStub.called);
+    assert.isTrue(cancelTapStub.calledOnce);
+  });
+});
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 af565cb..b061043 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
@@ -25,15 +25,14 @@
 import {appContext} from '../../../services/app-context';
 import {
   ChangeInfo,
-  BranchInfo,
-  RepoName,
   BranchName,
+  RepoName,
   CommitId,
   ChangeInfoId,
 } from '../../../types/common';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {customElement, property, observe} from '@polymer/decorators';
-import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
 import {HttpMethod, ChangeStatus} from '../../../constants/constants';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {fireEvent} from '../../../utils/event-util';
@@ -68,7 +67,7 @@
 
 export interface GrConfirmCherrypickDialog {
   $: {
-    branchInput: HTMLElement;
+    branchInput: GrTypedAutocomplete<BranchName>;
   };
 }
 
@@ -91,7 +90,7 @@
    */
 
   @property({type: String})
-  branch?: BranchName;
+  branch = '' as BranchName;
 
   @property({type: String})
   baseCommit?: string;
@@ -106,7 +105,7 @@
   commitNum?: CommitId;
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   @property({type: String})
   project?: RepoName;
@@ -115,7 +114,7 @@
   changes: ChangeInfo[] = [];
 
   @property({type: Object})
-  _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+  _query?: (input: string) => Promise<{name: BranchName}[]>;
 
   @property({type: Boolean})
   _showCherryPickTopic = false;
@@ -150,18 +149,25 @@
     this._query = (text: string) => this._getProjectBranchesSuggestions(text);
   }
 
+  containsDuplicateProject(changes: ChangeInfo[]) {
+    const projects: {[projectName: string]: boolean} = {};
+    for (let i = 0; i < changes.length; i++) {
+      const change = changes[i];
+      if (projects[change.project]) {
+        return true;
+      }
+      projects[change.project] = true;
+    }
+    return false;
+  }
+
   updateChanges(changes: ChangeInfo[]) {
     this.changes = changes;
     this._statuses = {};
-    const projects: {[projectName: string]: boolean} = {};
-    this._duplicateProjectChanges = false;
     changes.forEach(change => {
       this.selectedChangeIds.add(change.id);
-      if (projects[change.project]) {
-        this._duplicateProjectChanges = true;
-      }
-      projects[change.project] = true;
     });
+    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
     this._changesCount = changes.length;
     this._showCherryPickTopic = changes.length > 1;
   }
@@ -185,6 +191,10 @@
     if (this.selectedChangeIds.has(changeId))
       this.selectedChangeIds.delete(changeId);
     else this.selectedChangeIds.add(changeId);
+    const changes = this.changes.filter(change =>
+      this.selectedChangeIds.has(change.id)
+    );
+    this._duplicateProjectChanges = this.containsDuplicateProject(changes);
   }
 
   _computeTopicErrorMessage(duplicateProjectChanges: boolean) {
@@ -238,7 +248,7 @@
     cherryPickType: CherryPickType,
     duplicateProjectChanges: boolean,
     statuses: Statuses,
-    branch?: BranchName
+    branch: BranchName
   ) {
     if (!branch) return true;
     const duplicateProject =
@@ -287,6 +297,9 @@
     let newMessage = commitMessage;
 
     if (changeStatus === 'MERGED') {
+      if (!newMessage.endsWith('\n')) {
+        newMessage += '\n';
+      }
       newMessage += '(cherry picked from commit ' + commitNum.toString() + ')';
     }
     this.message = newMessage;
@@ -384,29 +397,22 @@
     this.$.branchInput.focus();
   }
 
-  _getProjectBranchesSuggestions(
-    input: string
-  ): Promise<AutocompleteSuggestion[]> {
-    if (!this.project) {
-      this.reporting.error(new Error('no project specified'));
-      return Promise.resolve([]);
-    }
+  _getProjectBranchesSuggestions(input: string) {
+    if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
     }
     return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
-      .then((response: BranchInfo[] | undefined) => {
+      .then(response => {
         if (!response) return [];
-        const branches = [];
+        const branches: Array<{name: BranchName}> = [];
         for (const branchInfo of response) {
-          let branch;
-          if (branchInfo.ref.startsWith('refs/heads/')) {
-            branch = branchInfo.ref.substring('refs/heads/'.length);
-          } else {
-            branch = branchInfo.ref;
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
           }
-          branches.push({name: branch});
+          branches.push({name: name as BranchName});
         }
         return branches;
       });
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 536a4ab..3df997f8 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
@@ -46,6 +46,16 @@
     element.project = 'test-project';
   });
 
+  test('with message missing newline', () => {
+    element.changeStatus = 'MERGED';
+    element.commitMessage = 'message';
+    element.commitNum = '123';
+    element.branch = 'master';
+    flush();
+    const expectedMessage = 'message\n(cherry picked from commit 123)';
+    assert.equal(element.message, expectedMessage);
+  });
+
   test('with merged change', () => {
     element.changeStatus = 'MERGED';
     element.commitMessage = 'message\n';
@@ -105,47 +115,52 @@
         current_revision: 'a',
       },
     ];
-    setup(() => {
+    setup(async () => {
       element.updateChanges(changes);
       element._cherryPickType = CHERRY_PICK_TYPES.TOPIC;
-      flush();
+      await flush();
     });
 
-    test('cherry pick topic submit', done => {
+    test('cherry pick topic submit', async () => {
       element.branch = 'master';
+      await flush();
       const executeChangeActionStub = stubRestApi(
           'executeChangeAction').returns(Promise.resolve([]));
       MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').$.confirm);
-      flush(() => {
-        const args = executeChangeActionStub.args[0];
-        assert.equal(args[0], 1);
-        assert.equal(args[1], 'POST');
-        assert.equal(args[2], '/cherrypick');
-        assert.equal(args[4].destination, 'master');
-        assert.isTrue(args[4].allow_conflicts);
-        assert.isTrue(args[4].allow_empty);
-        done();
-      });
+          querySelector('gr-dialog').confirmButton);
+      await flush();
+      const args = executeChangeActionStub.args[0];
+      assert.equal(args[0], 1);
+      assert.equal(args[1], 'POST');
+      assert.equal(args[2], '/cherrypick');
+      assert.equal(args[4].destination, 'master');
+      assert.isTrue(args[4].allow_conflicts);
+      assert.isTrue(args[4].allow_empty);
     });
 
-    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 a change removes it from being cherry picked',
+        async () => {
+          const duplicateChangesStub = sinon.stub(element,
+              'containsDuplicateProject');
+          element.branch = 'master';
+          await flush();
+          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').confirmButton);
+          await flush();
+          assert.equal(executeChangeActionStub.callCount, 1);
+          assert.isTrue(duplicateChangesStub.called);
+        });
 
-    test('deselecting all change shows error message', () => {
+    test('deselecting all change shows error message', async () => {
       element.branch = 'master';
+      await flush();
       const executeChangeActionStub = stubRestApi(
           'executeChangeAction').returns(Promise.resolve([]));
       const checkboxes = element.shadowRoot.querySelectorAll(
@@ -154,8 +169,8 @@
       MockInteractions.tap(checkboxes[0]);
       MockInteractions.tap(checkboxes[1]);
       MockInteractions.tap(element.shadowRoot.
-          querySelector('gr-dialog').$.confirm);
-      flush();
+          querySelector('gr-dialog').confirmButton);
+      await flush();
       assert.equal(executeChangeActionStub.callCount, 0);
       assert.equal(element.shadowRoot.querySelector('.error-message').innerText
           , 'No change selected');
@@ -168,18 +183,16 @@
       ), 'error');
     });
 
-    test('submit button is blocked while cherry picks is running', done => {
-      const confirmButton = element.shadowRoot.querySelector('gr-dialog').$
-          .confirm;
+    test('submit button is blocked while cherry picks is running', async () => {
+      const confirmButton = element.shadowRoot.querySelector('gr-dialog')
+          .confirmButton;
       assert.isTrue(confirmButton.hasAttribute('disabled'));
       element.branch = 'b';
-      flush();
+      await flush();
       assert.isFalse(confirmButton.hasAttribute('disabled'));
       element.updateStatus(changes[0], {status: 'RUNNING'});
-      flush(() => {
-        assert.isTrue(confirmButton.hasAttribute('disabled'));
-        done();
-      });
+      await flush();
+      assert.isTrue(confirmButton.hasAttribute('disabled'));
     });
   });
 
@@ -189,12 +202,11 @@
     assert.isTrue(focusStub.called);
   });
 
-  test('_getProjectBranchesSuggestions non-empty', done => {
-    element._getProjectBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
+  test('_getProjectBranchesSuggestions non-empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions(
+        'test-branch');
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
   });
 });
 
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 eb4053a..b3bbc8a 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
@@ -19,16 +19,24 @@
 import '../../shared/gr-dialog/gr-dialog';
 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 {RepoName, BranchName} from '../../../types/common';
-import {AutocompleteSuggestion} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {BranchName, RepoName} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {GrTypedAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const SUGGESTIONS_LIMIT = 15;
 
+// This is used to make sure 'branch'
+// can be typed as BranchName.
+export interface GrConfirmMoveDialog {
+  $: {
+    branchInput: GrTypedAutocomplete<BranchName>;
+  };
+}
+
 @customElement('gr-confirm-move-dialog')
-export class GrConfirmMoveDialog extends KeyboardShortcutMixin(PolymerElement) {
+export class GrConfirmMoveDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -46,21 +54,38 @@
    */
 
   @property({type: String})
-  branch?: BranchName;
+  branch = '' as BranchName;
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   @property({type: String})
   project?: RepoName;
 
   @property({type: Object})
-  _query: (input: string) => Promise<AutocompleteSuggestion[]>;
+  _query?: (input: string) => Promise<{name: BranchName}[]>;
 
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter': '_handleConfirmTap',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, e =>
+        this._handleConfirmTap(e)
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, e =>
+        this._handleConfirmTap(e)
+      )
+    );
   }
 
   private readonly restApiService = appContext.restApiService;
@@ -92,9 +117,7 @@
     );
   }
 
-  _getProjectBranchesSuggestions(
-    input: string
-  ): Promise<AutocompleteSuggestion[]> {
+  _getProjectBranchesSuggestions(input: string) {
     if (!this.project) return Promise.reject(new Error('Missing project'));
     if (input.startsWith('refs/heads/')) {
       input = input.substring('refs/heads/'.length);
@@ -102,21 +125,15 @@
     return this.restApiService
       .getRepoBranches(input, this.project, SUGGESTIONS_LIMIT)
       .then(response => {
-        const branches: AutocompleteSuggestion[] = [];
-        let branch;
-        if (response) {
-          response.forEach(value => {
-            if (value.ref.startsWith('refs/heads/')) {
-              branch = value.ref.substring('refs/heads/'.length);
-            } else {
-              branch = value.ref;
-            }
-            branches.push({
-              name: branch,
-            });
-          });
+        if (!response) return [];
+        const branches: Array<{name: BranchName}> = [];
+        for (const branchInfo of response) {
+          let name: string = branchInfo.ref;
+          if (name.startsWith('refs/heads/')) {
+            name = name.substring('refs/heads/'.length);
+          }
+          branches.push({name: name as BranchName});
         }
-
         return branches;
       });
   }
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
deleted file mode 100644
index 36a2ad3..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.js
+++ /dev/null
@@ -1,75 +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 './gr-confirm-move-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
-
-suite('gr-confirm-move-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    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';
-  });
-
-  test('with updated commit message', () => {
-    element.branch = 'master';
-    const myNewMessage = 'updated commit message';
-    element.message = myNewMessage;
-    flush();
-    assert.equal(element.message, myNewMessage);
-  });
-
-  test('_getProjectBranchesSuggestions empty', done => {
-    element._getProjectBranchesSuggestions('nonexistent').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
-  });
-
-  test('_getProjectBranchesSuggestions non-empty', done => {
-    element._getProjectBranchesSuggestions('test-branch').then(branches => {
-      assert.equal(branches.length, 1);
-      assert.equal(branches[0].name, 'test-branch');
-      done();
-    });
-  });
-
-  test('_getProjectBranchesSuggestions input empty string', done => {
-    element._getProjectBranchesSuggestions('').then(branches => {
-      assert.equal(branches.length, 0);
-      done();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
new file mode 100644
index 0000000..ea5d320
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.ts
@@ -0,0 +1,74 @@
+/**
+ * @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';
+import './gr-confirm-move-dialog';
+import {GrConfirmMoveDialog} from './gr-confirm-move-dialog';
+import {stubRestApi} from '../../../test/test-utils';
+import {BranchName, GitRef, RepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-confirm-move-dialog');
+
+suite('gr-confirm-move-dialog tests', () => {
+  let element: GrConfirmMoveDialog;
+
+  setup(() => {
+    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.project = 'test-repo' as RepoName;
+  });
+
+  test('with updated commit message', () => {
+    element.branch = 'master' as BranchName;
+    const myNewMessage = 'updated commit message';
+    element.message = myNewMessage;
+    flush();
+    assert.equal(element.message, myNewMessage);
+  });
+
+  test('_getProjectBranchesSuggestions empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions(
+      'nonexistent'
+    );
+    assert.equal(branches.length, 0);
+  });
+
+  test('_getProjectBranchesSuggestions non-empty', async () => {
+    const branches = await element._getProjectBranchesSuggestions(
+      'test-branch'
+    );
+    assert.equal(branches.length, 1);
+    assert.equal(branches[0].name, 'test-branch');
+  });
+
+  test('_getProjectBranchesSuggestions input empty string', async () => {
+    const branches = await element._getProjectBranchesSuggestions('');
+    assert.equal(branches.length, 0);
+  });
+});
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 4e6c963..77b7717 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
@@ -27,8 +27,9 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
+import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 
-interface RebaseChange {
+export interface RebaseChange {
   name: string;
   value: NumericChangeId;
 }
@@ -39,10 +40,15 @@
 
 export interface GrConfirmRebaseDialog {
   $: {
+    confirmDialog: GrDialog;
     parentInput: GrAutocomplete;
+    parentUpToDateMsg: HTMLDivElement;
+    rebaseOnParent: HTMLDivElement;
     rebaseOnParentInput: HTMLInputElement;
     rebaseOnOtherInput: HTMLInputElement;
+    rebaseOnTip: HTMLDivElement;
     rebaseOnTipInput: HTMLInputElement;
+    tipUpToDateMsg: HTMLDivElement;
   };
 }
 
@@ -77,10 +83,10 @@
   rebaseOnCurrent?: boolean;
 
   @property({type: String})
-  _text?: string;
+  _text = '';
 
   @property({type: Object})
-  _query?: AutocompleteQuery;
+  _query: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: Array})
   _recentChanges?: RebaseChange[];
@@ -146,15 +152,15 @@
       );
   }
 
-  _displayParentOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+  _displayParentOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
     return hasParent && rebaseOnCurrent;
   }
 
-  _displayParentUpToDateMsg(rebaseOnCurrent: boolean, hasParent: boolean) {
+  _displayParentUpToDateMsg(rebaseOnCurrent?: boolean, hasParent?: boolean) {
     return hasParent && !rebaseOnCurrent;
   }
 
-  _displayTipOption(rebaseOnCurrent: boolean, hasParent: boolean) {
+  _displayTipOption(rebaseOnCurrent?: boolean, hasParent?: boolean) {
     return !(!rebaseOnCurrent && !hasParent);
   }
 
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 6def4a5..1052201 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
@@ -57,12 +57,7 @@
         class="rebaseOption"
         hidden$="[[!_displayParentOption(rebaseOnCurrent, hasParent)]]"
       >
-        <input
-          id="rebaseOnParentInput"
-          name="rebaseOptions"
-          type="radio"
-          on-click="_handleRebaseOnParent"
-        />
+        <input id="rebaseOnParentInput" name="rebaseOptions" type="radio" />
         <label id="rebaseOnParentLabel" for="rebaseOnParentInput">
           Rebase on parent change
         </label>
@@ -84,7 +79,6 @@
           name="rebaseOptions"
           type="radio"
           disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
-          on-click="_handleRebaseOnTip"
         />
         <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
           Rebase on top of the [[branch]] branch<span hidden$="[[!hasParent]]">
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.ts
similarity index 67%
rename from polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.ts
index 0faf604..74c1b3c 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.ts
@@ -15,14 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-rebase-dialog.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-confirm-rebase-dialog';
+import {GrConfirmRebaseDialog, RebaseChange} from './gr-confirm-rebase-dialog';
+import {stubRestApi} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {NumericChangeId} from '../../../types/common';
+import {createChangeViewChange} from '../../../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-confirm-rebase-dialog');
 
 suite('gr-confirm-rebase-dialog tests', () => {
-  let element;
+  let element: GrConfirmRebaseDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -75,16 +79,20 @@
   test('input cleared on cancel or submit', () => {
     element._text = '123';
     element.$.confirmDialog.dispatchEvent(
-        new CustomEvent('confirm', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('confirm', {
+        composed: true,
+        bubbles: true,
+      })
+    );
     assert.equal(element._text, '');
 
     element._text = '123';
     element.$.confirmDialog.dispatchEvent(
-        new CustomEvent('cancel', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('cancel', {
+        composed: true,
+        bubbles: true,
+      })
+    );
     assert.equal(element._text, '');
   });
 
@@ -102,55 +110,59 @@
   });
 
   suite('parent suggestions', () => {
-    let recentChanges;
-    let getChangesStub;
+    let recentChanges: RebaseChange[];
+    let getChangesStub: sinon.SinonStub;
     setup(() => {
       recentChanges = [
         {
           name: '123: my first awesome change',
-          value: 123,
+          value: 123 as NumericChangeId,
         },
         {
           name: '124: my second awesome change',
-          value: 124,
+          value: 124 as NumericChangeId,
         },
         {
           name: '245: my third awesome change',
-          value: 245,
+          value: 245 as NumericChangeId,
         },
       ];
 
-      getChangesStub = stubRestApi('getChanges').returns(Promise.resolve(
-          [
-            {
-              _number: 123,
-              subject: 'my first awesome change',
-            },
-            {
-              _number: 124,
-              subject: 'my second awesome change',
-            },
-            {
-              _number: 245,
-              subject: 'my third awesome change',
-            },
-          ]
-      ));
+      getChangesStub = stubRestApi('getChanges').returns(
+        Promise.resolve([
+          {
+            ...createChangeViewChange(),
+            _number: 123 as NumericChangeId,
+            subject: 'my first awesome change',
+          },
+          {
+            ...createChangeViewChange(),
+            _number: 124 as NumericChangeId,
+            subject: 'my second awesome change',
+          },
+          {
+            ...createChangeViewChange(),
+            _number: 245 as NumericChangeId,
+            subject: 'my third awesome change',
+          },
+        ])
+      );
     });
 
     test('_getRecentChanges', () => {
-      sinon.spy(element, '_getRecentChanges');
-      return element._getRecentChanges()
-          .then(() => {
-            assert.deepEqual(element._recentChanges, recentChanges);
-            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(getChangesStub.callCount, 1);
-          });
+      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
+      return element
+        ._getRecentChanges()
+        .then(() => {
+          assert.deepEqual(element._recentChanges, recentChanges);
+          assert.equal(getChangesStub.callCount, 1);
+          // When called a second time, should not re-request recent changes.
+          element._getRecentChanges();
+        })
+        .then(() => {
+          assert.equal(recentChangesSpy.callCount, 2);
+          assert.equal(getChangesStub.callCount, 1);
+        });
     });
 
     test('_filterChanges', () => {
@@ -159,25 +171,25 @@
       assert.equal(element._filterChanges('awesome', recentChanges).length, 3);
       assert.equal(element._filterChanges('third', recentChanges).length, 1);
 
-      element.changeNumber = 123;
+      element.changeNumber = 123 as NumericChangeId;
       assert.equal(element._filterChanges('123', recentChanges).length, 0);
       assert.equal(element._filterChanges('124', recentChanges).length, 1);
       assert.equal(element._filterChanges('awesome', recentChanges).length, 2);
     });
 
     test('input text change triggers function', () => {
-      sinon.spy(element, '_getRecentChanges');
+      const recentChangesSpy = sinon.spy(element, '_getRecentChanges');
       element.$.parentInput.noDebounce = true;
       MockInteractions.pressAndReleaseKeyOn(
-          element.$.parentInput.$.input,
-          13,
-          null,
-          'enter');
+        element.$.parentInput.$.input,
+        13,
+        null,
+        'enter'
+      );
       element._text = '1';
-      assert.isTrue(element._getRecentChanges.calledOnce);
+      assert.isTrue(recentChangesSpy.calledOnce);
       element._text = '12';
-      assert.isTrue(element._getRecentChanges.calledTwice);
+      assert.isTrue(recentChangesSpy.calledTwice);
     });
   });
 });
-
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 450551b..b971039 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
@@ -59,7 +59,7 @@
   /* The revert message updated by the user
       The default value is set by the dialog */
   @property({type: String})
-  _message?: string;
+  _message = '';
 
   @property({type: Number})
   _revertType = RevertType.REVERT_SINGLE_CHANGE;
@@ -198,7 +198,7 @@
     this._message = this._revertMessages[RevertType.REVERT_SUBMISSION];
   }
 
-  _handleConfirmTap(e: MouseEvent) {
+  _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     if (this._message === this._originalRevertMessages[this._revertType]) {
@@ -218,7 +218,7 @@
     );
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
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 17647ad..b2acff2 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
@@ -32,10 +32,10 @@
     }
     .revertSubmissionLayout {
       display: flex;
+      align-items: center;
     }
     .label {
       margin-left: var(--spacing-m);
-      margin-bottom: var(--spacing-m);
     }
     iron-autogrow-textarea {
       font-family: var(--monospace-font-family);
@@ -47,6 +47,9 @@
       color: var(--error-text-color);
       margin-bottom: var(--spacing-m);
     }
+    label[for='messageInput'] {
+      margin-top: var(--spacing-m);
+    }
   </style>
   <gr-dialog
     confirm-label="Revert"
@@ -82,8 +85,8 @@
           <label for="revertSubmission" class="label revertSubmission">
             Revert entire submission ([[_changesCount]] Changes)
           </label>
-        </div></template
-      >
+        </div>
+      </template>
       <gr-endpoint-decorator name="confirm-revert-change">
         <label for="messageInput"> Revert Commit Message </label>
         <iron-autogrow-textarea
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
deleted file mode 100644
index 7c84043..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.js
+++ /dev/null
@@ -1,83 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-revert-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
-
-suite('gr-confirm-revert-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('no match', () => {
-    assert.isNotOk(element._message);
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSingleChangeMessage({},
-        'not a commitHash in sight', undefined);
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'one line commit\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "one line commit"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "many lines"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "much lines"\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element._message);
-    element._populateRevertSingleChangeMessage({},
-        'Revert "one line commit"\n\nChange-Id: abcdefg\n',
-        'abcd123');
-    const expected = 'Revert "Revert "one line commit""\n\n' +
-        'This reverts commit abcd123.\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element._message, expected);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
new file mode 100644
index 0000000..38429b1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -0,0 +1,100 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import {createChange} from '../../../test/test-data-generators';
+import {CommitId} from '../../../types/common';
+import './gr-confirm-revert-dialog';
+import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
+
+const basicFixture = fixtureFromElement('gr-confirm-revert-dialog');
+
+suite('gr-confirm-revert-dialog tests', () => {
+  let element: GrConfirmRevertDialog;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('no match', () => {
+    assert.isNotOk(element._message);
+    const alertStub = sinon.stub();
+    element.addEventListener('show-alert', alertStub);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'not a commitHash in sight',
+      undefined
+    );
+    assert.isTrue(alertStub.calledOnce);
+  });
+
+  test('single line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'one line commit\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "one line commit"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('multi line', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "many lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('issue above change id', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "much lines"\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+
+  test('revert a revert', () => {
+    assert.isNotOk(element._message);
+    element._populateRevertSingleChangeMessage(
+      createChange(),
+      'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+      'abcd123' as CommitId
+    );
+    const expected =
+      'Revert "Revert "one line commit""\n\n' +
+      'This reverts commit abcd123.\n\n' +
+      'Reason for revert: <INSERT REASONING HERE>\n';
+    assert.equal(element._message, expected);
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-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
deleted file mode 100644
index bd268fd..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../shared/gr-dialog/gr-dialog';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-revert-submission-dialog_html';
-import {customElement, property} from '@polymer/decorators';
-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;
-
-@customElement('gr-confirm-revert-submission-dialog')
-export class GrConfirmRevertSubmissionDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  /**
-   * Fired when the confirm button is pressed.
-   *
-   * @event confirm
-   */
-
-  /**
-   * Fired when the cancel button is pressed.
-   *
-   * @event cancel
-   */
-
-  @property({type: String})
-  message?: string;
-
-  @property({type: String})
-  commitMessage?: string;
-
-  private readonly jsAPI = appContext.jsApiService;
-
-  _getTrimmedChangeSubject(subject: string) {
-    if (!subject) return '';
-    if (subject.length < CHANGE_SUBJECT_LIMIT) return subject;
-    return subject.substring(0, CHANGE_SUBJECT_LIMIT) + '...';
-  }
-
-  _modifyRevertSubmissionMsg(change?: ChangeInfo) {
-    if (!change || !this.message || !this.commitMessage) {
-      return this.message;
-    }
-    return this.jsAPI.modifyRevertSubmissionMsg(
-      change,
-      this.message,
-      this.commitMessage
-    );
-  }
-
-  _populateRevertSubmissionMessage(
-    change?: ChangeInfo,
-    changes?: ChangeInfo[]
-  ) {
-    if (change === undefined) {
-      return;
-    }
-    // Follow the same convention of the revert
-    const commitHash = change.current_revision;
-    if (!commitHash) {
-      fireAlert(this, ERR_COMMIT_NOT_FOUND);
-      return;
-    }
-    const revertTitle = `Revert submission ${change.submission_id}`;
-    this.message =
-      revertTitle + '\n\n' + 'Reason for revert: <INSERT REASONING HERE>\n';
-    changes = changes || [];
-    if (changes.length) {
-      this.message += 'Reverted Changes:\n';
-      changes.forEach(change => {
-        this.message +=
-          `${change.change_id.substring(0, 10)}: ` +
-          `${this._getTrimmedChangeSubject(change.subject)}\n`;
-      });
-    }
-    this.message = this._modifyRevertSubmissionMsg(change);
-  }
-
-  _handleConfirmTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('confirm', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-
-  _handleCancelTap(e: Event) {
-    e.preventDefault();
-    e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('cancel', {
-        composed: true,
-        bubbles: false,
-      })
-    );
-  }
-}
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-confirm-revert-submission-dialog': GrConfirmRevertSubmissionDialog;
-  }
-}
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
deleted file mode 100644
index 48bd796..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_html.ts
+++ /dev/null
@@ -1,58 +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`
-  <!-- TODO(taoalpha): move all shared styles to a style module. -->
-  <style include="shared-styles">
-    :host {
-      display: block;
-    }
-    :host([disabled]) {
-      opacity: 0.5;
-      pointer-events: none;
-    }
-    label {
-      cursor: pointer;
-      display: block;
-      width: 100%;
-    }
-    iron-autogrow-textarea {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      width: 73ch; /* Add a char to account for the border. */
-    }
-  </style>
-  <gr-dialog
-    confirm-label="Revert Submission"
-    on-confirm="_handleConfirmTap"
-    on-cancel="_handleCancelTap"
-  >
-    <div class="header" slot="header">Revert Submission</div>
-    <div class="main" slot="main">
-      <label for="messageInput"> Revert Commit Message </label>
-      <iron-autogrow-textarea
-        id="messageInput"
-        class="message"
-        autocomplete="on"
-        max-rows="15"
-        bind-value="{{message}}"
-      ></iron-autogrow-textarea>
-    </div>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
deleted file mode 100644
index 1ed799f..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-submission-dialog/gr-confirm-revert-submission-dialog_test.js
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-confirm-revert-submission-dialog.js';
-
-const basicFixture = fixtureFromElement('gr-confirm-revert-submission-dialog');
-
-suite('gr-confirm-revert-submission-dialog tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('no match', () => {
-    assert.isNotOk(element.message);
-    const alertStub = sinon.stub();
-    element.addEventListener('show-alert', alertStub);
-    element._populateRevertSubmissionMessage(
-        'not a commitHash in sight', {}
-    );
-    assert.isTrue(alertStub.calledOnce);
-  });
-
-  test('single line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('multi line', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('issue above change id', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-        'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-
-  test('revert a revert', () => {
-    assert.isNotOk(element.message);
-    element._populateRevertSubmissionMessage(
-        {current_revision: 'abcd123', submission_id: '111'});
-    const expected = 'Revert submission 111\n\n' +
-      'Reason for revert: <INSERT REASONING HERE>\n';
-    assert.equal(element.message, expected);
-  });
-});
-
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 33f7304..9d371d3 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
@@ -19,26 +19,20 @@
 import '../../shared/gr-dialog/gr-dialog';
 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 {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-confirm-submit-dialog_html';
-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';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query} from 'lit/decorators';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
-export interface GrConfirmSubmitDialog {
-  $: {
-    dialog: GrDialog;
-  };
-}
 @customElement('gr-confirm-submit-dialog')
-export class GrConfirmSubmitDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrConfirmSubmitDialog extends LitElement {
+  @query('#dialog')
+  dialog?: GrDialog;
 
   /**
    * Fired when the confirm button is pressed.
@@ -64,12 +58,120 @@
   @property({type: Boolean})
   _initialised = false;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        #dialog {
+          min-width: 40em;
+        }
+        p {
+          margin-bottom: var(--spacing-l);
+        }
+        .warningBeforeSubmit {
+          color: var(--warning-foreground);
+          vertical-align: top;
+          margin-right: var(--spacing-s);
+        }
+        @media screen and (max-width: 50em) {
+          #dialog {
+            min-width: inherit;
+            width: 100%;
+          }
+        }
+      `,
+    ];
+  }
+
+  private renderPrivate() {
+    if (!this.change?.is_private) return '';
+    return html`
+      <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>
+    `;
+  }
+
+  private renderUnresolvedCommentCount() {
+    if (!this.change?.unresolved_comment_count) return '';
+    return html`
+      <p>
+        <iron-icon
+          icon="gr-icons:warning"
+          class="warningBeforeSubmit"
+        ></iron-icon>
+        ${this._computeUnresolvedCommentsWarning(this.change)}
+      </p>
+      <gr-thread-list
+        id="commentList"
+        .threads="${this._computeUnresolvedThreads(this.commentThreads)}"
+        .change="${this.change}"
+        .changeNum="${this.change?._number}"
+        logged-in
+        hide-dropdown
+      >
+      </gr-thread-list>
+    `;
+  }
+
+  private renderChangeEdit() {
+    if (!this._computeHasChangeEdit(this.change)) return '';
+    return html`
+      <iron-icon
+        icon="gr-icons:warning"
+        class="warningBeforeSubmit"
+      ></iron-icon>
+      Your unpublished edit will not be submitted. Did you forget to click
+      <b>PUBLISH</b>
+    `;
+  }
+
+  private renderInitialised() {
+    if (!this._initialised) return '';
+    return html`
+      <div class="header" slot="header">${this.action?.label}</div>
+      <div class="main" slot="main">
+        <gr-endpoint-decorator name="confirm-submit-change">
+          <p>Ready to submit “<strong>${this.change?.subject}</strong>”?</p>
+          ${this.renderPrivate()} ${this.renderUnresolvedCommentCount()}
+          ${this.renderChangeEdit()}
+          <gr-endpoint-param
+            name="change"
+            .value="${this.change}"
+          ></gr-endpoint-param>
+          <gr-endpoint-param
+            name="action"
+            .value="${this.action}"
+          ></gr-endpoint-param>
+        </gr-endpoint-decorator>
+      </div>
+    `;
+  }
+
+  override render() {
+    return html` <gr-dialog
+      id="dialog"
+      confirm-label="Continue"
+      confirm-on-enter=""
+      @cancel=${this._handleCancelTap}
+      @confirm=${this._handleConfirmTap}
+    >
+      ${this.renderInitialised()}
+    </gr-dialog>`;
+  }
+
   init() {
     this._initialised = true;
   }
 
   resetFocus() {
-    this.$.dialog.resetFocus();
+    this.dialog?.resetFocus();
   }
 
   _computeHasChangeEdit(change?: ChangeInfo) {
@@ -85,19 +187,20 @@
     return commentThreads.filter(thread => isUnresolved(thread));
   }
 
-  _computeUnresolvedCommentsWarning(change: ChangeInfo) {
+  _computeUnresolvedCommentsWarning(change?: ChangeInfo) {
+    if (!change) return '';
     const unresolvedCount = change.unresolved_comment_count;
     if (!unresolvedCount) throw new Error('unresolved comments undefined or 0');
     return `Heads Up! ${pluralize(unresolvedCount, 'unresolved comment')}.`;
   }
 
-  _handleConfirmTap(e: MouseEvent) {
+  _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('confirm', {bubbles: false}));
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(new CustomEvent('cancel', {bubbles: false}));
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
deleted file mode 100644
index e4662da..0000000
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_html.ts
+++ /dev/null
@@ -1,99 +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">
-    #dialog {
-      min-width: 40em;
-    }
-    p {
-      margin-bottom: var(--spacing-l);
-    }
-    .warningBeforeSubmit {
-      color: var(--warning-foreground);
-      vertical-align: top;
-      margin-right: var(--spacing-s);
-    }
-    @media screen and (max-width: 50em) {
-      #dialog {
-        min-width: inherit;
-        width: 100%;
-      }
-    }
-  </style>
-  <gr-dialog
-    id="dialog"
-    confirm-label="Continue"
-    confirm-on-enter=""
-    on-cancel="_handleCancelTap"
-    on-confirm="_handleConfirmTap"
-  >
-    <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:warning"
-              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>
-    </template>
-  </gr-dialog>
-`;
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
index e9f3019..e1823b1 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-submit-dialog/gr-confirm-submit-dialog_test.ts
@@ -31,14 +31,14 @@
     element._initialised = true;
   });
 
-  test('display', () => {
+  test('display', async () => {
     element.action = {label: 'my-label'};
     element.change = {
       ...createChange(),
       subject: 'my-subject',
       revisions: {},
     };
-    flush();
+    await flush();
     const header = queryAndAssert(element, '.header');
     assert.equal(header.textContent!.trim(), 'my-label');
 
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 fe1b618..92f4a87 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
@@ -14,18 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-font-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-download-commands/gr-download-commands';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-dialog_html';
 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';
+import {
+  ChangeInfo,
+  DownloadInfo,
+  PatchSetNum,
+  RevisionInfo,
+} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {GrOverlayStops} from '../../shared/gr-overlay/gr-overlay';
+import {fireAlert, fireEvent} from '../../../utils/event-util';
+import {addShortcut} from '../../../utils/dom-util';
 
 export interface GrDownloadDialog {
   $: {
@@ -51,7 +58,7 @@
   change: ChangeInfo | undefined;
 
   @property({type: Object})
-  config?: ServerInfo;
+  config?: DownloadInfo;
 
   @property({type: String})
   patchNum: PatchSetNum | undefined;
@@ -59,6 +66,24 @@
   @property({type: String})
   _selectedScheme?: string;
 
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    for (const key of ['1', '2', '3', '4', '5']) {
+      this.cleanups.push(
+        addShortcut(this, {key}, e => this._handleNumberKey(e))
+      );
+    }
+  }
+
   @computed('change', 'patchNum')
   get _schemes() {
     // Polymer 2: check for undefined
@@ -78,13 +103,26 @@
     return [];
   }
 
-  /** @override */
-  ready() {
+  _handleNumberKey(e: KeyboardEvent) {
+    const index = Number(e.key) - 1;
+    const commands = this._computeDownloadCommands(
+      this.change,
+      this.patchNum,
+      this._selectedScheme
+    );
+    if (index > commands.length) return;
+    navigator.clipboard.writeText(commands[index].command).then(() => {
+      fireAlert(this, `${commands[index].title} command copied to clipboard`);
+      fireEvent(this, 'close');
+    });
+  }
+
+  override ready() {
     super.ready();
     this._ensureAttribute('role', 'dialog');
   }
 
-  focus() {
+  override focus() {
     if (this._schemes.length) {
       this.$.downloadCommands.focusOnCopy();
     } else {
@@ -209,12 +247,7 @@
   _handleCloseTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    this.dispatchEvent(
-      new CustomEvent('close', {
-        composed: true,
-        bubbles: false,
-      })
-    );
+    fireEvent(this, 'close');
   }
 
   @observe('_schemes')
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
index 9dc9832..097fb0e 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_html.ts
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
@@ -74,6 +77,7 @@
       commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
       schemes="[[_schemes]]"
       selected-scheme="{{_selectedScheme}}"
+      show-keyboard-shortcut-tooltips
     ></gr-download-commands>
   </section>
   <section class="flexContainer">
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index 52ec8af..f61bb68 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -20,9 +20,9 @@
 import {
   createChange,
   createCommit,
+  createDownloadInfo,
   createRevision,
   createRevisions,
-  createServerInfo,
 } from '../../../test/test-data-generators';
 import {
   CommitId,
@@ -31,6 +31,7 @@
   RepoName,
 } from '../../../types/common';
 import {GrDownloadDialog} from './gr-download-dialog';
+import {mockPromise} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-download-dialog');
 
@@ -116,7 +117,7 @@
   setup(() => {
     element = basicFixture.instantiate();
     element.patchNum = 1 as PatchSetNum;
-    element.config = createServerInfo();
+    element.config = createDownloadInfo();
     flush();
   });
 
@@ -168,14 +169,16 @@
       );
     });
 
-    test('close event', done => {
+    test('close event', async () => {
+      const closeCalled = mockPromise();
       element.addEventListener('close', () => {
-        done();
+        closeCalled.resolve();
       });
       const closeButton = element.shadowRoot!.querySelector(
         '.closeButtonContainer gr-button'
       );
       tap(closeButton!);
+      await closeCalled;
     });
   });
 
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 e94f67e..ae3eee5 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
@@ -24,7 +24,6 @@
 import '../gr-commit-info/gr-commit-info';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-file-list-header_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {computeLatestPatchNum, PatchSet} from '../../../utils/patch-set-util';
@@ -42,11 +41,14 @@
 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 {ChangeStatus, DiffViewMode} from '../../../constants/constants';
+import {DiffViewMode} from '../../../constants/constants';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {fireEvent} from '../../../utils/event-util';
-
-const MERGED_STATUS = 'MERGED';
+import {
+  Shortcut,
+  ShortcutSection,
+} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -63,7 +65,7 @@
 }
 
 @customElement('gr-file-list-header')
-export class GrFileListHeader extends KeyboardShortcutMixin(PolymerElement) {
+export class GrFileListHeader extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -81,10 +83,6 @@
    */
 
   /**
-   * @event open-included-in-dialog
-   */
-
-  /**
    * @event open-download-dialog
    */
 
@@ -124,9 +122,6 @@
   @property({type: Object})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Boolean})
-  diffPrefsDisabled?: boolean;
-
   @property({type: String, notify: true})
   diffViewMode?: DiffViewMode;
 
@@ -147,6 +142,8 @@
   @property({type: Object})
   revisionInfo?: RevisionInfo;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   setDiffViewMode(mode: DiffViewMode) {
     this.$.modeSelect.setMode(mode);
   }
@@ -171,11 +168,8 @@
     return classes.join(' ');
   }
 
-  _computePrefsButtonHidden(
-    prefs: DiffPreferencesInfo,
-    diffPrefsDisabled: boolean
-  ) {
-    return diffPrefsDisabled || !prefs;
+  _computePrefsButtonHidden(prefs: DiffPreferencesInfo, loggedIn: boolean) {
+    return !loggedIn || !prefs;
   }
 
   _fileListActionsVisible(
@@ -185,13 +179,6 @@
     return shownFileCount <= maxFilesForBulkActions;
   }
 
-  _showAddPatchsetDescription(
-    patchsetDescription: string,
-    change?: ChangeInfo
-  ) {
-    return !patchsetDescription && change?.status === ChangeStatus.NEW;
-  }
-
   _handlePatchChange(e: CustomEvent) {
     const {basePatchNum, patchNum} = e.detail;
     if (
@@ -208,11 +195,6 @@
     fireEvent(this, 'open-diff-prefs');
   }
 
-  _handleIncludedInTap(e: Event) {
-    e.preventDefault();
-    fireEvent(this, 'open-included-in-dialog');
-  }
-
   _handleDownloadTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
@@ -233,7 +215,7 @@
     return 'patchInfoOldPatchSet';
   }
 
-  _hideIncludedIn(change?: ChangeInfo) {
-    return change?.status === MERGED_STATUS ? '' : 'hide';
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
   }
 }
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 8ebb029..73d0819 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
@@ -21,9 +21,6 @@
     .prefsButton {
       float: right;
     }
-    .collapseToggleButton {
-      text-decoration: none;
-    }
     .patchInfoOldPatchSet.patchInfo-header {
       background-color: var(--emphasis-color);
     }
@@ -61,11 +58,9 @@
       display: flex;
     }
     .downloadContainer,
-    .uploadContainer,
-    .includedInContainer {
+    .uploadContainer {
       margin-right: 16px;
     }
-    .includedInContainer.hide,
     .uploadContainer.hide {
       display: none;
     }
@@ -98,9 +93,7 @@
     }
     .fileViewActions gr-button {
       margin: 0;
-      --gr-button: {
-        padding: 2px 4px;
-      }
+      --gr-button-padding: 2px 4px;
     }
     .editMode .hideOnEdit {
       display: none;
@@ -172,41 +165,63 @@
           <span class="separator"></span>
         </span>
       </template>
+      <div class="fileViewActions">
+        <span class="fileViewActionsLabel">Diff view:</span>
+        <gr-diff-mode-selector
+          id="modeSelect"
+          mode="{{diffViewMode}}"
+          save-on-change="[[loggedIn]]"
+        ></gr-diff-mode-selector>
+        <span
+          id="diffPrefsContainer"
+          class="hideOnEdit"
+          hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
+          hidden=""
+        >
+          <gr-tooltip-content has-tooltip title="Diff preferences">
+            <gr-button
+              link=""
+              class="prefsButton desktop"
+              on-click="_handlePrefsTap"
+              ><iron-icon icon="gr-icons:settings"></iron-icon
+            ></gr-button>
+          </gr-tooltip-content>
+        </span>
+        <span class="separator"></span>
+      </div>
       <span class="downloadContainer desktop">
-        <gr-button
-          link=""
-          class="download"
+        <gr-tooltip-content
+          has-tooltip
           title="[[createTitle(Shortcut.OPEN_DOWNLOAD_DIALOG,
-                ShortcutSection.ACTIONS)]]"
-          on-click="_handleDownloadTap"
-          >Download</gr-button
+                   ShortcutSection.ACTIONS)]]"
         >
-      </span>
-      <span class$="includedInContainer [[_hideIncludedIn(change)]] desktop">
-        <gr-button link="" class="includedIn" on-click="_handleIncludedInTap"
-          >Included In</gr-button
-        >
+          <gr-button link="" class="download" on-click="_handleDownloadTap"
+            >Download</gr-button
+          >
+        </gr-tooltip-content>
       </span>
       <template
         is="dom-if"
         if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]"
       >
-        <gr-button
-          id="expandBtn"
-          link=""
+        <gr-tooltip-content
+          has-tooltip
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-                ShortcutSection.FILE_LIST)]]"
-          on-click="_expandAllDiffs"
-          >Expand All</gr-button
+                  ShortcutSection.FILE_LIST)]]"
         >
-        <gr-button
-          id="collapseBtn"
-          link=""
-          on-click="_collapseAllDiffs"
+          <gr-button id="expandBtn" link="" on-click="_expandAllDiffs"
+            >Expand All</gr-button
+          >
+        </gr-tooltip-content>
+        <gr-tooltip-content
+          has-tooltip
           title="[[createTitle(Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-          ShortcutSection.FILE_LIST)]]"
-          >Collapse All</gr-button
+                  ShortcutSection.FILE_LIST)]]"
         >
+          <gr-button id="collapseBtn" link="" on-click="_collapseAllDiffs"
+            >Collapse All</gr-button
+          >
+        </gr-tooltip-content>
       </template>
       <template
         is="dom-if"
@@ -216,30 +231,6 @@
           Bulk actions disabled because there are too many files.
         </div>
       </template>
-      <div class="fileViewActions">
-        <span class="separator"></span>
-        <span class="fileViewActionsLabel">Diff view:</span>
-        <gr-diff-mode-selector
-          id="modeSelect"
-          mode="{{diffViewMode}}"
-          save-on-change="[[!diffPrefsDisabled]]"
-        ></gr-diff-mode-selector>
-        <span
-          id="diffPrefsContainer"
-          class="hideOnEdit"
-          hidden$="[[_computePrefsButtonHidden(diffPrefs, diffPrefsDisabled)]]"
-          hidden=""
-        >
-          <gr-button
-            link=""
-            has-tooltip=""
-            title="Diff preferences"
-            class="prefsButton desktop"
-            on-click="_handlePrefsTap"
-            ><iron-icon icon="gr-icons:settings"></iron-icon
-          ></gr-button>
-        </span>
-      </div>
     </div>
   </div>
 `;
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 dd70678..479a9a1 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
@@ -34,27 +34,15 @@
     element = basicFixture.instantiate();
   });
 
-  teardown(done => {
-    flush(() => {
-      done();
-    });
+  teardown(async () => {
+    await flush();
   });
 
-  test('Diff preferences hidden when no prefs or diffPrefsDisabled', () => {
-    element.diffPrefsDisabled = true;
-    flush();
+  test('Diff preferences hidden when no prefs', () => {
     assert.isTrue(element.$.diffPrefsContainer.hidden);
 
-    element.diffPrefsDisabled = false;
-    flush();
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefsDisabled = true;
     element.diffPrefs = {font_size: '12'};
-    flush();
-    assert.isTrue(element.$.diffPrefsContainer.hidden);
-
-    element.diffPrefsDisabled = false;
+    element.loggedIn = true;
     flush();
     assert.isFalse(element.$.diffPrefsContainer.hidden);
   });
@@ -77,43 +65,41 @@
     assert.isTrue(element._collapseAllDiffs.called);
   });
 
-  test('show/hide diffs disabled for large amounts of files', done => {
+  test('show/hide diffs disabled for large amounts of files', async () => {
     const computeSpy = sinon.spy(element, '_fileListActionsVisible');
     element._files = [];
     element.changeNum = '42';
     element.basePatchNum = 'PARENT';
     element.patchNum = '2';
     element.shownFileCount = 1;
-    flush(() => {
-      assert.isTrue(computeSpy.lastCall.returnValue);
-      _.times(element._maxFilesForBulkActions + 1, () => {
-        element.shownFileCount = element.shownFileCount + 1;
-      });
-      assert.isFalse(computeSpy.lastCall.returnValue);
-      done();
+    await flush();
+    assert.isTrue(computeSpy.lastCall.returnValue);
+    _.times(element._maxFilesForBulkActions + 1, () => {
+      element.shownFileCount = element.shownFileCount + 1;
     });
+    assert.isFalse(computeSpy.lastCall.returnValue);
   });
 
-  test('fileViewActions are properly hidden', () => {
+  test('fileViewActions are properly hidden', async () => {
     const actions = element.shadowRoot
         .querySelector('.fileViewActions');
     assert.equal(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.SOME;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.ALL;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(actions).display, 'none');
     element.filesExpanded = FilesExpandedState.NONE;
-    flush();
+    await flush();
     assert.equal(getComputedStyle(actions).display, 'none');
   });
 
-  test('expand/collapse buttons are toggled correctly', () => {
+  test('expand/collapse buttons are toggled correctly', async () => {
     // Only the expand button should be visible in the initial state when
     // NO files are expanded.
     element.shownFileCount = 10;
-    flush();
+    await flush();
     const expandBtn = element.shadowRoot.querySelector('#expandBtn');
     const collapseBtn = element.shadowRoot.querySelector('#collapseBtn');
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
@@ -122,19 +108,19 @@
     // Both expand and collapse buttons should be visible when SOME files are
     // expanded.
     element.filesExpanded = FilesExpandedState.SOME;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
 
     // Only the collapse button should be visible when ALL files are expanded.
     element.filesExpanded = FilesExpandedState.ALL;
-    flush();
+    await flush();
     assert.equal(getComputedStyle(expandBtn).display, 'none');
     assert.notEqual(getComputedStyle(collapseBtn).display, 'none');
 
     // Only the expand button should be visible when NO files are expanded.
     element.filesExpanded = FilesExpandedState.NONE;
-    flush();
+    await flush();
     assert.notEqual(getComputedStyle(expandBtn).display, 'none');
     assert.equal(getComputedStyle(collapseBtn).display, 'none');
   });
@@ -172,7 +158,7 @@
 
   suite('editMode behavior', () => {
     setup(() => {
-      element.diffPrefsDisabled = false;
+      element.loggedIn = true;
       element.diffPrefs = {};
     });
 
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 4b5525a..f0d8935 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
@@ -14,9 +14,11 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../diff/gr-diff-cursor/gr-diff-cursor';
 import '../../diff/gr-diff-host/gr-diff-host';
+import '../../diff/gr-comment-api/gr-comment-api';
 import '../../diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog';
 import '../../edit/gr-edit-file-controls/gr-edit-file-controls';
 import '../../shared/gr-button/gr-button';
@@ -33,8 +35,8 @@
 import {asyncForeach, debounce, DelayedTask} from '../../../utils/async-util';
 import {
   KeyboardShortcutMixin,
-  Modifier,
   Shortcut,
+  ShortcutListener,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {FilesExpandedState} from '../gr-file-list-constants';
 import {pluralize} from '../../../utils/string-util';
@@ -47,7 +49,13 @@
   ScrollMode,
   SpecialFilePath,
 } from '../../../constants/constants';
-import {descendedFromClass, toggleClass} from '../../../utils/dom-util';
+import {
+  addGlobalShortcut,
+  addShortcut,
+  descendedFromClass,
+  Key,
+  toggleClass,
+} from '../../../utils/dom-util';
 import {
   addUnmodifiedFiles,
   computeDisplayPath,
@@ -57,14 +65,13 @@
 } from '../../../utils/path-list-util';
 import {customElement, observe, property} from '@polymer/decorators';
 import {
+  BasePatchSetNum,
+  EditPatchSetNum,
   ElementPropertyDeepChange,
   FileInfo,
   FileNameToFileInfoMap,
   NumericChangeId,
   PatchRange,
-  PreferencesInfo,
-  RevisionInfo,
-  UrlEncodedCommentId,
 } from '../../../types/common';
 import {DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffHost} from '../../diff/gr-diff-host/gr-diff-host';
@@ -73,9 +80,14 @@
 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 {CustomKeyboardEvent} from '../../../types/events';
 import {ParsedChangeInfo, PatchSetFile} from '../../../types/types';
 import {Timing} from '../../../constants/reporting';
+import {RevisionInfo} from '../../shared/revision-info/revision-info';
+import {preferences$} from '../../../services/user/user-model';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {Subject} from 'rxjs';
+import {takeUntil} from 'rxjs/operators';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
 
 export const DEFAULT_NUM_FILES_SHOWN = 200;
 
@@ -91,7 +103,6 @@
 export interface GrFileList {
   $: {
     diffPreferencesDialog: GrDiffPreferencesDialog;
-    diffCursor: GrDiffCursor;
   };
 }
 
@@ -164,18 +175,15 @@
  * @property {number} lines_inserted - fallback to 0 if not present in api
  */
 
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = KeyboardShortcutMixin(PolymerElement);
+
 @customElement('gr-file-list')
-export class GrFileList extends KeyboardShortcutMixin(PolymerElement) {
+export class GrFileList extends base {
   static get template() {
     return htmlTemplate;
   }
 
-  /**
-   * Fired when a draft refresh should get triggered
-   *
-   * @event reload-drafts
-   */
-
   @property({type: Object})
   patchRange?: PatchRange;
 
@@ -188,16 +196,10 @@
   @property({type: Object})
   changeComments?: ChangeComments;
 
-  @property({type: Array})
-  revisions?: {[revisionId: string]: RevisionInfo};
-
   @property({type: Number, notify: true})
   selectedIndex = -1;
 
   @property({type: Object})
-  keyEventTarget = document.body;
-
-  @property({type: Object})
   change?: ParsedChangeInfo;
 
   @property({type: String, notify: true, observer: '_updateDiffPreferences'})
@@ -224,12 +226,6 @@
   @property({type: Object, notify: true, observer: '_updateDiffPreferences'})
   diffPrefs?: DiffPreferencesInfo;
 
-  @property({type: Object})
-  _userPrefs?: PreferencesInfo;
-
-  @property({type: Boolean})
-  _showInlineDiffs?: boolean;
-
   @property({type: Number, notify: true})
   numFilesShown: number = DEFAULT_NUM_FILES_SHOWN;
 
@@ -269,9 +265,18 @@
   @property({type: Object, computed: '_computeSizeBarLayout(_shownFiles.*)'})
   _sizeBarLayout: SizeBarLayout = createDefaultSizeBarLayout();
 
-  @property({type: Boolean, computed: '_computeShowSizeBars(_userPrefs)'})
+  @property({type: Boolean})
   _showSizeBars = true;
 
+  // For merge commits vs Auto Merge, an extra file row is shown detailing the
+  // files that were merged without conflict. These files are also passed to any
+  // plugins.
+  @property({type: Array})
+  _cleanlyMergedPaths: string[] = [];
+
+  @property({type: Array})
+  _cleanlyMergedOldPaths: string[] = [];
+
   private _cancelForEachDiff?: () => void;
 
   loadingTask?: DelayedTask;
@@ -311,122 +316,128 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
-  }
+  disconnected$ = new Subject();
 
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.TOGGLE_INLINE_DIFF]: '_handleToggleInlineDiff',
-      [Shortcut.TOGGLE_ALL_INLINE_DIFFS]: '_handleToggleAllInlineDiffs',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.CURSOR_NEXT_FILE]: '_handleCursorNext',
-      [Shortcut.CURSOR_PREV_FILE]: '_handleCursorPrev',
-      [Shortcut.NEXT_LINE]: '_handleCursorNext',
-      [Shortcut.PREV_LINE]: '_handleCursorPrev',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.OPEN_LAST_FILE]: '_handleOpenLastFile',
-      [Shortcut.OPEN_FIRST_FILE]: '_handleOpenFirstFile',
-      [Shortcut.OPEN_FILE]: '_handleOpenFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunk',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunk',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_handleToggleFileReviewed',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.LEFT_PANE, _ => this._handleLeftPane()),
+      listen(Shortcut.RIGHT_PANE, _ => this._handleRightPane()),
+      listen(Shortcut.TOGGLE_INLINE_DIFF, _ => this._handleToggleInlineDiff()),
+      listen(Shortcut.TOGGLE_ALL_INLINE_DIFFS, _ => this._toggleInlineDiffs()),
+      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+        toggleClass(this, 'hideComments')
+      ),
+      listen(Shortcut.CURSOR_NEXT_FILE, e => this._handleCursorNext(e)),
+      listen(Shortcut.CURSOR_PREV_FILE, e => this._handleCursorPrev(e)),
+      // This is already been taken care of by CURSOR_NEXT_FILE above. The two
+      // shortcuts share the same bindings. It depends on whether all files
+      // are expanded whether the cursor moves to the next file or line.
+      listen(Shortcut.NEXT_LINE, _ => {}), // docOnly
+      // This is already been taken care of by CURSOR_PREV_FILE above. The two
+      // shortcuts share the same bindings. It depends on whether all files
+      // are expanded whether the cursor moves to the previous file or line.
+      listen(Shortcut.PREV_LINE, _ => {}), // docOnly
+      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
+      listen(Shortcut.OPEN_LAST_FILE, _ =>
+        this._openSelectedFile(this._files.length - 1)
+      ),
+      listen(Shortcut.OPEN_FIRST_FILE, _ => this._openSelectedFile(0)),
+      listen(Shortcut.OPEN_FILE, _ => this.handleOpenFile()),
+      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
+      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
+      listen(Shortcut.NEXT_COMMENT_THREAD, _ => this._handleNextComment()),
+      listen(Shortcut.PREV_COMMENT_THREAD, _ => this._handlePrevComment()),
+      listen(Shortcut.TOGGLE_FILE_REVIEWED, _ =>
+        this._handleToggleFileReviewed()
+      ),
+      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
+      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
+      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+    ];
   }
 
   private fileCursor = new GrCursorManager();
 
+  private diffCursor = new GrDiffCursor();
+
   constructor() {
     super();
     this.fileCursor.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.fileCursor.cursorTargetClass = 'selected';
     this.fileCursor.focusOnMove = true;
-    this.addEventListener('keydown', e => this._scopedKeydownHandler(e));
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
     getPluginLoader()
       .awaitPluginsLoaded()
       .then(() => {
         this._dynamicHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
           'change-view-file-list-header'
         );
-        this._dynamicContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-content'
-        );
-        this._dynamicPrependedHeaderEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-header-prepend'
-        );
-        this._dynamicPrependedContentEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-content-prepend'
-        );
-        this._dynamicSummaryEndpoints = getPluginEndpoints().getDynamicEndpoints(
-          'change-view-file-list-summary'
-        );
+        this._dynamicContentEndpoints =
+          getPluginEndpoints().getDynamicEndpoints(
+            'change-view-file-list-content'
+          );
+        this._dynamicPrependedHeaderEndpoints =
+          getPluginEndpoints().getDynamicEndpoints(
+            'change-view-file-list-header-prepend'
+          );
+        this._dynamicPrependedContentEndpoints =
+          getPluginEndpoints().getDynamicEndpoints(
+            'change-view-file-list-content-prepend'
+          );
+        this._dynamicSummaryEndpoints =
+          getPluginEndpoints().getDynamicEndpoints(
+            'change-view-file-list-summary'
+          );
 
         if (
           this._dynamicHeaderEndpoints.length !==
           this._dynamicContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicPrependedHeaderEndpoints.length !==
           this._dynamicPrependedContentEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list header and content.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
         if (
           this._dynamicHeaderEndpoints.length !==
           this._dynamicSummaryEndpoints.length
         ) {
-          console.warn(
-            'Different number of dynamic file-list headers and summary.'
-          );
+          this.reporting.error(new Error('dynamic header/content mismatch'));
         }
       });
+    this.cleanups.push(
+      addGlobalShortcut({key: Key.ESC}, _ => this._handleEscKey()),
+      addShortcut(this, {key: Key.ENTER}, _ => this.handleOpenFile(), {
+        shouldSuppress: true,
+      })
+    );
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
+    this.disconnected$.next();
+    this.diffCursor.dispose();
     this.fileCursor.unsetCursor();
     this._cancelDiffs();
     this.loadingTask?.cancel();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
-  /**
-   * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
-   * events must be scoped to a component level (e.g. `enter`) in order to not
-   * override native browser functionality.
-   *
-   * Context: Issue 7277
-   */
-  _scopedKeydownHandler(e: KeyboardEvent) {
-    if (e.keyCode === 13) {
-      // TODO(TS): e is not an instance of CustomKeyboardEvent.
-      // However, to fix it we should fix keyboard-shortcut-mixin first
-      // The keyboard-shortcut-mixin will be updated in a separate change
-      this._handleOpenFile((e as unknown) as CustomKeyboardEvent);
-    }
-  }
-
   reload() {
     if (!this.changeNum || !this.patchRange?.patchNum) {
       return Promise.resolve();
@@ -446,6 +457,7 @@
           this._filesByPath = filesByPath;
         })
     );
+
     promises.push(
       this._getLoggedIn()
         .then(loggedIn => (this._loggedIn = loggedIn))
@@ -468,11 +480,9 @@
       })
     );
 
-    promises.push(
-      this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      })
-    );
+    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
+      this._showSizeBars = !!prefs?.size_bar_in_change_table;
+    });
 
     return Promise.all(promises).then(() => {
       this._loading = false;
@@ -481,6 +491,42 @@
     });
   }
 
+  @observe('_filesByPath')
+  async _updateCleanlyMergedPaths(filesByPath?: FileNameToFileInfoMap) {
+    // When viewing Auto Merge base vs a patchset, add an additional row that
+    // knows how many files were cleanly merged. This requires an additional RPC
+    // for the diffs between target parent and the patch set. The cleanly merged
+    // files are all the files in the target RPC that weren't in the Auto Merge
+    // RPC.
+    if (
+      this.change &&
+      this.changeNum &&
+      this.patchRange?.patchNum &&
+      new RevisionInfo(this.change).isMergeCommit(this.patchRange.patchNum) &&
+      this.patchRange.basePatchNum === 'PARENT' &&
+      this.patchRange.patchNum !== EditPatchSetNum
+    ) {
+      const allFilesByPath = await this.restApiService.getChangeOrEditFiles(
+        this.changeNum,
+        {
+          basePatchNum: -1 as BasePatchSetNum, // -1 is first (target) parent
+          patchNum: this.patchRange.patchNum,
+        }
+      );
+      if (!allFilesByPath || !filesByPath) return;
+      const conflictingPaths = Object.keys(filesByPath);
+      this._cleanlyMergedPaths = Object.keys(allFilesByPath).filter(
+        path => !conflictingPaths.includes(path)
+      );
+      this._cleanlyMergedOldPaths = this._cleanlyMergedPaths
+        .map(path => allFilesByPath[path].old_path)
+        .filter((oldPath): oldPath is string => !!oldPath);
+    } else {
+      this._cleanlyMergedPaths = [];
+      this._cleanlyMergedOldPaths = [];
+    }
+  }
+
   _detectChromiteButler() {
     const hasButler = !!document.getElementById('butler-suggested-owners');
     if (hasButler) {
@@ -535,14 +581,20 @@
   }
 
   private _toggleFileExpanded(file: PatchSetFile) {
-    // Is the path in the list of expanded diffs? IF so remove it, otherwise
+    // Is the path in the list of expanded diffs? If so, remove it, otherwise
     // add it to the list.
-    const pathIndex = this._expandedFiles.findIndex(f => f.path === file.path);
-    if (pathIndex === -1) {
+    const indexInExpanded = this._expandedFiles.findIndex(
+      f => f.path === file.path
+    );
+    if (indexInExpanded === -1) {
       this.push('_expandedFiles', file);
     } else {
-      this.splice('_expandedFiles', pathIndex, 1);
+      this.splice('_expandedFiles', indexInExpanded, 1);
     }
+    const indexInAll = this._files.findIndex(f => f.__path === file.path);
+    this.root!.querySelectorAll(`.${FILE_ROW_CLASS}`)[
+      indexInAll
+    ].scrollIntoView({block: 'nearest'});
   }
 
   _toggleFileExpandedByIndex(index: number) {
@@ -570,8 +622,6 @@
   }
 
   expandAllDiffs() {
-    this._showInlineDiffs = true;
-
     // Find the list of paths that are in the file list, but not in the
     // expanded list.
     const newFiles: PatchSetFile[] = [];
@@ -587,13 +637,12 @@
   }
 
   collapseAllDiffs() {
-    this._showInlineDiffs = false;
     this._expandedFiles = [];
     this.filesExpanded = this._computeExpandedFiles(
       this._expandedFiles.length,
       this._files.length
     );
-    this.$.diffCursor.handleDiffUpdate();
+    this.diffCursor.handleDiffUpdate();
   }
 
   /**
@@ -614,52 +663,21 @@
     return changeComments.computeCommentsString(patchRange, file.__path, file);
   }
 
-  _computeDraftCount(
-    changeComments?: ChangeComments,
-    patchRange?: PatchRange,
-    path?: string
-  ) {
-    if (
-      changeComments === undefined ||
-      patchRange === undefined ||
-      path === undefined
-    ) {
-      return '';
-    }
-    return (
-      changeComments.computeDraftCount({
-        patchNum: patchRange.basePatchNum,
-        path,
-      }) +
-      changeComments.computeDraftCount({
-        patchNum: patchRange.patchNum,
-        path,
-      }) +
-      changeComments.computePortedDraftCount(
-        {
-          patchNum: patchRange.patchNum,
-          basePatchNum: patchRange.basePatchNum,
-        },
-        path
-      )
-    );
-  }
-
   /**
    * Computes a string with the number of drafts.
    */
   _computeDraftsString(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
-    path?: string
+    file?: NormalizedFileInfo
   ) {
-    const draftCount = this._computeDraftCount(
-      changeComments,
+    if (changeComments === undefined) return '';
+    const draftCount = changeComments.computeDraftCountForFile(
       patchRange,
-      path
+      file
     );
-    if (draftCount === '') return draftCount;
-    return pluralize(draftCount, 'draft');
+    if (draftCount === 0) return '';
+    return pluralize(Number(draftCount), 'draft');
   }
 
   /**
@@ -668,12 +686,12 @@
   _computeDraftsStringMobile(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
-    path?: string
+    file?: NormalizedFileInfo
   ) {
-    const draftCount = this._computeDraftCount(
-      changeComments,
+    if (changeComments === undefined) return '';
+    const draftCount = changeComments.computeDraftCountForFile(
       patchRange,
-      path
+      file
     );
     return draftCount === 0 ? '' : `${draftCount}d`;
   }
@@ -684,23 +702,23 @@
   _computeCommentsStringMobile(
     changeComments?: ChangeComments,
     patchRange?: PatchRange,
-    path?: string
+    file?: NormalizedFileInfo
   ) {
     if (
       changeComments === undefined ||
       patchRange === undefined ||
-      path === undefined
+      file === undefined
     ) {
       return '';
     }
     const commentThreadCount =
       changeComments.computeCommentThreadCount({
         patchNum: patchRange.basePatchNum,
-        path,
+        path: file.__path,
       }) +
       changeComments.computeCommentThreadCount({
         patchNum: patchRange.patchNum,
-        path,
+        path: file.__path,
       });
     return commentThreadCount === 0 ? '' : `${commentThreadCount}c`;
   }
@@ -864,205 +882,91 @@
     return fileData;
   }
 
-  _handleLeftPane(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveLeft();
+  _handleLeftPane() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveLeft();
   }
 
-  _handleRightPane(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this._noDiffsExpanded()) {
-      return;
-    }
-
-    e.preventDefault();
-    this.$.diffCursor.moveRight();
+  _handleRightPane() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveRight();
   }
 
-  _handleToggleInlineDiff(e: CustomKeyboardEvent) {
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      this.modifierPressed(e) ||
-      this.fileCursor.index === -1
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleToggleInlineDiff() {
+    if (this.fileCursor.index === -1) return;
     this._toggleFileExpandedByIndex(this.fileCursor.index);
   }
 
-  _handleToggleAllInlineDiffs(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    this._toggleInlineDiffs();
-  }
-
-  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
-    toggleClass(this, 'hideComments');
-  }
-
-  _handleCursorNext(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveDown();
+  _handleCursorNext(e: KeyboardEvent) {
+    if (this.filesExpanded === FilesExpandedState.ALL) {
+      this.diffCursor.moveDown();
       this._displayLine = true;
     } else {
-      // Down key
-      if (this.getKeyboardEvent(e).keyCode === 40) {
-        return;
-      }
-      e.preventDefault();
-      this.fileCursor.next();
+      if (e.key === Key.DOWN) return;
+      this.fileCursor.next({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleCursorPrev(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-
-    if (this._showInlineDiffs) {
-      e.preventDefault();
-      this.$.diffCursor.moveUp();
+  _handleCursorPrev(e: KeyboardEvent) {
+    if (this.filesExpanded === FilesExpandedState.ALL) {
+      this.diffCursor.moveUp();
       this._displayLine = true;
     } else {
-      // Up key
-      if (this.getKeyboardEvent(e).keyCode === 38) {
-        return;
-      }
-      e.preventDefault();
-      this.fileCursor.previous();
+      if (e.key === Key.UP) return;
+      this.fileCursor.previous({circular: true});
       this.selectedIndex = this.fileCursor.index;
     }
   }
 
-  _handleNewComment(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
+  _handleNewComment() {
     this.classList.remove('hideComments');
-    this.$.diffCursor.createCommentInPlace();
+    this.diffCursor.createCommentInPlace();
   }
 
-  _handleOpenLastFile(e: CustomKeyboardEvent) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      this.getKeyboardEvent(e).metaKey
-    ) {
-      return;
-    }
-
-    e.preventDefault();
-    this._openSelectedFile(this._files.length - 1);
-  }
-
-  _handleOpenFirstFile(e: CustomKeyboardEvent) {
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      this.getKeyboardEvent(e).metaKey
-    ) {
-      return;
-    }
-
-    e.preventDefault();
-    this._openSelectedFile(0);
-  }
-
-  _handleOpenFile(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
-
-    if (this._showInlineDiffs) {
+  handleOpenFile() {
+    if (this.filesExpanded === FilesExpandedState.ALL) {
       this._openCursorFile();
       return;
     }
-
     this._openSelectedFile();
   }
 
-  _handleNextChunk(e: CustomKeyboardEvent) {
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
-      this._noDiffsExpanded()
-    ) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
-      this.$.diffCursor.moveToNextCommentThread();
-    } else {
-      this.$.diffCursor.moveToNextChunk();
-    }
+  _handleNextChunk() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToNextChunk();
   }
 
-  _handlePrevChunk(e: CustomKeyboardEvent) {
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) &&
-        !this.isModifierPressed(e, Modifier.SHIFT_KEY)) ||
-      this._noDiffsExpanded()
-    ) {
-      return;
-    }
-
-    e.preventDefault();
-    if (this.isModifierPressed(e, Modifier.SHIFT_KEY)) {
-      this.$.diffCursor.moveToPreviousCommentThread();
-    } else {
-      this.$.diffCursor.moveToPreviousChunk();
-    }
+  _handleNextComment() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToNextCommentThread();
   }
 
-  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
+  _handlePrevChunk() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToPreviousChunk();
+  }
 
-    e.preventDefault();
+  _handlePrevComment() {
+    if (this._noDiffsExpanded()) return;
+    this.diffCursor.moveToPreviousCommentThread();
+  }
+
+  _handleToggleFileReviewed() {
     if (!this._files[this.fileCursor.index]) {
       return;
     }
     this._reviewFile(this._files[this.fileCursor.index].__path);
   }
 
-  _handleToggleLeftPane(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleToggleLeftPane() {
     this._forEachDiff(diff => {
       diff.toggleLeftDiff();
     });
   }
 
   _toggleInlineDiffs() {
-    if (this._showInlineDiffs) {
+    if (this.filesExpanded === FilesExpandedState.ALL) {
       this.collapseAllDiffs();
     } else {
       this.expandAllDiffs();
@@ -1070,7 +974,7 @@
   }
 
   _openCursorFile() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
+    const diff = this.diffCursor.getTargetDiffElement();
     if (!this.change || !diff || !this.patchRange || !diff.path) {
       throw new Error('change, diff and patchRange must be all set and valid');
     }
@@ -1100,14 +1004,6 @@
     );
   }
 
-  _addDraftAtTarget() {
-    const diff = this.$.diffCursor.getTargetDiffElement();
-    const target = this.$.diffCursor.getTargetLineElement();
-    if (diff && target) {
-      diff.addDraftAtLine(target);
-    }
-  }
-
   _shouldHideChangeTotals(_patchChange: PatchChange): boolean {
     return _patchChange.inserted === 0 && _patchChange.deleted === 0;
   }
@@ -1128,7 +1024,7 @@
     // Polymer 2: check for undefined
     if (
       change === undefined ||
-      patchRange === undefined ||
+      !patchRange?.patchNum ||
       path === undefined ||
       editMode === undefined
     ) {
@@ -1209,6 +1105,24 @@
       : 'gr-icons:expand-more';
   }
 
+  _computeShowNumCleanlyMerged(cleanlyMergedPaths: string[]): boolean {
+    return cleanlyMergedPaths.length > 0;
+  }
+
+  _computeCleanlyMergedText(cleanlyMergedPaths: string[]): string {
+    const fileCount = pluralize(cleanlyMergedPaths.length, 'file');
+    return `${fileCount} merged cleanly in Parent 1`;
+  }
+
+  _handleShowParent1(): void {
+    if (!this.change || !this.patchRange) return;
+    GerritNav.navigateToChange(
+      this.change,
+      this.patchRange.patchNum,
+      -1 as BasePatchSetNum // Parent 1
+    );
+  }
+
   @observe(
     '_filesByPath',
     'changeComments',
@@ -1233,12 +1147,10 @@
     ) {
       return;
     }
-
     // Await all promises resolving from reload. @See Issue 9057
     if (loading || !changeComments) {
       return;
     }
-
     const commentedPaths = changeComments.getPaths(patchRange);
     const files: FileNameToReviewedFileInfoMap = {...filesByPath};
     addUnmodifiedFiles(files, commentedPaths);
@@ -1285,12 +1197,7 @@
 
   _updateDiffCursor() {
     // Overwrite the cursor's list of diffs:
-    this.$.diffCursor.splice(
-      'diffs',
-      0,
-      this.$.diffCursor.diffs.length,
-      ...this.diffs
-    );
+    this.diffCursor.replaceDiffs(this.diffs);
   }
 
   _filesChanged() {
@@ -1411,11 +1318,9 @@
     );
 
     // Find the paths introduced by the new index splices:
-    const newFiles = record.indexSplices
-      .map(splice =>
-        splice.object.slice(splice.index, splice.index + splice.addedCount)
-      )
-      .reduce((acc, paths) => acc.concat(paths), []);
+    const newFiles = record.indexSplices.flatMap(splice =>
+      splice.object.slice(splice.index, splice.index + splice.addedCount)
+    );
 
     // Required so that the newly created diff view is included in this.diffs.
     flush();
@@ -1427,7 +1332,7 @@
     }
 
     this._updateDiffCursor();
-    this.$.diffCursor.reInitAndUpdateStops();
+    this.diffCursor.reInitAndUpdateStops();
   }
 
   private _clearCollapsedDiffs(collapsedDiffs: GrDiffHost[]) {
@@ -1460,64 +1365,50 @@
       }
     }
 
-    return new Promise(resolve => {
-      this.dispatchEvent(
-        new CustomEvent('reload-drafts', {
-          detail: {resolve},
-          composed: true,
-          bubbles: true,
-        })
+    asyncForeach(files, (file, cancel) => {
+      const path = file.path;
+      this._cancelForEachDiff = cancel;
+
+      iter++;
+      console.info('Expanding diff', iter, 'of', initialCount, ':', path);
+      const diffElem = this._findDiffByPath(path, diffElements);
+      if (!diffElem) {
+        this.reporting.error(
+          new Error(`Did not find <gr-diff-host> element for ${path}`)
+        );
+        return Promise.resolve();
+      }
+      if (!this.diffPrefs) {
+        throw new Error('diffPrefs must be set');
+      }
+
+      const promises: Array<Promise<unknown>> = [diffElem.reload()];
+      if (this._loggedIn && !this.diffPrefs.manual_review) {
+        promises.push(this._reviewFile(path, true));
+      }
+      return Promise.all(promises);
+    }).then(() => {
+      this._cancelForEachDiff = undefined;
+      console.info('Finished expanding', initialCount, 'diff(s)');
+      this.reporting.timeEndWithAverage(
+        Timing.FILE_EXPAND_ALL,
+        Timing.FILE_EXPAND_ALL_AVG,
+        initialCount
       );
-    }).then(() =>
-      asyncForeach(files, (file, cancel) => {
-        const path = file.path;
-        this._cancelForEachDiff = cancel;
+      /* Block diff cursor from auto scrolling after files are done rendering.
+      * This prevents the bug where the screen jumps to the first diff chunk
+      * after files are done being rendered after the user has already begun
+      * scrolling.
+      * This also however results in the fact that the cursor does not auto
+      * focus on the first diff chunk on a small screen. This is however, a use
+      * case we are willing to not support for now.
 
-        iter++;
-        console.info('Expanding diff', iter, 'of', initialCount, ':', path);
-        const diffElem = this._findDiffByPath(path, diffElements);
-        if (!diffElem) {
-          console.warn(`Did not find <gr-diff-host> element for ${path}`);
-          return Promise.resolve();
-        }
-        if (!this.changeComments || !this.patchRange || !this.diffPrefs) {
-          throw new Error(
-            'changeComments, patchRange and diffPrefs must be set'
-          );
-        }
-
-        diffElem.threads = this.changeComments.getThreadsBySideForFile(
-          file,
-          this.patchRange
-        );
-        const promises: Array<Promise<unknown>> = [diffElem.reload()];
-        if (this._loggedIn && !this.diffPrefs.manual_review) {
-          promises.push(this._reviewFile(path, true));
-        }
-        return Promise.all(promises);
-      }).then(() => {
-        this._cancelForEachDiff = undefined;
-        console.info('Finished expanding', initialCount, 'diff(s)');
-        this.reporting.timeEndWithAverage(
-          Timing.FILE_EXPAND_ALL,
-          Timing.FILE_EXPAND_ALL_AVG,
-          initialCount
-        );
-        /* Block diff cursor from auto scrolling after files are done rendering.
-       * This prevents the bug where the screen jumps to the first diff chunk
-       * after files are done being rendered after the user has already begun
-       * scrolling.
-       * This also however results in the fact that the cursor does not auto
-       * focus on the first diff chunk on a small screen. This is however, a use
-       * case we are willing to not support for now.
-
-       * Using handleDiffUpdate resulted in diffCursor.row being set which
-       * prevented the issue of scrolling to top when we expand the second
-       * file individually.
-       */
-        this.$.diffCursor.reInitAndUpdateStops();
-      })
-    );
+      * Using handleDiffUpdate resulted in diffCursor.row being set which
+      * prevented the issue of scrolling to top when we expand the second
+      * file individually.
+      */
+      this.diffCursor.reInitAndUpdateStops();
+    });
   }
 
   /** Cancel the rendering work of every diff in the list */
@@ -1540,52 +1431,7 @@
     return undefined;
   }
 
-  /**
-   * Reset the comments of a modified thread
-   */
-  reloadCommentsForThreadWithRootId(rootId: UrlEncodedCommentId, path: string) {
-    // Don't bother continuing if we already know that the path that contains
-    // the updated comment thread is not expanded.
-    if (!this._expandedFiles.some(f => f.path === path)) {
-      return;
-    }
-    const diff = this.diffs.find(d => d.path === path);
-
-    if (!diff) {
-      throw new Error("Can't find diff by path");
-    }
-
-    const threadEl = diff.getThreadEls().find(t => t.rootId === rootId);
-    if (!threadEl) {
-      return;
-    }
-
-    if (!this.changeComments) {
-      throw new Error('changeComments must be set');
-    }
-
-    const newComments = this.changeComments.getCommentsForThread(rootId);
-
-    // If newComments is null, it means that a single draft was
-    // removed from a thread in the thread view, and the thread should
-    // no longer exist. Remove the existing thread element in the diff
-    // view.
-    if (!newComments) {
-      threadEl.fireRemoveSelf();
-      return;
-    }
-
-    threadEl.comments = newComments.map(c => {
-      return {...c};
-    });
-    flush();
-  }
-
-  _handleEscKey(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
-      return;
-    }
-    e.preventDefault();
+  _handleEscKey() {
     this._displayLine = false;
   }
 
@@ -1711,10 +1557,6 @@
     return stats.deletionOffset;
   }
 
-  _computeShowSizeBars(userPrefs?: PreferencesInfo) {
-    return !!userPrefs?.size_bar_in_change_table;
-  }
-
   _computeSizeBarsClass(showSizeBars?: boolean, path?: string) {
     let hideClass = '';
     if (!showSizeBars) {
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 89983ad..f7be36b 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
@@ -206,6 +209,12 @@
       cursor: pointer;
       opacity: 100;
     }
+    .showParentButton {
+      line-height: var(--line-height-normal);
+      margin-bottom: calc(var(--spacing-s) * -1);
+      margin-left: var(--spacing-m);
+      margin-top: calc(var(--spacing-s) * -1);
+    }
     .row:focus {
       outline: none;
     }
@@ -247,9 +256,7 @@
       display: inline-block;
       visibility: hidden;
       vertical-align: bottom;
-      --gr-button: {
-        padding: 0px;
-      }
+      --gr-button-padding: 0px;
     }
     .row:focus-within gr-copy-clipboard,
     .row:hover gr-copy-clipboard {
@@ -309,12 +316,12 @@
           as="headerEndpoint"
         >
           <gr-endpoint-decorator name$="[[headerEndpoint]]" role="columnheader">
-            <gr-endpoint-param
-              name="change"
-              value="[[change]]"
-            ></gr-endpoint-param>
+            <gr-endpoint-param name="change" value="[[change]]">
+            </gr-endpoint-param>
             <gr-endpoint-param name="patchRange" value="[[patchRange]]">
             </gr-endpoint-param>
+            <gr-endpoint-param name="files" value="[[_files]]">
+            </gr-endpoint-param>
           </gr-endpoint-decorator>
         </template>
       </template>
@@ -404,7 +411,7 @@
               </span>
               <gr-file-status-chip file="[[file]]"></gr-file-status-chip>
               <gr-copy-clipboard
-                hide-input=""
+                hideInput=""
                 text="[[file.__path]]"
               ></gr-copy-clipboard>
             </a>
@@ -412,7 +419,7 @@
               <div class="oldPath" title$="[[file.old_path]]">
                 [[file.old_path]]
                 <gr-copy-clipboard
-                  hide-input=""
+                  hideInput=""
                   text="[[file.old_path]]"
                 ></gr-copy-clipboard>
               </div>
@@ -423,8 +430,7 @@
               <span class="drafts"
                 ><!-- This comments ensure that span is empty when the function
                 returns empty string.
-              -->[[_computeDraftsString(changeComments, patchRange,
-                file.__path)]]<!-- This comments ensure that span is empty when
+              -->[[_computeDraftsString(changeComments, patchRange, file)]]<!-- This comments ensure that span is empty when
                 the function returns empty string.
            --></span
               >
@@ -450,14 +456,14 @@
                 ><!-- This comments ensure that span is empty when the function
                 returns empty string.
               -->[[_computeDraftsStringMobile(changeComments, patchRange,
-                file.__path)]]<!-- This comments ensure that span is empty when
+                file)]]<!-- This comments ensure that span is empty when
                 the function returns empty string.
            --></span
               >
               <span
                 ><!--
              -->[[_computeCommentsStringMobile(changeComments, patchRange,
-                file.__path)]]<!--
+                file)]]<!--
            --></span
               >
               <span class="noCommentsScreenReaderText">
@@ -642,6 +648,54 @@
         </template>
       </div>
     </template>
+    <template
+      is="dom-if"
+      if="[[_computeShowNumCleanlyMerged(_cleanlyMergedPaths)]]"
+    >
+      <div class="row">
+        <!-- endpoint: change-view-file-list-content-prepend -->
+        <template is="dom-if" if="[[_showPrependedDynamicColumns]]">
+          <template
+            is="dom-repeat"
+            items="[[_dynamicPrependedContentEndpoints]]"
+            as="contentEndpoint"
+          >
+            <gr-endpoint-decorator name="[[contentEndpoint]]" role="gridcell">
+              <gr-endpoint-param name="change" value="[[change]]">
+              </gr-endpoint-param>
+              <gr-endpoint-param name="changeNum" value="[[changeNum]]">
+              </gr-endpoint-param>
+              <gr-endpoint-param name="patchRange" value="[[patchRange]]">
+              </gr-endpoint-param>
+              <gr-endpoint-param
+                name="cleanlyMergedPaths"
+                value="[[_cleanlyMergedPaths]]"
+              >
+              </gr-endpoint-param>
+              <gr-endpoint-param
+                name="cleanlyMergedOldPaths"
+                value="[[_cleanlyMergedOldPaths]]"
+              >
+              </gr-endpoint-param>
+            </gr-endpoint-decorator>
+          </template>
+        </template>
+        <div role="gridcell">
+          <div>
+            <span class="cleanlyMergedText">
+              [[_computeCleanlyMergedText(_cleanlyMergedPaths)]]
+            </span>
+            <gr-button
+              link
+              class="showParentButton"
+              on-click="_handleShowParent1"
+            >
+              Show Parent 1
+            </gr-button>
+          </div>
+        </div>
+      </div>
+    </template>
   </div>
   <div class="row totalChanges" hidden$="[[_hideChangeTotals]]">
     <div class="total-stats">
@@ -736,5 +790,4 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
 `;
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 b8ba86c..7409be7 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
@@ -17,6 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import '../../diff/gr-comment-api/gr-comment-api.js';
+import '../../shared/gr-date-formatter/gr-date-formatter.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-file-list.js';
 import {createCommentApiMockWithTemplateElement} from '../../../test/mocks/comment-api.js';
@@ -25,16 +26,29 @@
 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, stubRestApi, spyRestApi, listenOnce} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
+import {
+  listenOnce,
+  mockPromise,
+  query,
+  spyRestApi,
+  stubRestApi,
+} from '../../../test/test-utils.js';
+import {EditPatchSetNum} from '../../../types/common.js';
 import {createCommentThreads} from '../../../utils/comment-util.js';
-import {createChangeComments} from '../../../test/test-data-generators.js';
+import {
+  createChange,
+  createChangeComments,
+  createCommit,
+  createParsedChange,
+  createRevision,
+} from '../../../test/test-data-generators.js';
+import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {queryAndAssert} from '../../../utils/common-util.js';
 
 const commentApiMock = createCommentApiMockWithTemplateElement(
     'gr-file-list-comment-api-mock', html`
     <gr-file-list id="fileList"
-        change-comments="[[_changeComments]]"
-        on-reload-drafts="_reloadDraftsWithCallback"></gr-file-list>
+        change-comments="[[_changeComments]]"></gr-file-list>
     <gr-comment-api id="commentAPI"></gr-comment-api>
 `);
 
@@ -51,35 +65,9 @@
   let commentApiWrapper;
 
   let saveStub;
-  let loadCommentSpy;
-
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-    kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-    kb.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    kb.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    kb.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    kb.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-    kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-    kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-    kb.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
-    kb.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
-    kb.bindShortcut(Shortcut.OPEN_FILE, 'o');
-    kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-    kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-    kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
 
   suite('basic tests', () => {
-    setup(done => {
-      stubRestApi('getPreferences').returns(Promise.resolve({}));
+    setup(async () => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
@@ -94,14 +82,7 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.diffPrefs = {};
       element.numFilesShown = 200;
@@ -360,103 +341,103 @@
 
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, parentTo1
-              , '/COMMIT_MSG'), '2c');
+              , {__path: '/COMMIT_MSG'}), '2c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2
-              , '/COMMIT_MSG'), '3c');
+              , {__path: '/COMMIT_MSG'}), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              'unresolved.file'), '1 draft');
+              {__path: 'unresolved.file'}), '1 draft');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              'unresolved.file'), '1 draft');
+              {__path: 'unresolved.file'}), '1 draft');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'unresolved.file'), '1d');
+              {__path: 'unresolved.file'}), '1d');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'unresolved.file'), '1d');
+              {__path: 'unresolved.file'}), '1d');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
-              'myfile.txt'
+              {__path: 'myfile.txt'}
           ), '1c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
+              {__path: 'myfile.txt'}), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo1,
-              'file_added_in_rev2.txt'
+              {__path: 'file_added_in_rev2.txt'}
           ), '');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo1,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'file_added_in_rev2.txt'), '');
+              {__path: 'file_added_in_rev2.txt'}), '');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
-              '/COMMIT_MSG'
+              {__path: '/COMMIT_MSG'}
           ), '1c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '3c');
+              {__path: '/COMMIT_MSG'}), '3c');
       assert.equal(
           element._computeDraftsString(element.changeComments, parentTo1,
-              '/COMMIT_MSG'), '2 drafts');
+              {__path: '/COMMIT_MSG'}), '2 drafts');
       assert.equal(
           element._computeDraftsString(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2 drafts');
+              {__path: '/COMMIT_MSG'}), '2 drafts');
       assert.equal(
           element._computeDraftsStringMobile(
               element.changeComments,
               parentTo1,
-              '/COMMIT_MSG'
+              {__path: '/COMMIT_MSG'}
           ), '2d');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              '/COMMIT_MSG'), '2d');
+              {__path: '/COMMIT_MSG'}), '2d');
       assert.equal(
           element._computeCommentsStringMobile(
               element.changeComments,
               parentTo2,
-              'myfile.txt'
+              {__path: 'myfile.txt'}
           ), '2c');
       assert.equal(
           element._computeCommentsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '3c');
+              {__path: 'myfile.txt'}), '3c');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, parentTo2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
       assert.equal(
           element._computeDraftsStringMobile(element.changeComments, _1To2,
-              'myfile.txt'), '');
+              {__path: 'myfile.txt'}), '');
     });
 
     test('_reviewedTitle', () => {
@@ -489,7 +470,7 @@
         // https://github.com/sinonjs/sinon/issues/781
         const diffsStub = sinon.stub(element, 'diffs')
             .get(() => [{toggleLeftDiff: toggleLeftDiffStub}]);
-        MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
         assert.isTrue(toggleLeftDiffStub.calledOnce);
         diffsStub.restore();
       });
@@ -505,7 +486,7 @@
         assert.isFalse(items[1].classList.contains('selected'));
         assert.isFalse(items[2].classList.contains('selected'));
         // j with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'J');
         assert.equal(element.fileCursor.index, 0);
         // down should not move the cursor.
         MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
@@ -521,11 +502,11 @@
         assert.equal(element.selectedIndex, 2);
 
         // k with a modifier should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'K');
         assert.equal(element.fileCursor.index, 2);
 
         // up should not move the cursor.
-        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'up');
         assert.equal(element.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -540,10 +521,10 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
-        assert.equal(element.fileCursor.index, 0);
-        assert.equal(element.selectedIndex, 0);
+        assert.equal(element.fileCursor.index, 1);
+        assert.equal(element.selectedIndex, 1);
 
-        const createCommentInPlaceStub = sinon.stub(element.$.diffCursor,
+        const createCommentInPlaceStub = sinon.stub(element.diffCursor,
             'createCommentInPlace');
         MockInteractions.pressAndReleaseKeyOn(element, 67, null, 'c');
         assert.isTrue(createCommentInPlaceStub.called);
@@ -559,35 +540,36 @@
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[0]);
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[0]);
 
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
 
         element.fileCursor.setCursorAtIndex(1);
-        MockInteractions.keyUpOn(element, 73, null, 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flush();
         assert.equal(element.diffs.length, 1);
         assert.equal(element.diffs[0].path, paths[1]);
         assert.equal(element._expandedFiles.length, 1);
         assert.equal(element._expandedFiles[0].path, paths[1]);
 
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
         flush();
         assert.equal(element.diffs.length, paths.length);
         assert.equal(element._expandedFiles.length, paths.length);
         for (const diff of element.diffs) {
           assert.isTrue(element._expandedFiles.some(f => f.path === diff.path));
         }
-
-        MockInteractions.keyUpOn(element, 73, 'shift', 'i');
+        // since _expandedFilesChanged is stubbed
+        element.filesExpanded = FilesExpandedState.ALL;
+        MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
         flush();
         assert.equal(element.diffs.length, 0);
         assert.equal(element._expandedFiles.length, 0);
@@ -611,26 +593,19 @@
         assert.equal(getNumReviewed(), 0);
       });
 
-      suite('_handleOpenFile', () => {
+      suite('handleOpenFile', () => {
         let interact;
 
         setup(() => {
-          sinon.stub(element, 'shouldSuppressKeyboardShortcut')
-              .returns(false);
-          sinon.stub(element, 'modifierPressed').returns(false);
           const openCursorStub = sinon.stub(element, '_openCursorFile');
           const openSelectedStub = sinon.stub(element, '_openSelectedFile');
           const expandStub = sinon.stub(element, '_toggleFileExpanded');
 
-          interact = function(opt_payload) {
+          interact = function() {
             openCursorStub.reset();
             openSelectedStub.reset();
             expandStub.reset();
-
-            const e = new CustomEvent('fake-keyboard-event', opt_payload);
-            sinon.stub(e, 'preventDefault');
-            element._handleOpenFile(e);
-            assert.isTrue(e.preventDefault.called);
+            element.handleOpenFile();
             const result = {};
             if (openCursorStub.called) {
               result.opened_cursor = true;
@@ -646,17 +621,17 @@
         });
 
         test('open from selected file', () => {
-          element._showInlineDiffs = false;
+          element.filesExpanded = FilesExpandedState.NONE;
           assert.deepEqual(interact(), {opened_selected: true});
         });
 
         test('open from diff cursor', () => {
-          element._showInlineDiffs = true;
+          element.filesExpanded = FilesExpandedState.ALL;
           assert.deepEqual(interact(), {opened_cursor: true});
         });
 
         test('expand when user prefers', () => {
-          element._showInlineDiffs = false;
+          element.filesExpanded = FilesExpandedState.NONE;
           assert.deepEqual(interact(), {opened_selected: true});
           element._userPrefs = {};
           assert.deepEqual(interact(), {opened_selected: true});
@@ -664,23 +639,27 @@
       });
 
       test('shift+left/shift+right', () => {
-        const moveLeftStub = sinon.stub(element.$.diffCursor, 'moveLeft');
-        const moveRightStub = sinon.stub(element.$.diffCursor, 'moveRight');
+        const moveLeftStub = sinon.stub(element.diffCursor, 'moveLeft');
+        const moveRightStub = sinon.stub(element.diffCursor, 'moveRight');
 
         let noDiffsExpanded = true;
         sinon.stub(element, '_noDiffsExpanded')
             .callsFake(() => noDiffsExpanded);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowLeft');
         assert.isFalse(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowRight');
         assert.isFalse(moveRightStub.called);
 
         noDiffsExpanded = false;
 
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'left');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowLeft');
         assert.isTrue(moveLeftStub.called);
-        MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'right');
+        MockInteractions.pressAndReleaseKeyOn(
+            element, 73, 'shift', 'ArrowRight');
         assert.isTrue(moveRightStub.called);
       });
     });
@@ -913,35 +892,36 @@
 
     test('expandAllDiffs and collapseAllDiffs', () => {
       const collapseStub = sinon.stub(element, '_clearCollapsedDiffs');
-      const cursorUpdateStub = sinon.stub(element.$.diffCursor,
+      const cursorUpdateStub = sinon.stub(element.diffCursor,
           'handleDiffUpdate');
-      const reInitStub = sinon.stub(element.$.diffCursor,
+      const reInitStub = sinon.stub(element.diffCursor,
           'reInitAndUpdateStops');
 
       const path = 'path/to/my/file.txt';
       element._filesByPath = {[path]: {}};
       element.expandAllDiffs();
       flush();
-      assert.isTrue(element._showInlineDiffs);
+      assert.equal(element.filesExpanded, FilesExpandedState.ALL);
       assert.isTrue(reInitStub.calledOnce);
       assert.equal(collapseStub.lastCall.args[0].length, 0);
 
       element.collapseAllDiffs();
       flush();
       assert.equal(element._expandedFiles.length, 0);
-      assert.isFalse(element._showInlineDiffs);
+      assert.equal(element.filesExpanded, FilesExpandedState.NONE);
       assert.isTrue(cursorUpdateStub.calledOnce);
       assert.equal(collapseStub.lastCall.args[0].length, 1);
     });
 
-    test('_expandedFilesChanged', done => {
+    test('_expandedFilesChanged', async () => {
       sinon.stub(element, '_reviewFile');
       const path = 'path/to/my/file.txt';
+      const promise = mockPromise();
       const diffs = [{
         path,
         style: {},
         reload() {
-          done();
+          promise.resolve();
         },
         prefetchDiff() {},
         cancel() {},
@@ -955,6 +935,7 @@
       }];
       sinon.stub(element, 'diffs').get(() => diffs);
       element.push('_expandedFiles', {path});
+      await promise;
     });
 
     test('_clearCollapsedDiffs', () => {
@@ -993,7 +974,7 @@
           FilesExpandedState.ALL);
     });
 
-    test('_renderInOrder', done => {
+    test('_renderInOrder', async () => {
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
       const diffs = [{
@@ -1023,15 +1004,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.isFalse(reviewStub.called);
-            assert.isTrue(loadCommentSpy.called);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.isFalse(reviewStub.called);
     });
 
-    test('_renderInOrder logged in', done => {
+    test('_renderInOrder logged in', async () => {
       element._loggedIn = true;
       const reviewStub = sinon.stub(element, '_reviewFile');
       let callCount = 0;
@@ -1065,14 +1043,12 @@
       }];
       element._renderInOrder([
         {path: 'p2'}, {path: 'p1'}, {path: 'p0'},
-      ], diffs, 3)
-          .then(() => {
-            assert.equal(reviewStub.callCount, 3);
-            done();
-          });
+      ], diffs, 3);
+      await flush();
+      assert.equal(reviewStub.callCount, 3);
     });
 
-    test('_renderInOrder respects diffPrefs.manual_review', () => {
+    test('_renderInOrder respects diffPrefs.manual_review', async () => {
       element._loggedIn = true;
       element.diffPrefs = {manual_review: true};
       const reviewStub = sinon.stub(element, '_reviewFile');
@@ -1083,43 +1059,163 @@
         reload() { return Promise.resolve(); },
       }];
 
-      return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-        assert.isFalse(reviewStub.called);
-        delete element.diffPrefs.manual_review;
-        return element._renderInOrder([{path: 'p'}], diffs, 1).then(() => {
-          assert.isTrue(reviewStub.called);
-          assert.isTrue(reviewStub.calledWithExactly('p', true));
-        });
-      });
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isFalse(reviewStub.called);
+      delete element.diffPrefs.manual_review;
+      element._renderInOrder([{path: 'p'}], diffs, 1);
+      await flush();
+      assert.isTrue(reviewStub.called);
+      assert.isTrue(reviewStub.calledWithExactly('p', true));
     });
 
-    test('_loadingChanged fired from reload in debouncer', done => {
-      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+    test('_loadingChanged fired from reload in debouncer', async () => {
+      const reloadBlocker = mockPromise();
+      stubRestApi('getChangeOrEditFiles').resolves({'foo.bar': {}});
+      stubRestApi('getReviewedFiles').resolves(null);
+      stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
+
       element.changeNum = 123;
       element.patchRange = {patchNum: 12};
       element._filesByPath = {'foo.bar': {}};
+      element.change = {...createParsedChange(), _number: 123};
 
-      element.reload().then(() => {
-        assert.isFalse(element._loading);
-        element.loadingTask.flush();
-        assert.isFalse(element.classList.contains('loading'));
-        done();
-      });
+      const reloaded = element.reload();
       assert.isTrue(element._loading);
       assert.isFalse(element.classList.contains('loading'));
       element.loadingTask.flush();
       assert.isTrue(element.classList.contains('loading'));
+
+      reloadBlocker.resolve();
+      await reloaded;
+
+      assert.isFalse(element._loading);
+      element.loadingTask.flush();
+      assert.isFalse(element.classList.contains('loading'));
     });
 
     test('_loadingChanged does not set class when there are no files', () => {
-      sinon.stub(element, '_getReviewedFiles').returns(Promise.resolve([]));
+      const reloadBlocker = mockPromise();
+      stubRestApi('getLoggedIn').returns(reloadBlocker.then(() => false));
+      sinon.stub(element, '_getReviewedFiles').resolves([]);
       element.changeNum = 123;
       element.patchRange = {patchNum: 12};
+      element.change = {...createParsedChange(), _number: 123};
       element.reload();
+
       assert.isTrue(element._loading);
+
       element.loadingTask.flush();
+
       assert.isFalse(element.classList.contains('loading'));
     });
+
+    suite('for merge commits', () => {
+      let filesStub;
+
+      setup(async () => {
+        filesStub = stubRestApi('getChangeOrEditFiles')
+            .onFirstCall()
+            .resolves({'conflictingFile.js': {}})
+            .onSecondCall()
+            .resolves({'conflictingFile.js': {}, 'cleanlyMergedFile.js': {}});
+        stubRestApi('getReviewedFiles').resolves([]);
+        stubRestApi('getDiffPreferences').resolves(createDefaultDiffPrefs());
+        const changeWithMultipleParents = {
+          ...createChange(),
+          revisions: {
+            r1: {
+              ...createRevision(),
+              commit: {
+                ...createCommit(),
+                parents: [
+                  {commit: 'p1', subject: 'subject1'},
+                  {commit: 'p2', subject: 'subject2'},
+                ],
+              },
+            },
+          },
+        };
+        element.changeNum = changeWithMultipleParents._number;
+        element.change = changeWithMultipleParents;
+        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+        await flush();
+      });
+
+      test('displays cleanly merged file count', async () => {
+        await element.reload();
+        await flush();
+
+        const message = queryAndAssert(element, '.cleanlyMergedText')
+            .textContent.trim();
+        assert.equal(message, '1 file merged cleanly in Parent 1');
+      });
+
+      test('displays plural cleanly merged file count', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+            .onFirstCall()
+            .resolves({'conflictingFile.js': {}})
+            .onSecondCall()
+            .resolves({
+              'conflictingFile.js': {},
+              'cleanlyMergedFile.js': {},
+              'anotherCleanlyMergedFile.js': {},
+            });
+        await element.reload();
+        await flush();
+
+        const message = queryAndAssert(
+            element,
+            '.cleanlyMergedText'
+        ).textContent.trim();
+        assert.equal(message, '2 files merged cleanly in Parent 1');
+      });
+
+      test('displays button for navigating to parent 1 base', async () => {
+        await element.reload();
+        await flush();
+
+        queryAndAssert(element, '.showParentButton');
+      });
+
+      test('computes old paths for cleanly merged files', async () => {
+        filesStub.restore();
+        stubRestApi('getChangeOrEditFiles')
+            .onFirstCall()
+            .resolves({'conflictingFile.js': {}})
+            .onSecondCall()
+            .resolves({
+              'conflictingFile.js': {},
+              'cleanlyMergedFile.js': {old_path: 'cleanlyMergedFileOldName.js'},
+            });
+        await element.reload();
+        await flush();
+
+        assert.deepEqual(element._cleanlyMergedOldPaths, [
+          'cleanlyMergedFileOldName.js',
+        ]);
+      });
+
+      test('not shown for non-Auto Merge base parents', async () => {
+        element.patchRange = {basePatchNum: 1, patchNum: 2};
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+
+      test('not shown in edit mode', async () => {
+        element.patchRange = {basePatchNum: 1, patchNum: EditPatchSetNum};
+        await element.reload();
+        await flush();
+
+        assert.notOk(query(element, '.cleanlyMergedText'));
+        assert.notOk(query(element, '.showParentButton'));
+      });
+    });
   });
 
   suite('diff url file list', () => {
@@ -1378,14 +1474,12 @@
         ignore_whitespace: 'IGNORE_NONE',
       };
       diff.diff = getMockDiffResponse();
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getCommentsForPath')
-            .withArgs('/COMMIT_MSG', {
-              basePatchNum: 'PARENT',
-              patchNum: 2,
-            })
-            .returns(diff.comments);
-      });
+      sinon.stub(diff.changeComments, 'getCommentsForPath')
+          .withArgs('/COMMIT_MSG', {
+            basePatchNum: 'PARENT',
+            patchNum: 2,
+          })
+          .returns(diff.comments);
       await listenOnce(diff, 'render');
     }
 
@@ -1398,11 +1492,11 @@
       }
 
       element._updateDiffCursor();
-      element.$.diffCursor.handleDiffUpdate();
+      element.diffCursor.handleDiffUpdate();
       return diffs;
     }
 
-    setup(done => {
+    setup(async () => {
       stubRestApi('getPreferences').returns(Promise.resolve({}));
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
@@ -1417,17 +1511,10 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.fileList;
-      loadCommentSpy = sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
       element.diffPrefs = {};
       element.change = {_number: 42, project: 'testRepo'};
       sinon.stub(element, '_reviewFile');
 
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      commentApiWrapper.loadComments().then(() => {
-        sinon.stub(element.changeComments, 'getPaths').returns({});
-        done();
-      });
       element._loading = false;
       element.numFilesShown = 75;
       element.selectedIndex = 0;
@@ -1454,12 +1541,12 @@
         patchNum: 2,
       };
       sinon.stub(window, 'fetch').callsFake(() => Promise.resolve());
-      flush();
+      await flush();
     });
 
     test('cursor with individually opened files', async () => {
-      MockInteractions.keyUpOn(element, 73, null, 'i');
-      flush();
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      await flush();
       let diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
 
@@ -1472,22 +1559,23 @@
       // Tapping content on a line selects the line number.
       MockInteractions.tap(dom(
           diffStops[10]).querySelectorAll('.contentText')[0]);
-      flush();
+      await flush();
       assert.isTrue(diffStops[10].classList.contains('target-row'));
 
       // Keyboard shortcuts are still moving the file cursor, not the diff
       // cursor.
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      flush();
+      await flush();
       assert.isTrue(diffStops[10].classList.contains('target-row'));
       assert.isFalse(diffStops[11].classList.contains('target-row'));
 
       // The file cursor is now at 1.
       assert.equal(element.fileCursor.index, 1);
-      MockInteractions.keyUpOn(element, 73, null, 'i');
-      flush();
 
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      await flush();
       diffs = await renderAndGetNewDiffs(1);
+
       // Two diffs should be rendered.
       assert.equal(diffs.length, 2);
       const diffStopsFirst = diffs[0].getCursorStops();
@@ -1499,8 +1587,8 @@
     });
 
     test('cursor with toggle all files', async () => {
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      flush();
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'I');
+      await flush();
 
       const diffs = await renderAndGetNewDiffs(0);
       const diffStops = diffs[0].getCursorStops();
@@ -1514,13 +1602,13 @@
       // Tapping content on a line selects the line number.
       MockInteractions.tap(dom(
           diffStops[10]).querySelectorAll('.contentText')[0]);
-      flush();
+      await flush();
       assert.isTrue(diffStops[10].classList.contains('target-row'));
 
       // Keyboard shortcuts are still moving the file cursor, not the diff
       // cursor.
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
-      flush();
+      await flush();
       assert.isFalse(diffStops[10].classList.contains('target-row'));
       assert.isTrue(diffStops[11].classList.contains('target-row'));
 
@@ -1529,78 +1617,58 @@
     });
 
     suite('n key presses', () => {
-      let nKeySpy;
       let nextCommentStub;
       let nextChunkStub;
       let fileRows;
 
       setup(() => {
         sinon.stub(element, '_renderInOrder').returns(Promise.resolve());
-        nKeySpy = sinon.spy(element, '_handleNextChunk');
-        nextCommentStub = sinon.stub(element.$.diffCursor,
+        nextCommentStub = sinon.stub(element.diffCursor,
             'moveToNextCommentThread');
-        nextChunkStub = sinon.stub(element.$.diffCursor,
+        nextChunkStub = sinon.stub(element.diffCursor,
             'moveToNextChunk');
         fileRows =
             element.root.querySelectorAll('.row:not(.header-row)');
       });
 
-      test('n key with some files expanded and no shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-        flush();
-
-        // Handle N key should return before calling diff cursor functions.
-        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.equal(element.filesExpanded, 'some');
-      });
-
-      test('n key with some files expanded and shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, null, 'i');
-        flush();
-        assert.equal(nextChunkStub.callCount, 0);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 0);
-        assert.equal(element.filesExpanded, 'some');
-      });
-
-      test('n key without all files expanded and shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-        flush();
+      test('n key with some files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
         MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
-        assert.isTrue(nKeySpy.called);
-        assert.isFalse(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 1);
-        assert.isTrue(element._showInlineDiffs);
+        assert.isTrue(nextChunkStub.calledOnce);
       });
 
-      test('n key without all files expanded and no shift key', () => {
-        MockInteractions.keyUpOn(fileRows[0], 73, 'shift', 'i');
-        flush();
+      test('N key with some files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.SOME);
 
-        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
-        assert.isTrue(nKeySpy.called);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
+        assert.isTrue(nextCommentStub.calledOnce);
+      });
+
+      test('n key with all files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nextChunkStub.calledOnce);
+      });
+
+      test('N key with all files expanded', async () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'I');
+        await flush();
+        assert.equal(element.filesExpanded, FilesExpandedState.ALL);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
         assert.isTrue(nextCommentStub.called);
-
-        // This is also called in diffCursor.moveToFirstChunk.
-        assert.equal(nextChunkStub.callCount, 0);
-        assert.isTrue(element._showInlineDiffs);
       });
     });
 
-    test('_openSelectedFile behavior', () => {
+    test('_openSelectedFile behavior', async () => {
       const _filesByPath = element._filesByPath;
       element.set('_filesByPath', {});
       const navStub = sinon.stub(GerritNav, 'navigateToDiff');
@@ -1609,35 +1677,30 @@
       assert.isFalse(navStub.called);
 
       element.set('_filesByPath', _filesByPath);
-      flush();
+      await flush();
       // Navigates when a file is selected.
       element._openSelectedFile();
       assert.isTrue(navStub.called);
     });
 
     test('_displayLine', () => {
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut')
-          .callsFake(() => false);
-      sinon.stub(element, 'modifierPressed')
-          .callsFake(() => false);
-      element._showInlineDiffs = true;
-      const mockEvent = {preventDefault() {}};
+      element.filesExpanded = FilesExpandedState.ALL;
 
       element._displayLine = false;
-      element._handleCursorNext(mockEvent);
+      element._handleCursorNext(new KeyboardEvent('keydown'));
       assert.isTrue(element._displayLine);
 
       element._displayLine = false;
-      element._handleCursorPrev(mockEvent);
+      element._handleCursorPrev(new KeyboardEvent('keydown'));
       assert.isTrue(element._displayLine);
 
       element._displayLine = true;
-      element._handleEscKey(mockEvent);
+      element._handleEscKey();
       assert.isFalse(element._displayLine);
     });
 
     suite('editMode behavior', () => {
-      test('reviewed checkbox', () => {
+      test('reviewed checkbox', async () => {
         element._reviewFile.restore();
         const saveReviewStub = sinon.stub(element, '_saveReviewedState');
 
@@ -1646,7 +1709,7 @@
         assert.isTrue(saveReviewStub.calledOnce);
 
         element.editMode = true;
-        flush();
+        await flush();
 
         MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
         assert.isTrue(saveReviewStub.calledOnce);
@@ -1662,13 +1725,13 @@
       });
     });
 
-    test('editing actions', () => {
+    test('editing actions', async () => {
       // Edit controls are guarded behind a dom-if initially and not rendered.
       assert.isNotOk(dom(element.root)
           .querySelector('gr-edit-file-controls'));
 
       element.editMode = true;
-      flush();
+      await flush();
 
       // Commit message should not have edit controls.
       const editControls =
@@ -1678,110 +1741,6 @@
               .map(row => row.querySelector('gr-edit-file-controls'));
       assert.isTrue(editControls[0].classList.contains('invisible'));
     });
-
-    test('reloadCommentsForThreadWithRootId', async () => {
-      // Expand the commit message diff
-      MockInteractions.keyUpOn(element, 73, 'shift', 'i');
-      const diffs = await renderAndGetNewDiffs(0);
-      flush();
-
-      // Two comment threads should be generated by renderAndGetNewDiffs
-      const threadEls = diffs[0].getThreadEls();
-      assert.equal(threadEls.length, 2);
-      const threadElsByRootId = new Map(
-          threadEls.map(threadEl => [threadEl.rootId, threadEl]));
-
-      const thread1 = threadElsByRootId.get('503008e2_0ab203ee');
-      assert.equal(thread1.comments.length, 1);
-      assert.equal(thread1.comments[0].message, 'a comment');
-      assert.equal(thread1.comments[0].line, 10);
-
-      const thread2 = threadElsByRootId.get('ecf0b9fa_fe1a5f62');
-      assert.equal(thread2.comments.length, 2);
-      assert.isTrue(thread2.comments[0].unresolved);
-      assert.equal(thread2.comments[0].message, 'another comment');
-      assert.equal(thread2.comments[0].line, 20);
-
-      const commentStub =
-          sinon.stub(element.changeComments, 'getCommentsForThread');
-      const commentStubRes1 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'edited text',
-          unresolved: false,
-        },
-      ];
-      const commentStubRes2 = [
-        {
-          patch_set: 2,
-          path: '/p',
-          id: 'ecf0b9fa_fe1a5f62',
-          line: 20,
-          updated: '2018-02-08 18:49:18.000000000',
-          message: 'another comment',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ee',
-          line: 10,
-          in_reply_to: 'ecf0b9fa_fe1a5f62',
-          updated: '2018-02-14 22:07:43.000000000',
-          message: 'response',
-          unresolved: true,
-        },
-        {
-          patch_set: 2,
-          path: '/p',
-          id: '503008e2_0ab203ef',
-          line: 20,
-          in_reply_to: '503008e2_0ab203ee',
-          updated: '2018-02-15 22:07:43.000000000',
-          message: 'a third comment in the thread',
-          unresolved: true,
-        },
-      ];
-      commentStub.withArgs('503008e2_0ab203ee').returns(
-          commentStubRes1);
-      commentStub.withArgs('ecf0b9fa_fe1a5f62').returns(
-          commentStubRes2);
-
-      // Reload comments from the first comment thread, which should have a
-      // an updated message and a toggled resolve state.
-      element.reloadCommentsForThreadWithRootId('503008e2_0ab203ee',
-          '/COMMIT_MSG');
-      assert.equal(thread1.comments.length, 1);
-      assert.isFalse(thread1.comments[0].unresolved);
-      assert.equal(thread1.comments[0].message, 'edited text');
-
-      // Reload comments from the second comment thread, which should have a new
-      // reply.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.equal(thread2.comments.length, 3);
-
-      const commentStubCount = commentStub.callCount;
-      const getThreadsSpy = sinon.spy(diffs[0], 'getThreadEls');
-
-      // Should not be getting threads when the file is not expanded.
-      element.reloadCommentsForThreadWithRootId('ecf0b9fa_fe1a5f62',
-          'other/file');
-      assert.isFalse(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-
-      // Should be query selecting diffs when the file is expanded.
-      // Should not be fetching change comments when the rootId is not found
-      // to match.
-      element.reloadCommentsForThreadWithRootId('acf0b9fa_fe1a5f62',
-          '/COMMIT_MSG');
-      assert.isTrue(getThreadsSpy.called);
-      assert.equal(commentStubCount, commentStub.callCount);
-    });
   });
 });
 
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 c11e484..d50e00f 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
@@ -16,6 +16,7 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../../styles/shared-styles';
+import '../../../styles/gr-font-styles';
 import '../../shared/gr-button/gr-button';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-included-in-dialog_html';
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 2029209..674b7e7 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       background-color: var(--dialog-background-color);
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
similarity index 63%
rename from polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js
rename to polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
index c109538..4e02155 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.js
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.ts
@@ -15,25 +15,37 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-included-in-dialog.js';
+import '../../../test/common-test-setup-karma';
+import './gr-included-in-dialog';
+import {GrIncludedInDialog} from './gr-included-in-dialog';
+import {BranchName, IncludedInInfo, TagName} from '../../../types/common';
+import {IronInputElement} from '@polymer/iron-input';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-included-in-dialog');
 
 suite('gr-included-in-dialog', () => {
-  let element;
+  let element: GrIncludedInDialog;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
   test('_computeGroups', () => {
-    const includedIn = {branches: [], tags: []};
+    const includedIn = {branches: [], tags: []} as IncludedInInfo;
     let filterText = '';
     assert.deepEqual(element._computeGroups(includedIn, filterText), []);
 
-    includedIn.branches.push('master', 'development', 'stable-2.0');
-    includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
+    includedIn.branches.push(
+      'master' as BranchName,
+      'development' as BranchName,
+      'stable-2.0' as BranchName
+    );
+    includedIn.tags.push(
+      'v1.9' as TagName,
+      'v2.0' as TagName,
+      'v2.1' as TagName
+    );
     assert.deepEqual(element._computeGroups(includedIn, filterText), [
       {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
       {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
@@ -64,19 +76,18 @@
     ]);
   });
 
-  test('_computeGroups with .bindValue', done => {
-    element.$.filterInput.bindValue = 'stable-3.2';
-    const includedIn = {branches: [], tags: []};
-    includedIn.branches.push('master', 'stable-3.2');
-
-    setTimeout(() => {
-      const filterText = element._filterText;
-      assert.deepEqual(element._computeGroups(includedIn, filterText), [
-        {title: 'Branches', items: ['stable-3.2']},
-      ]);
-
-      done();
-    });
+  test('_computeGroups with .bindValue', async () => {
+    queryAndAssert<IronInputElement>(element, '#filterInput')!.bindValue =
+      'stable-3.2';
+    const includedIn = {branches: [], tags: []} as IncludedInInfo;
+    includedIn.branches.push(
+      'master' as BranchName,
+      'stable-3.2' as BranchName
+    );
+    await flush();
+    const filterText = element._filterText;
+    assert.deepEqual(element._computeGroups(includedIn, filterText), [
+      {title: 'Branches', items: ['stable-3.2']},
+    ]);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
index b4fa4a8..9a38095 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.ts
@@ -16,7 +16,6 @@
  */
 import '@polymer/iron-selector/iron-selector';
 import '../../shared/gr-button/gr-button';
-import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-label-score-row_html';
@@ -133,9 +132,10 @@
     if (side === 'start') {
       return new Array(startPosition);
     }
-    const endPosition = this.labelValues[
-      Number(permittedLabels[label][permittedLabels[label].length - 1])
-    ];
+    const endPosition =
+      this.labelValues[
+        Number(permittedLabels[label][permittedLabels[label].length - 1])
+      ];
     return new Array(Object.keys(this.labelValues).length - endPosition - 1);
   }
 
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
index c53f386..e57a216 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_html.ts
@@ -17,9 +17,6 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
   <style include="shared-styles">
     .labelNameCell,
     .buttonsCell,
@@ -48,58 +45,39 @@
     gr-button {
       min-width: 42px;
       box-sizing: border-box;
-      --gr-button: {
-        background-color: var(
-          --button-background-color,
-          var(--table-header-background-color)
-        );
-        padding: 0 var(--spacing-m);
-        @apply --vote-chip-styles;
-      }
     }
-    gr-button.iron-selected[vote='max'] {
+    gr-button::part(paper-button) {
+      background-color: var(
+        --button-background-color,
+        var(--table-header-background-color)
+      );
+      padding: 0 var(--spacing-m);
+    }
+    gr-button[vote='max'].iron-selected {
       --button-background-color: var(--vote-color-approved);
     }
-    gr-button.iron-selected[vote='positive'] {
+    gr-button[vote='positive'].iron-selected {
       --button-background-color: var(--vote-color-recommended);
-      --gr-button: {
-        padding: 0 var(--spacing-m);
-        border-style: solid;
-        border-color: var(--vote-outline-recommended);
-        border-top-left-radius: 1em;
-        border-top-right-radius: 1em;
-        border-bottom-right-radius: 1em;
-        border-bottom-left-radius: 1em;
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        color: var(--chip-color);
-      }
     }
-    gr-button.iron-selected[vote='min'] {
+    gr-button[vote='min'].iron-selected {
       --button-background-color: var(--vote-color-rejected);
     }
-    gr-button.iron-selected[vote='negative'] {
+    gr-button[vote='negative'].iron-selected {
       --button-background-color: var(--vote-color-disliked);
-      --gr-button: {
-        padding: 0 var(--spacing-m);
-        border-style: solid;
-        border-color: var(--vote-outline-disliked);
-        border-top-left-radius: 1em;
-        border-top-right-radius: 1em;
-        border-bottom-right-radius: 1em;
-        border-bottom-left-radius: 1em;
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        color: var(--chip-color);
-      }
     }
-    gr-button.iron-selected[vote='neutral'] {
+    gr-button[vote='neutral'].iron-selected {
       --button-background-color: var(--vote-color-neutral);
     }
+    gr-button[vote='positive'].iron-selected::part(paper-button) {
+      border-color: var(--vote-outline-recommended);
+    }
+    gr-button[vote='negative'].iron-selected::part(paper-button) {
+      border-color: var(--vote-outline-disliked);
+    }
+    gr-button > gr-tooltip-content {
+      margin: 0px -10px;
+      padding: 0px 10px;
+    }
     .placeholder {
       display: inline-block;
       width: 42px;
@@ -145,13 +123,19 @@
         <gr-button
           role="radio"
           vote$="[[_computeVoteAttribute(value, index, _items.length)]]"
-          has-tooltip=""
+          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
           data-name$="[[label.name]]"
           data-value$="[[value]]"
-          title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
+          aria-label$="[[value]]"
+          voteChip
         >
-          [[value]]</gr-button
-        >
+          <gr-tooltip-content
+            has-tooltip
+            title$="[[_computeLabelValueTitle(labels, label.name, value)]]"
+          >
+            [[value]]
+          </gr-tooltip-content>
+        </gr-button>
       </template>
     </iron-selector>
     <template
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
index 3c9b7c7..51d76b2 100644
--- a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.js
@@ -23,7 +23,7 @@
 suite('gr-label-row-score tests', () => {
   let element;
 
-  setup(done => {
+  setup(async () => {
     element = basicFixture.instantiate();
     element.labels = {
       'Code-Review': {
@@ -80,7 +80,7 @@
       value: '+1',
     };
 
-    flush(done);
+    await flush();
   });
 
   function checkAriaCheckedValid() {
@@ -97,19 +97,16 @@
     }
   }
 
-  test('label picker', () => {
+  test('label picker', async () => {
     const labelsChangedHandler = sinon.stub();
     element.addEventListener('labels-changed', labelsChangedHandler);
     assert.ok(element.$.labelSelector);
-    MockInteractions.tap(element.shadowRoot
-        .querySelector(
-            'gr-button[data-value="-1"]'));
-    flush();
+    MockInteractions.tap(
+        element.shadowRoot.querySelector('gr-button[data-value="-1"]'));
+    await flush();
     assert.strictEqual(element.selectedValue, '-1');
-    assert.strictEqual(element.selectedItem
-        .textContent.trim(), '-1');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'bad');
+    assert.strictEqual(element.selectedItem.textContent.trim(), '-1');
+    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'bad');
     const detail = labelsChangedHandler.args[0][0].detail;
     assert.equal(detail.name, 'Verified');
     assert.equal(detail.value, '-1');
@@ -121,69 +118,48 @@
     let index = 0;
     const totalItems = 5;
     // positive and first position
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'positive');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'positive');
     // negative and first position
     value = -1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'min');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'min');
     // negative but not first position
     index = 1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'negative');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'negative');
     // neutral
     value = 0;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'neutral');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'neutral');
     // positive but not last position
     value = 1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'positive');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'positive');
     // positive and last position
     index = 4;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'max');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'max');
     // negative and last position
     value = -1;
-    assert.equal(element._computeVoteAttribute(value, index,
-        totalItems), 'negative');
+    assert.equal(
+        element._computeVoteAttribute(value, index, totalItems), 'negative');
   });
 
   test('correct item is selected', () => {
     // 1 should be the value of the selected item
     assert.strictEqual(element.$.labelSelector.selected, '+1');
     assert.strictEqual(
-        element.$.labelSelector.selectedItem
-            .textContent.trim(), '+1');
-    assert.strictEqual(
-        element.$.selectedValueLabel.textContent.trim(), 'good');
+        element.$.labelSelector.selectedItem.textContent.trim(), '+1');
+    assert.strictEqual(element.$.selectedValueLabel.textContent.trim(), 'good');
     checkAriaCheckedValid();
   });
 
-  test('do not display tooltips on touch devices', () => {
-    const verifiedBtn = element.shadowRoot
-        .querySelector(
-            'iron-selector > gr-button[data-value="-1"]');
-
-    // On touch devices, tooltips should not be shown.
-    verifiedBtn._isTouchDevice = true;
-    verifiedBtn._handleShowTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-    verifiedBtn._handleHideTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-
-    // On other devices, tooltips should be shown.
-    verifiedBtn._isTouchDevice = false;
-    verifiedBtn._handleShowTooltip();
-    assert.isOk(verifiedBtn._tooltip);
-    verifiedBtn._handleHideTooltip();
-    assert.isNotOk(verifiedBtn._tooltip);
-  });
-
   test('_computeLabelValue', () => {
-    assert.strictEqual(element._computeLabelValue(element.labels,
-        element.permittedLabels,
-        element.label), '+1');
+    assert.strictEqual(
+        element._computeLabelValue(
+            element.labels, element.permittedLabels, element.label),
+        '+1');
   });
 
   test('_computeBlankItems', () => {
@@ -195,21 +171,24 @@
       '2': 4,
     };
 
-    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-        'Code-Review').length, 0);
+    assert.strictEqual(
+        element._computeBlankItems(element.permittedLabels, 'Code-Review')
+            .length,
+        0);
 
-    assert.strictEqual(element._computeBlankItems(element.permittedLabels,
-        'Verified').length, 1);
+    assert.strictEqual(
+        element._computeBlankItems(element.permittedLabels, 'Verified').length,
+        1);
   });
 
   test('labelValues returns no keys', () => {
     element.labelValues = {};
 
-    assert.deepEqual(element._computeBlankItems(element.permittedLabels,
-        'Code-Review'), []);
+    assert.deepEqual(
+        element._computeBlankItems(element.permittedLabels, 'Code-Review'), []);
   });
 
-  test('changes in label score are reflected in the DOM', () => {
+  test('changes in label score are reflected in the DOM', async () => {
     element.labels = {
       'Code-Review': {
         values: {
@@ -232,16 +211,17 @@
         default_value: 0,
       },
     };
+    await flush();
     const selector = element.$.labelSelector;
     element.set('label', {name: 'Verified', value: ' 0'});
-    flush();
+    await flush();
     assert.strictEqual(selector.selected, ' 0');
     assert.strictEqual(
         element.$.selectedValueLabel.textContent.trim(), 'No score');
     checkAriaCheckedValid();
   });
 
-  test('without permitted labels', () => {
+  test('without permitted labels', async () => {
     element.permittedLabels = {
       Verified: [
         '-1',
@@ -249,22 +229,22 @@
         '+1',
       ],
     };
-    flush();
+    await flush();
     assert.isOk(element.$.labelSelector);
     assert.isFalse(element.$.labelSelector.hidden);
 
     element.permittedLabels = {};
-    flush();
+    await flush();
     assert.isOk(element.$.labelSelector);
     assert.isTrue(element.$.labelSelector.hidden);
 
     element.permittedLabels = {Verified: []};
-    flush();
+    await flush();
     assert.isOk(element.$.labelSelector);
     assert.isTrue(element.$.labelSelector.hidden);
   });
 
-  test('asymmetrical labels', done => {
+  test('asymmetrical labels', async () => {
     element.permittedLabels = {
       'Code-Review': [
         '-2',
@@ -278,35 +258,26 @@
         '+1',
       ],
     };
-    flush(() => {
-      assert.strictEqual(element.$.labelSelector
-          .items.length, 2);
-      assert.strictEqual(
-          element.root.querySelectorAll('.placeholder').length,
-          3);
+    await flush();
+    assert.strictEqual(element.$.labelSelector.items.length, 2);
+    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 3);
 
-      element.permittedLabels = {
-        'Code-Review': [
-          ' 0',
-          '+1',
-        ],
-        'Verified': [
-          '-2',
-          '-1',
-          ' 0',
-          '+1',
-          '+2',
-        ],
-      };
-      flush(() => {
-        assert.strictEqual(element.$.labelSelector
-            .items.length, 5);
-        assert.strictEqual(
-            element.root.querySelectorAll('.placeholder').length,
-            0);
-        done();
-      });
-    });
+    element.permittedLabels = {
+      'Code-Review': [
+        ' 0',
+        '+1',
+      ],
+      'Verified': [
+        '-2',
+        '-1',
+        ' 0',
+        '+1',
+        '+2',
+      ],
+    };
+    await flush();
+    assert.strictEqual(element.$.labelSelector.items.length, 5);
+    assert.strictEqual(element.root.querySelectorAll('.placeholder').length, 0);
   });
 
   test('default_value', () => {
@@ -366,4 +337,3 @@
     assert.isNull(element.selectedValue);
   });
 });
-
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 4147cc0..a496be5 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
@@ -16,9 +16,8 @@
  */
 import '../gr-label-score-row/gr-label-score-row';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-label-scores_html';
-import {customElement, property} from '@polymer/decorators';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {hasOwnProperty} from '../../../utils/common-util';
 import {
   LabelNameToValueMap,
@@ -33,21 +32,14 @@
   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';
 import {Execution} from '../../../constants/reporting';
+import {ChangeStatus} from '../../../constants/constants';
 
 @customElement('gr-label-scores')
-export class GrLabelScores extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array, computed: '_computeLabels(change.labels.*, account)'})
-  _labels: Label[] = [];
-
-  @property({type: Object, observer: '_computeColumns'})
+export class GrLabelScores extends LitElement {
+  @property({type: Object})
   permittedLabels?: LabelNameToValueMap;
 
   @property({type: Object})
@@ -56,11 +48,63 @@
   @property({type: Object})
   account?: AccountInfo;
 
-  @property({type: Object})
-  _labelValues?: LabelValuesMap;
-
   private readonly reporting = appContext.reportingService;
 
+  static override get styles() {
+    return [
+      css`
+        .scoresTable {
+          display: table;
+          width: 100%;
+        }
+        .mergedMessage,
+        .abandonedMessage {
+          font-style: italic;
+          text-align: center;
+          width: 100%;
+        }
+        gr-label-score-row:hover {
+          background-color: var(--hover-background-color);
+        }
+        gr-label-score-row {
+          display: table-row;
+        }
+        gr-label-score-row.no-access {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const labels = this._computeLabels();
+    const labelValues = this._computeColumns();
+    return html`<div class="scoresTable">
+        ${labels.map(
+          label => html`<gr-label-score-row
+            class="${this.computeLabelAccessClass(label.name)}"
+            .label="${label}"
+            .name="${label.name}"
+            .labels="${this.change?.labels}"
+            .permittedLabels="${this.permittedLabels}"
+            .labelValues="${labelValues}"
+          ></gr-label-score-row>`
+        )}
+      </div>
+      <div
+        class="mergedMessage"
+        ?hidden=${this.change?.status !== ChangeStatus.MERGED}
+      >
+        Because this change has been merged, votes may not be decreased.
+      </div>
+      <div
+        class="abandonedMessage"
+        ?hidden=${this.change?.status !== ChangeStatus.ABANDONED}
+      >
+        Because this change has been abandoned, you cannot vote.
+      </div>`;
+  }
+
   getLabelValues(includeDefaults = true): LabelNameToValuesMap {
     const labels: LabelNameToValuesMap = {};
     if (this.shadowRoot === null || !this.change) {
@@ -79,7 +123,7 @@
 
       if (selectedVal === undefined) continue;
 
-      const defValNum = this._getDefaultValue(this.change.labels, label);
+      const defValNum = this.getDefaultValue(label);
       if (includeDefaults || selectedVal !== defValNum) {
         labels[label] = selectedVal;
       }
@@ -87,7 +131,7 @@
     return labels;
   }
 
-  _getStringLabelValue(
+  private getStringLabelValue(
     labels: LabelNameToInfoMap,
     labelName: string,
     numberValue?: number
@@ -108,25 +152,26 @@
     return stringVal;
   }
 
-  _getDefaultValue(labels?: LabelNameToInfoMap, labelName?: string) {
+  private getDefaultValue(labelName?: string) {
+    const labels = this.change?.labels;
     if (!labelName || !labels?.[labelName]) return undefined;
     const labelInfo = labels[labelName] as DetailedLabelInfo;
     return labelInfo.default_value;
   }
 
-  _getVoteForAccount(
-    labels: LabelNameToInfoMap | undefined,
-    labelName: string,
-    account?: AccountInfo
-  ): string | null {
+  _getVoteForAccount(labelName: string): string | null {
+    const labels = this.change?.labels;
     if (!labels) return null;
     const votes = labels[labelName] as DetailedLabelInfo;
     if (votes.all && votes.all.length > 0) {
       for (let i = 0; i < votes.all.length; i++) {
-        // TODO(TS): Replace == with === and check code can assign string to _account_id instead of number
-        // eslint-disable-next-line eqeqeq
-        if (account && votes.all[i]._account_id == account._account_id) {
-          return this._getStringLabelValue(
+        if (
+          this.account &&
+          // TODO(TS): Replace == with === and check code can assign string to _account_id instead of number
+          // eslint-disable-next-line eqeqeq
+          votes.all[i]._account_id == this.account._account_id
+        ) {
+          return this.getStringLabelValue(
             labels,
             labelName,
             votes.all[i].value
@@ -137,32 +182,26 @@
     return null;
   }
 
-  _computeLabels(
-    labelRecord: PolymerDeepPropertyChange<
-      LabelNameToInfoMap,
-      LabelNameToInfoMap
-    >,
-    account?: AccountInfo
-  ): Label[] {
-    if (!account) return [];
-    if (!labelRecord?.base) return [];
-    const labelsObj = labelRecord.base;
+  _computeLabels(): Label[] {
+    if (!this.account) return [];
+    const labelsObj = this.change?.labels;
+    if (!labelsObj) return [];
     return Object.keys(labelsObj)
       .sort(labelCompare)
       .map(key => {
         return {
           name: key,
-          value: this._getVoteForAccount(labelsObj, key, this.account),
+          value: this._getVoteForAccount(key),
         };
       });
   }
 
-  _computeColumns(permittedLabels?: LabelNameToValueMap) {
-    if (!permittedLabels) return;
-    const labels = Object.keys(permittedLabels);
+  _computeColumns() {
+    if (!this.permittedLabels) return;
+    const labels = Object.keys(this.permittedLabels);
     const values: Set<number> = new Set();
     for (const label of labels) {
-      for (const value of permittedLabels[label]) {
+      for (const value of this.permittedLabels[label]) {
         values.add(Number(value));
       }
     }
@@ -173,23 +212,14 @@
     for (let i = 0; i < orderedValues.length; i++) {
       labelValues[orderedValues[i]] = i;
     }
-    this._labelValues = labelValues;
+    return labelValues;
   }
 
-  _changeIsMerged(changeStatus: string) {
-    return changeStatus === 'MERGED';
-  }
+  private computeLabelAccessClass(label?: string) {
+    if (!this.permittedLabels || !label) return '';
 
-  _computeLabelAccessClass(
-    label?: string,
-    permittedLabels?: LabelNameToValueMap
-  ) {
-    if (!permittedLabels || !label) {
-      return '';
-    }
-
-    return hasOwnProperty(permittedLabels, label) &&
-      permittedLabels[label].length
+    return hasOwnProperty(this.permittedLabels, label) &&
+      this.permittedLabels[label].length
       ? 'access'
       : 'no-access';
   }
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
deleted file mode 100644
index 7b1fb7f..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_html.ts
+++ /dev/null
@@ -1,55 +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">
-    .scoresTable {
-      display: table;
-      width: 100%;
-    }
-    .mergedMessage {
-      font-style: italic;
-      text-align: center;
-      width: 100%;
-    }
-    gr-label-score-row:hover {
-      background-color: var(--hover-background-color);
-    }
-    gr-label-score-row {
-      display: table-row;
-    }
-    gr-label-score-row.no-access {
-      display: none;
-    }
-  </style>
-  <div class="scoresTable">
-    <template is="dom-repeat" items="[[_labels]]" as="label">
-      <gr-label-score-row
-        class$="[[_computeLabelAccessClass(label.name, permittedLabels)]]"
-        label="[[label]]"
-        name="[[label.name]]"
-        labels="[[change.labels]]"
-        permitted-labels="[[permittedLabels]]"
-        label-values="[[_labelValues]]"
-      ></gr-label-score-row>
-    </template>
-  </div>
-  <div class="mergedMessage" hidden$="[[!_changeIsMerged(change.status)]]">
-    Because this change has been merged, votes may not be decreased.
-  </div>
-`;
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
deleted file mode 100644
index ef123c9..0000000
--- a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.js
+++ /dev/null
@@ -1,189 +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 './gr-label-scores.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-label-scores');
-
-suite('gr-label-scores tests', () => {
-  let element;
-
-  setup(done => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-    element.change = {
-      _number: '123',
-      labels: {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-          value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
-        },
-        'Verified': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-          value: 1,
-          all: [{
-            _account_id: 123,
-            value: 1,
-          }],
-        },
-      },
-    };
-
-    element.account = {
-      _account_id: 123,
-    };
-
-    element.permittedLabels = {
-      'Code-Review': [
-        '-2',
-        '-1',
-        ' 0',
-        '+1',
-        '+2',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-    flush(done);
-  });
-
-  test('get and set label scores', () => {
-    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,
-      'Verified': -1,
-    });
-  });
-
-  test('getLabelValues includeDefaults', async () => {
-    element.change = {
-      _number: '123',
-      labels: {
-        'Code-Review': {
-          values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
-          default_value: 0,
-        },
-      },
-    };
-    await flush();
-
-    assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
-    assert.deepEqual(element.getLabelValues(false), {});
-  });
-
-  test('_getVoteForAccount', () => {
-    const labelName = 'Code-Review';
-    assert.strictEqual(element._getVoteForAccount(
-        element.change.labels, labelName, element.account),
-    '+1');
-  });
-
-  test('_computeColumns', () => {
-    element._computeColumns(element.permittedLabels);
-    assert.deepEqual(element._labelValues, {
-      '-2': 0,
-      '-1': 1,
-      '0': 2,
-      '1': 3,
-      '2': 4,
-    });
-  });
-
-  test('_computeLabelAccessClass undefined case', () => {
-    assert.strictEqual(
-        element._computeLabelAccessClass(undefined, undefined), '');
-    assert.strictEqual(
-        element._computeLabelAccessClass('', undefined), '');
-    assert.strictEqual(
-        element._computeLabelAccessClass(undefined, {}), '');
-  });
-
-  test('_computeLabelAccessClass has access', () => {
-    assert.strictEqual(
-        element._computeLabelAccessClass('foo', {foo: ['']}), 'access');
-  });
-
-  test('_computeLabelAccessClass no access', () => {
-    assert.strictEqual(
-        element._computeLabelAccessClass('zap', {foo: ['']}), 'no-access');
-  });
-
-  test('changes in label score are reflected in _labels', () => {
-    element.change = {
-      _number: '123',
-      labels: {
-        'Code-Review': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-        'Verified': {
-          values: {
-            '0': 'No score',
-            '+1': 'good',
-            '+2': 'excellent',
-            '-1': 'bad',
-            '-2': 'terrible',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    assert.deepEqual(element._labels [
-        ({name: 'Code-Review', value: null}, {name: 'Verified', value: null})
-    ]);
-    element.set(['change', 'labels', 'Verified', 'all'],
-        [{_account_id: 123, value: 1}]);
-    assert.deepEqual(element._labels, [
-      {name: 'Code-Review', value: null},
-      {name: 'Verified', value: '+1'},
-    ]);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
new file mode 100644
index 0000000..f529464
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.ts
@@ -0,0 +1,219 @@
+/**
+ * @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';
+import './gr-label-scores';
+import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {GrLabelScores} from './gr-label-scores';
+import {AccountId} from '../../../types/common';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
+import {
+  createAccountWithId,
+  createChange,
+} from '../../../test/test-data-generators';
+import {ChangeStatus} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-label-scores');
+
+suite('gr-label-scores tests', () => {
+  const accountId = 123 as AccountId;
+  let element: GrLabelScores;
+
+  setup(async () => {
+    stubRestApi('getLoggedIn').resolves(false);
+    element = basicFixture.instantiate();
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [
+            {
+              _account_id: accountId,
+              value: 1,
+            },
+          ],
+        },
+        Verified: {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [
+            {
+              _account_id: accountId,
+              value: 1,
+            },
+          ],
+        },
+      },
+    };
+
+    element.account = createAccountWithId(accountId);
+
+    element.permittedLabels = {
+      'Code-Review': ['-2', '-1', ' 0', '+1', '+2'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+    await flush();
+  });
+
+  test('get and set label scores', async () => {
+    for (const label of Object.keys(element.permittedLabels!)) {
+      const row = queryAndAssert<GrLabelScoreRow>(
+        element,
+        'gr-label-score-row[name="' + label + '"]'
+      );
+      row.setSelectedValue('-1');
+    }
+    await flush();
+    assert.deepEqual(element.getLabelValues(), {
+      'Code-Review': -1,
+      Verified: -1,
+    });
+  });
+
+  test('getLabelValues includeDefaults', async () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          values: {'0': 'meh', '+1': 'good', '-1': 'bad'},
+          default_value: 0,
+        },
+      },
+    };
+    await flush();
+
+    assert.deepEqual(element.getLabelValues(true), {'Code-Review': 0});
+    assert.deepEqual(element.getLabelValues(false), {});
+  });
+
+  test('_getVoteForAccount', () => {
+    const labelName = 'Code-Review';
+    assert.strictEqual(element._getVoteForAccount(labelName), '+1');
+  });
+
+  test('_computeColumns', () => {
+    const labelValues = element._computeColumns();
+    assert.deepEqual(labelValues, {
+      '-2': 0,
+      '-1': 1,
+      '0': 2,
+      '1': 3,
+      '2': 4,
+    });
+  });
+
+  test('changes in label score are reflected in _labels', async () => {
+    const change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+        Verified: {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    element.change = change;
+    await flush();
+    let labels = element._computeLabels();
+    assert.deepEqual(labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: null},
+    ]);
+    element.change = {
+      ...change,
+      labels: {
+        ...change.labels,
+        Verified: {
+          ...change.labels.Verified,
+          all: [
+            {
+              _account_id: accountId,
+              value: 1,
+            },
+          ],
+        },
+      },
+    };
+    await flush();
+    labels = element._computeLabels();
+    assert.deepEqual(labels, [
+      {name: 'Code-Review', value: null},
+      {name: 'Verified', value: '+1'},
+    ]);
+  });
+  suite('message', () => {
+    test('shown when change is abandoned', async () => {
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.ABANDONED,
+      };
+      await flush();
+      assert.isFalse(isHidden(queryAndAssert(element, '.abandonedMessage')));
+      assert.isTrue(isHidden(queryAndAssert(element, '.mergedMessage')));
+    });
+    test('shown when change is merged', async () => {
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.MERGED,
+      };
+      await flush();
+      assert.isFalse(isHidden(queryAndAssert(element, '.mergedMessage')));
+      assert.isTrue(isHidden(queryAndAssert(element, '.abandonedMessage')));
+    });
+    test('do not show for new', async () => {
+      element.change = {
+        ...createChange(),
+        status: ChangeStatus.NEW,
+      };
+      await flush();
+      assert.isTrue(isHidden(queryAndAssert(element, '.mergedMessage')));
+      assert.isTrue(isHidden(queryAndAssert(element, '.abandonedMessage')));
+    });
+  });
+});
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 3ea9f68..95e4301 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.ts
@@ -22,7 +22,6 @@
 import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../shared/gr-formatted-text/gr-formatted-text';
 import '../../../styles/shared-styles';
-import '../../../styles/gr-voting-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-message_html';
 import {MessageTag, SpecialFilePath} from '../../../constants/constants';
@@ -51,12 +50,13 @@
   computeLatestPatchNum,
   computePredecessor,
 } from '../../../utils/patch-set-util';
-import {isServiceUser} from '../../../utils/account-util';
+import {isServiceUser, replaceTemplates} from '../../../utils/account-util';
 
 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/;
+const VOTE_RESET_TEXT = '0 (vote reset)';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -115,6 +115,9 @@
   @property({type: Object})
   message: ChangeMessage | undefined;
 
+  @property({type: Array})
+  commentThreads: CommentThread[] = [];
+
   @computed('message')
   get author() {
     return this.message?.author || this.message?.updated_by;
@@ -131,7 +134,7 @@
     reflectToAttribute: true,
     computed: '_computeIsHidden(hideAutomated, isAutomated)',
   })
-  hidden = false;
+  override hidden = false;
 
   @computed('message')
   get isAutomated() {
@@ -176,21 +179,26 @@
 
   @property({
     type: String,
-    computed: '_computeMessageContentExpanded(message.message, message.tag)',
+    computed:
+      '_computeMessageContentExpanded(_expanded, message.message,' +
+      ' message.accounts_in_message,' +
+      ' message.tag)',
   })
   _messageContentExpanded = '';
 
   @property({
     type: String,
     computed:
-      '_computeMessageContentCollapsed(message.message, message.tag,' +
-      ' message.commentThreads)',
+      '_computeMessageContentCollapsed(message.message,' +
+      ' message.accounts_in_message,' +
+      ' message.tag,' +
+      ' commentThreads)',
   })
   _messageContentCollapsed = '';
 
   @property({
     type: String,
-    computed: '_computeCommentCountText(message.commentThreads.length)',
+    computed: '_computeCommentCountText(commentThreads)',
   })
   _commentCountText = '';
 
@@ -201,7 +209,7 @@
     this.addEventListener('click', e => this._handleClick(e));
   }
 
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.restApiService.getConfig().then(config => {
       this.config = config;
@@ -223,16 +231,22 @@
     }
   }
 
-  _computeCommentCountText(threadsLength?: number) {
-    if (!threadsLength) {
+  _computeCommentCountText(commentThreads?: CommentThread[]) {
+    if (!commentThreads?.length) {
       return undefined;
     }
 
-    return pluralize(threadsLength, 'comment');
+    return pluralize(commentThreads.length, 'comment');
   }
 
-  _computeMessageContentExpanded(content?: string, tag?: ReviewInputTag) {
-    return this._computeMessageContent(true, content, tag);
+  _computeMessageContentExpanded(
+    expanded: boolean,
+    content?: string,
+    accountsInMessage?: AccountInfo[],
+    tag?: ReviewInputTag
+  ) {
+    if (!expanded) return '';
+    return this._computeMessageContent(true, content, accountsInMessage, tag);
   }
 
   _patchsetCommentSummary(commentThreads: CommentThread[] = []) {
@@ -261,10 +275,18 @@
 
   _computeMessageContentCollapsed(
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag,
     commentThreads?: CommentThread[]
   ) {
-    const summary = this._computeMessageContent(false, content, tag);
+    // Content is under text-overflow, so it's always shorten
+    const shortenedContent = content?.substring(0, 1000);
+    const summary = this._computeMessageContent(
+      false,
+      shortenedContent,
+      accountsInMessage,
+      tag
+    );
     if (summary || !commentThreads) return summary;
     return this._patchsetCommentSummary(commentThreads);
   }
@@ -319,11 +341,16 @@
   _computeMessageContent(
     isExpanded: boolean,
     content?: string,
+    accountsInMessage?: AccountInfo[],
     tag?: ReviewInputTag
   ) {
     if (!content) return '';
     const isNewPatchSet = this._isNewPatchsetTag(tag);
 
+    if (accountsInMessage) {
+      content = replaceTemplates(content, accountsInMessage, this.config);
+    }
+
     const lines = content.split('\n');
     const filteredLines = lines.filter(line => {
       if (!isExpanded && line.startsWith('>')) {
@@ -348,7 +375,10 @@
       //   Rebase messages (which have a ':newPatchSet' tag) should be kept on
       //   lines like this:
       //     Patch Set 27: Patch Set 26 was rebased
-      if (isNewPatchSet) {
+      // Only make this replacement if the line starts with Patch Set, since if
+      // it starts with "Uploaded patch set" (e.g for votes) we want to keep the
+      // "Uploaded patch set".
+      if (isNewPatchSet && line.startsWith('Patch Set')) {
         line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
       }
       return line;
@@ -435,7 +465,7 @@
       )
       .map(ms => {
         const label = ms?.[2];
-        const value = ms?.[1] === '-' ? 'removed' : ms?.[3];
+        const value = ms?.[1] === '-' ? VOTE_RESET_TEXT : ms?.[3];
         return {label, value};
       });
   }
@@ -448,7 +478,7 @@
     if (!score.value) {
       return '';
     }
-    if (score.value === 'removed') {
+    if (score.value.includes(VOTE_RESET_TEXT)) {
       return 'removed';
     }
     const classes = [];
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 4a41316..7f3e9de 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
@@ -17,10 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
       position: relative;
@@ -135,7 +132,7 @@
     }
     .dateContainer .patchsetDiffButton {
       margin-right: var(--spacing-m);
-      --padding: 0 var(--spacing-m);
+      --gr-button-padding: 0 var(--spacing-m);
     }
     span.date {
       color: var(--deemphasized-text-color);
@@ -148,6 +145,7 @@
       vertical-align: top;
     }
     .score {
+      box-sizing: border-box;
       border-radius: var(--border-radius);
       color: var(--vote-text-color);
       display: inline-block;
@@ -194,10 +192,12 @@
       padding-bottom: 1px;
       color: var(--vote-text-color);
     }
-    gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
+    gr-account-label::part(gr-account-label-text) {
+      font-weight: var(--font-weight-bold);
+    }
+    iron-icon {
+      --iron-icon-height: 20px;
+      --iron-icon-width: 20px;
     }
     @media screen and (max-width: 50em) {
       .expanded .content {
@@ -245,13 +245,13 @@
       <template is="dom-if" if="[[message.message]]">
         <div class="content messageContent">
           <div class="message hideOnOpen">[[_messageContentCollapsed]]</div>
-          <gr-formatted-text
-            no-trailing-margin=""
-            class="message hideOnCollapsed"
-            content="[[_messageContentExpanded]]"
-            config="[[_projectConfig.commentlinks]]"
-          ></gr-formatted-text>
           <template is="dom-if" if="[[_expanded]]">
+            <gr-formatted-text
+              noTrailingMargin
+              class="message hideOnCollapsed"
+              content="[[_messageContentExpanded]]"
+              config="[[_projectConfig.commentlinks]]"
+            ></gr-formatted-text>
             <template is="dom-if" if="[[_messageContentExpanded]]">
               <div
                 class="replyActionContainer"
@@ -281,11 +281,11 @@
             </template>
             <gr-thread-list
               change="[[change]]"
-              hidden$="[[!message.commentThreads.length]]"
-              threads="[[message.commentThreads]]"
+              hidden$="[[!commentThreads.length]]"
+              threads="[[commentThreads]]"
               change-num="[[changeNum]]"
               logged-in="[[_loggedIn]]"
-              hide-toggle-buttons
+              hide-dropdown
               show-comment-context
             >
             </gr-thread-list>
@@ -325,8 +325,8 @@
         <template is="dom-if" if="[[!message.id]]">
           <span class="date">
             <gr-date-formatter
-              has-tooltip=""
-              show-date-and-time=""
+              withTooltip
+              showDateAndTime
               date-str="[[message.date]]"
             ></gr-date-formatter>
           </span>
@@ -334,8 +334,8 @@
         <template is="dom-if" if="[[message.id]]">
           <span class="date" on-click="_handleAnchorClick">
             <gr-date-formatter
-              has-tooltip=""
-              show-date-and-time=""
+              withTooltip
+              showDateAndTime
               date-str="[[message.date]]"
             ></gr-date-formatter>
           </span>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
index b8f3c73..f87c4c3 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.ts
@@ -19,12 +19,14 @@
 import './gr-message';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
+  createAccountWithIdNameAndEmail,
   createChange,
   createChangeMessage,
   createComment,
   createRevisions,
 } from '../../../test/test-data-generators';
 import {
+  mockPromise,
   query,
   queryAll,
   queryAndAssert,
@@ -49,7 +51,7 @@
 } from '../../../types/events';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {CommentSide} from '../../../constants/constants';
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 
 const basicFixture = fixtureFromElement('gr-message');
 
@@ -57,13 +59,13 @@
   let element: GrMessage;
 
   suite('when admin and logged in', () => {
-    setup(done => {
+    setup(async () => {
       stubRestApi('getIsAdmin').returns(Promise.resolve(true));
       element = basicFixture.instantiate();
-      flush(done);
+      await flush();
     });
 
-    test('reply event', done => {
+    test('reply event', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -78,18 +80,20 @@
         expanded: true,
       };
 
+      const promise = mockPromise();
       element.addEventListener('reply', (e: CustomEvent<ReplyEventDetail>) => {
         assert.deepEqual(e.detail.message, element.message);
-        done();
+        promise.resolve();
       });
-      flush();
+      await flush();
       assert.isFalse(
         queryAndAssert<HTMLElement>(element, '.replyActionContainer').hidden
       );
       tap(queryAndAssert(element, '.replyBtn'));
+      await promise;
     });
 
-    test('can see delete button', () => {
+    test('can see delete button', async () => {
       element.message = {
         ...createChangeMessage(),
         id: '47c43261_55aa2c41' as ChangeMessageId,
@@ -104,11 +108,11 @@
         expanded: true,
       };
 
-      flush();
+      await flush();
       assert.isFalse(queryAndAssert<HTMLElement>(element, '.deleteBtn').hidden);
     });
 
-    test('delete change message', done => {
+    test('delete change message', async () => {
       element.changeNum = 314159 as NumericChangeId;
       element.message = {
         ...createChangeMessage(),
@@ -124,6 +128,7 @@
         expanded: true,
       };
 
+      const promise = mockPromise();
       element.addEventListener(
         'change-message-deleted',
         (e: CustomEvent<ChangeMessageDeletedEventDetail>) => {
@@ -131,14 +136,15 @@
           assert.isFalse(
             (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
           );
-          done();
+          promise.resolve();
         }
       );
-      flush();
+      await flush();
       tap(queryAndAssert(element, '.deleteBtn'));
       assert.isTrue(
         (queryAndAssert(element, '.deleteBtn') as GrButton).disabled
       );
+      await promise;
     });
 
     test('autogenerated prefix hiding', () => {
@@ -349,11 +355,21 @@
     suite('compute messages', () => {
       test('empty', () => {
         assert.equal(
-          element._computeMessageContent(true, '', '' as ReviewInputTag),
+          element._computeMessageContent(
+            true,
+            '',
+            undefined,
+            '' as ReviewInputTag
+          ),
           ''
         );
         assert.equal(
-          element._computeMessageContent(false, '', '' as ReviewInputTag),
+          element._computeMessageContent(
+            false,
+            '',
+            undefined,
+            '' as ReviewInputTag
+          ),
           ''
         );
       });
@@ -361,13 +377,13 @@
       test('new patchset', () => {
         const original = 'Uploaded patch set 1.';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
         assert.equal(actual, original);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, original);
       });
 
@@ -375,13 +391,13 @@
         const original = 'Patch Set 27: Patch Set 26 was rebased';
         const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
         const expected = 'Patch Set 26 was rebased';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -389,23 +405,31 @@
         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(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
         assert.equal(
           actual,
-          element._computeMessageContentCollapsed(original, tag, [])
+          element._computeMessageContentCollapsed(original, [], tag, [])
         );
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
-
+      test('new patchset with vote', () => {
+        const original = 'Uploaded patch set 2: Code-Review+1';
+        const tag = 'autogenerated:gerrit:newPatchSet' as ReviewInputTag;
+        const expected = 'Uploaded patch set 2: Code-Review+1';
+        let actual = element._computeMessageContent(true, original, [], tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, [], tag);
+        assert.equal(actual, expected);
+      });
       test('vote', () => {
         const original = 'Patch Set 1: Code-Style+1';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
 
@@ -413,9 +437,47 @@
         const original = 'Patch Set 1:\n\n(3 comments)';
         const tag = undefined;
         const expected = '';
-        let actual = element._computeMessageContent(true, original, tag);
+        let actual = element._computeMessageContent(true, original, [], tag);
         assert.equal(actual, expected);
-        actual = element._computeMessageContent(false, original, tag);
+        actual = element._computeMessageContent(false, original, [], tag);
+        assert.equal(actual, expected);
+      });
+
+      test('message template', () => {
+        const original =
+          'Removed vote: \n\n * Code-Style+1 by <GERRIT_ACCOUNT_0000001>\n * Code-Style-1 by <GERRIT_ACCOUNT_0000002>';
+        const tag = undefined;
+        const expected =
+          'Removed vote: \n\n * Code-Style+1 by User-1\n * Code-Style-1 by User-2';
+        const accountsInMessage = [
+          createAccountWithIdNameAndEmail(1),
+          createAccountWithIdNameAndEmail(2),
+        ];
+        let actual = element._computeMessageContent(
+          true,
+          original,
+          accountsInMessage,
+          tag
+        );
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(
+          false,
+          original,
+          accountsInMessage,
+          tag
+        );
+        assert.equal(actual, expected);
+      });
+
+      test('message template missing accounts', () => {
+        const original =
+          'Removed vote: \n\n * Code-Style+1 by <GERRIT_ACCOUNT_0000001>\n * Code-Style-1 by <GERRIT_ACCOUNT_0000002>';
+        const tag = undefined;
+        const expected =
+          'Removed vote: \n\n * Code-Style+1 by Gerrit Account 1\n * Code-Style-1 by Gerrit Account 2';
+        let actual = element._computeMessageContent(true, original, [], tag);
+        assert.equal(actual, expected);
+        actual = element._computeMessageContent(false, original, [], tag);
         assert.equal(actual, expected);
       });
     });
@@ -508,11 +570,11 @@
   });
 
   suite('when not logged in', () => {
-    setup(done => {
+    setup(async () => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
       stubRestApi('getIsAdmin').returns(Promise.resolve(false));
       element = basicFixture.instantiate();
-      flush(done);
+      await flush();
     });
 
     test('reply and delete button should be hidden', () => {
@@ -553,7 +615,8 @@
           comments: [
             {
               ...createComment(),
-              change_message_id: '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
+              change_message_id:
+                '6a07f64a82f96e7337ca5f7f84cfc73abf8ac2a3' as ChangeMessageId,
               patch_set: 1 as PatchSetNum,
               id: 'e365b138_bed65caa' as UrlEncodedCommentId,
               updated: '2020-05-15 13:35:56.000000000' as Timestamp,
@@ -570,10 +633,18 @@
         },
       ];
       assert.equal(
-        element._computeMessageContentCollapsed('', undefined, threads),
+        element._computeMessageContentCollapsed(
+          '',
+          undefined,
+          undefined,
+          threads
+        ),
         'testing the load'
       );
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
+      assert.equal(
+        element._computeMessageContent(false, '', undefined, undefined),
+        ''
+      );
     });
 
     test('single patchset comment with reply', () => {
@@ -610,10 +681,18 @@
         },
       ];
       assert.equal(
-        element._computeMessageContentCollapsed('', undefined, threads),
+        element._computeMessageContentCollapsed(
+          '',
+          undefined,
+          undefined,
+          threads
+        ),
         'n'
       );
-      assert.equal(element._computeMessageContent(false, '', undefined), '');
+      assert.equal(
+        element._computeMessageContent(false, '', undefined, undefined),
+        ''
+      );
     });
   });
 
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 6be0c07..e16c073 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
@@ -22,7 +22,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-messages-list_html';
 import {
-  KeyboardShortcutMixin,
   Shortcut,
   ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
@@ -90,27 +89,19 @@
  */
 function computeThreads(
   message: CombinedMessage,
-  changeComments: ChangeComments
+  allThreadsForChange: CommentThread[]
 ): CommentThread[] {
   if (message._index === undefined) {
     return [];
   }
   const messageId = getMessageId(message);
-  return changeComments.getAllThreadsForChange().filter(thread =>
-    thread.comments
-      .map(comment => {
-        // collapse all by default
-        comment.collapsed = true;
-        return comment;
-      })
-      .some(comment => {
-        const condition = comment.change_message_id === messageId;
-        // Since getAllThreadsForChange() always returns a new copy of
-        // all comments we can modify them here without worrying about
-        // polluting other threads.
-        comment.collapsed = !condition;
-        return condition;
-      })
+  return allThreadsForChange.filter(thread =>
+    thread.comments.some(comment => {
+      const matchesMessage = comment.change_message_id === messageId;
+      if (!matchesMessage) return false;
+      comment.collapsed = !matchesMessage;
+      return matchesMessage;
+    })
   );
 }
 
@@ -198,7 +189,6 @@
 }
 
 export const TEST_ONLY = {
-  computeThreads,
   computeTag,
   computeRevision,
   computeIsImportant,
@@ -211,7 +201,7 @@
 }
 
 @customElement('gr-messages-list')
-export class GrMessagesList extends KeyboardShortcutMixin(PolymerElement) {
+export class GrMessagesList extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -263,6 +253,8 @@
 
   private readonly reporting = appContext.reportingService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   scrollToMessage(messageID: string) {
     const selector = `[data-message-id="${messageID}"]`;
     const el = this.shadowRoot!.querySelector(selector) as
@@ -270,7 +262,9 @@
       | undefined;
 
     if (!el && this._showAllActivity) {
-      console.warn(`Failed to scroll to message: ${messageID}`);
+      this.reporting.error(
+        new Error(`Failed to scroll to message: ${messageID}`)
+      );
       return;
     }
     if (!el) {
@@ -350,14 +344,24 @@
         mDate = null;
       }
     }
-    combinedMessages.forEach(m => {
-      if (m.expanded === undefined) {
-        m.expanded = false;
+
+    const allThreadsForChange = changeComments.getAllThreadsForChange();
+    // collapse all by default
+    for (const thread of allThreadsForChange) {
+      for (const comment of thread.comments) {
+        comment.collapsed = true;
       }
-      m.commentThreads = computeThreads(m, changeComments);
-      m._revision_number = computeRevision(m, combinedMessages);
-      m.tag = computeTag(m);
-    });
+    }
+
+    for (let i = 0; i < combinedMessages.length; i++) {
+      const message = combinedMessages[i];
+      if (message.expanded === undefined) {
+        message.expanded = false;
+      }
+      message.commentThreads = computeThreads(message, allThreadsForChange);
+      message._revision_number = computeRevision(message, combinedMessages);
+      message.tag = computeTag(message);
+    }
     // computeIsImportant() depends on tags and revision numbers already being
     // updated for all messages, so we have to compute this in its own forEach
     // loop.
@@ -378,13 +382,13 @@
 
   _computeExpandAllTitle(_expandAllState?: string) {
     if (_expandAllState === ExpandAllState.COLLAPSE_ALL) {
-      return this.createTitle(
+      return this.shortcuts.createTitle(
         Shortcut.COLLAPSE_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
     }
     if (_expandAllState === ExpandAllState.EXPAND_ALL) {
-      return this.createTitle(
+      return this.shortcuts.createTitle(
         Shortcut.EXPAND_ALL_MESSAGES,
         ShortcutSection.ACTIONS
       );
@@ -432,24 +436,26 @@
   }
 
   /**
-   * This method is for reporting stats only.
+   * Called when this._combinedMessages has changed.
    */
   _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
-    if (combinedMessages) {
-      if (combinedMessages.length === 0) return;
-      const tags = combinedMessages.map(
-        message =>
-          message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
-      );
-      const tagsCounted = tags.reduce(
-        (acc, val) => {
-          acc[val] = (acc[val] || 0) + 1;
-          return acc;
-        },
-        {all: combinedMessages.length} as TagsCountReportInfo
-      );
-      this.reporting.reportInteraction('messages-count', tagsCounted);
+    if (!combinedMessages) return;
+    if (combinedMessages.length === 0) return;
+    for (let i = 0; i < combinedMessages.length; i++) {
+      this.notifyPath(`_combinedMessages.${i}.commentThreads`);
     }
+    const tags = combinedMessages.map(
+      message =>
+        message.tag || (message as FormattedReviewerUpdateInfo).type || 'none'
+    );
+    const tagsCounted = tags.reduce(
+      (acc, val) => {
+        acc[val] = (acc[val] || 0) + 1;
+        return acc;
+      },
+      {all: combinedMessages.length} as TagsCountReportInfo
+    );
+    this.reporting.reportInteraction('messages-count', tagsCounted);
   }
 
   /**
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 8fa8eab..56fae87 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
@@ -93,6 +93,7 @@
       change="[[change]]"
       change-num="[[changeNum]]"
       message="[[message]]"
+      comment-threads="[[message.commentThreads]]"
       project-name="[[projectName]]"
       show-reply-button="[[showReplyButtons]]"
       on-message-anchor-tap="_handleAnchorClick"
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 fd45eec..0939daa 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
@@ -23,6 +23,7 @@
 import {MessageTag} from '../../../constants/constants.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api.js';
 
 createCommentApiMockWithTemplateElement(
     'gr-messages-list-comment-mock-api', html`
@@ -143,11 +144,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
+      element.changeComments = new ChangeComments(comments);
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('expand/collapse all', () => {
@@ -454,12 +453,9 @@
       // comment API.
       commentApiWrapper = basicFixture.instantiate();
       element = commentApiWrapper.$.messagesList;
-      sinon.spy(commentApiWrapper.$.commentAPI, 'loadAll');
+      element.changeComments = new ChangeComments();
       element.messages = messages;
-
-      // Stub methods on the changeComments object after changeComments has
-      // been initialized.
-      return commentApiWrapper.loadComments();
+      flush();
     });
 
     test('hide autogenerated button is not hidden', () => {
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
index 49c2486..e4703df 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-change.ts
@@ -14,9 +14,8 @@
  * 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 {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   ChangeInfo,
@@ -25,9 +24,10 @@
 } from '../../../types/common';
 import {ChangeStatus} from '../../../constants/constants';
 import {isChangeInfo} from '../../../utils/change-util';
+import {ifDefined} from 'lit/directives/if-defined';
 
 @customElement('gr-related-change')
-export class GrRelatedChange extends GrLitElement {
+export class GrRelatedChange extends LitElement {
   @property()
   change?: ChangeInfo | RelatedChangeAndCommitInfo;
 
@@ -47,13 +47,14 @@
   @property()
   connectedRevisions?: CommitId[];
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
       css`
         a {
           display: block;
         }
+        :host,
         .changeContainer,
         a {
           max-width: 100%;
@@ -103,13 +104,13 @@
     ];
   }
 
-  render() {
+  override render() {
     const change = this.change;
     if (!change) throw new Error('Missing change');
     const linkClass = this._computeLinkClass(change);
     return html`
       <div class="changeContainer">
-        <a href="${this.href}" class="${linkClass}"><slot></slot></a>
+        <a href="${ifDefined(this.href)}" class="${linkClass}"><slot></slot></a>
         ${this.showSubmittableCheck
           ? html`<span
               tabindex="-1"
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 8d5bc33..7493e2f 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,20 +14,13 @@
  * 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,
-  TemplateResult,
-} from 'lit-element';
+import {classMap} from 'lit/directives/class-map';
+import {LitElement, css, html, nothing, TemplateResult} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {
   SubmittedTogetherInfo,
@@ -46,6 +39,8 @@
   getRevisionKey,
   isChangeInfo,
 } from '../../../utils/change-util';
+import {Interaction} from '../../../constants/reporting';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 /** What is the maximum number of shown changes in collapsed list? */
 const DEFALT_NUM_CHANGES_WHEN_COLLAPSED = 3;
@@ -66,7 +61,7 @@
 }
 
 @customElement('gr-related-changes-list')
-export class GrRelatedChangesList extends GrLitElement {
+export class GrRelatedChangesList extends LitElement {
   @property()
   change?: ParsedChangeInfo;
 
@@ -76,27 +71,27 @@
   @property()
   mergeable?: boolean;
 
-  @internalProperty()
+  @state()
   submittedTogether?: SubmittedTogetherInfo = {
     changes: [],
     non_visible_changes: 0,
   };
 
-  @internalProperty()
+  @state()
   relatedChanges: RelatedChangeAndCommitInfo[] = [];
 
-  @internalProperty()
+  @state()
   conflictingChanges: ChangeInfo[] = [];
 
-  @internalProperty()
+  @state()
   cherryPickChanges: ChangeInfo[] = [];
 
-  @internalProperty()
+  @state()
   sameTopicChanges: ChangeInfo[] = [];
 
   private readonly restApiService = appContext.restApiService;
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
       css`
@@ -107,21 +102,43 @@
         section {
           margin-bottom: var(--spacing-l);
         }
-        gr-related-change {
+        .relatedChangeLine {
           display: flex;
+          visibility: visible;
+          height: auto;
         }
-        .marker {
-          position: absolute;
-          margin-left: calc(-1 * var(--spacing-s));
+        .marker.arrow {
+          visibility: hidden;
+          min-width: 20px;
         }
-        .arrowToCurrentChange {
-          position: absolute;
+        .marker.arrowToCurrentChange {
+          min-width: 20px;
+          text-align: center;
+        }
+        .marker.space {
+          height: 1px;
+          min-width: 20px;
+        }
+        gr-related-collapse[collapsed] .marker.arrow {
+          visibility: visible;
+          min-width: auto;
+        }
+        gr-related-collapse[collapsed] .relatedChangeLine.show-when-collapsed {
+          visibility: visible;
+          height: auto;
+        }
+        /* keep width, so width of section and position of show all button
+         * are set according to width of all (even hidden) elements
+         */
+        gr-related-collapse[collapsed] .relatedChangeLine {
+          visibility: hidden;
+          height: 0px;
         }
       `,
     ];
   }
 
-  render() {
+  override render() {
     const sectionSize = this.sectionSizeFactory(
       this.relatedChanges.length,
       this.submittedTogether?.changes.length || 0,
@@ -157,13 +174,16 @@
       >
         ${this.relatedChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']:
+                  relatedChangesMarkersPredicate(index).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 relatedChangesMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: relatedChangesMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .connectedRevisions="${connectedRevisions}"
                 .href="${change?._change_number
@@ -175,7 +195,8 @@
                   : ''}"
                 .showChangeStatus=${true}
                 >${change.commit.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -208,14 +229,16 @@
       >
         ${submittedTogetherChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']:
+                  submittedTogetherMarkersPredicate(index).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 submittedTogetherMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: submittedTogetherMarkersPredicate(
-                    index
-                  ).showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
@@ -224,7 +247,8 @@
                 .showSubmittableCheck=${true}
                 >${change.project}: ${change.branch}:
                 ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
       <div class="note" ?hidden=${!countNonVisibleChanges}>
@@ -252,13 +276,16 @@
       >
         ${this.sameTopicChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']:
+                  sameTopicMarkersPredicate(index).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 sameTopicMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: sameTopicMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
@@ -266,7 +293,8 @@
                 )}"
                 >${change.project}: ${change.branch}:
                 ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -291,20 +319,24 @@
       >
         ${this.conflictingChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']:
+                  mergeConflictsMarkersPredicate(index).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 mergeConflictsMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: mergeConflictsMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 >${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -329,20 +361,24 @@
       >
         ${this.cherryPickChanges.map(
           (change, index) =>
-            html`${this.renderMarkers(
+            html`<div
+              class="${classMap({
+                ['relatedChangeLine']: true,
+                ['show-when-collapsed']:
+                  cherryPicksMarkersPredicate(index).showWhenCollapsed,
+              })}"
+            >
+              ${this.renderMarkers(
                 cherryPicksMarkersPredicate(index)
               )}<gr-related-change
-                class="${classMap({
-                  ['show-when-collapsed']: cherryPicksMarkersPredicate(index)
-                    .showWhenCollapsed,
-                })}"
                 .change="${change}"
                 .href="${GerritNav.getUrlForChangeById(
                   change._number,
                   change.project
                 )}"
                 >${change.branch}: ${change.subject}</gr-related-change
-              >`
+              >
+            </div>`
         )}
       </gr-related-collapse>
     </section>`;
@@ -471,7 +507,7 @@
     if (changeMarkers.showCurrentChangeArrow) {
       return html`<span
         role="img"
-        class="arrowToCurrentChange"
+        class="marker arrowToCurrentChange"
         aria-label="Arrow marking current change"
         >➔</span
       >`;
@@ -479,7 +515,7 @@
     if (changeMarkers.showTopArrow) {
       return html`<span
         role="img"
-        class="marker"
+        class="marker arrow"
         aria-label="Arrow marking change has collapsed ancestors"
         ><iron-icon icon="gr-icons:arrowDropUp"></iron-icon
       ></span> `;
@@ -487,12 +523,12 @@
     if (changeMarkers.showBottomArrow) {
       return html`<span
         role="img"
-        class="marker"
+        class="marker arrow"
         aria-label="Arrow marking change has collapsed descendants"
         ><iron-icon icon="gr-icons:arrowDropDown"></iron-icon
       ></span> `;
     }
-    return nothing;
+    return html`<span class="marker space"></span>`;
   }
 
   reload(getRelatedChanges?: Promise<RelatedChangesInfo | undefined>) {
@@ -630,13 +666,16 @@
 }
 
 @customElement('gr-related-collapse')
-export class GrRelatedCollapse extends GrLitElement {
+export class GrRelatedCollapse extends LitElement {
   @property()
-  title = '';
+  override title = '';
 
-  @property()
+  @property({type: Boolean})
   showAll = false;
 
+  @property({type: Boolean, reflect: true})
+  collapsed = true;
+
   @property()
   length = 0;
 
@@ -645,53 +684,20 @@
 
   private readonly reporting = appContext.reportingService;
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
+      fontStyles,
       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;
+          margin-left: 20px;
         }
         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) {
-          visibility: visible;
-          height: auto;
-        }
-        .collapsed ::slotted(.marker) {
-          display: block;
-        }
-        .show-all ::slotted(.marker) {
-          display: none;
-        }
-        /* keep width, so width of section and position of show all button
-         * are set according to width of all (even hidden) elements
-         */
-        .collapsed ::slotted(gr-related-change) {
-          visibility: hidden;
-          height: 0px;
-        }
-        ::slotted(gr-related-change) {
-          visibility: visible;
-          height: auto;
-        }
         gr-button iron-icon {
           color: inherit;
           --iron-icon-height: 18px;
@@ -709,15 +715,11 @@
     ];
   }
 
-  render() {
-    const title = html`<h4 class="title">${this.title}</h4>`;
+  override render() {
+    const title = html`<h3 class="title heading-3">${this.title}</h3>`;
 
     const collapsible = this.length > this.numChangesWhenCollapsed;
-    const items = html` <div
-      class="${!this.showAll && collapsible ? 'collapsed' : 'show-all'}"
-    >
-      <slot></slot>
-    </div>`;
+    this.collapsed = !this.showAll && collapsible;
 
     let button: TemplateResult | typeof nothing = nothing;
     if (collapsible) {
@@ -733,13 +735,13 @@
     }
 
     return html`<div class="container">${title}${button}</div>
-      ${items}`;
+      <div><slot></slot></div>`;
   }
 
   private toggle(e: MouseEvent) {
     e.stopPropagation();
     this.showAll = !this.showAll;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: this.title,
       toState: this.showAll ? 'Show all' : 'Show less',
     });
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
index ae9af4a..a6dc338f 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 import {PluginApi} from '../../../api/plugin';
 import {ChangeStatus} from '../../../constants/constants';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
@@ -74,9 +74,9 @@
         v: boolean;
       }>
     ) {
-      return instructions
-        .map(inst => Array.from({length: inst.len}, () => inst.v))
-        .reduce((acc, val) => acc.concat(val), []);
+      return instructions.flatMap(inst =>
+        Array.from({length: inst.len}, () => inst.v)
+      );
     }
 
     function checkShowWhenCollapsed(
@@ -599,7 +599,7 @@
       resetPlugins();
     });
 
-    test('endpoint params', done => {
+    test('endpoint params', async () => {
       element.change = {...createParsedChange(), labels: {}};
       interface RelatedChangesListGrEndpointDecorator
         extends GrEndpointDecorator {
@@ -620,11 +620,9 @@
         'http://some/plugins/url1.js'
       );
       getPluginLoader().loadPlugins([]);
-      flush(() => {
-        assert.strictEqual(hookEl.plugin, plugin);
-        assert.strictEqual(hookEl.change, element.change);
-        done();
-      });
+      await flush();
+      assert.strictEqual(hookEl!.plugin, plugin!);
+      assert.strictEqual(hookEl!.change, element.change);
     });
   });
 });
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 5f35fd3..486f37a 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
@@ -16,10 +16,11 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import './gr-reply-dialog.js';
-import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
+
+import {queryAndAssert, resetPlugins, stubRestApi} from '../../../test/test-utils.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader.js';
 
 const basicFixture = fixtureFromElement('gr-reply-dialog');
 const pluginApi = _testOnly_initGerritPluginApi();
@@ -87,19 +88,14 @@
 
   test('_submit blocked when invalid email is supplied to ccs', () => {
     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.$.ccs.$.entry.setText('test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
+    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
     assert.isFalse(sendStub.called);
     flush();
 
     element.$.ccs.$.entry.setText('test@test.test');
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
+    MockInteractions.tap(element.shadowRoot.querySelector('gr-button.send'));
     assert.isTrue(sendStub.called);
   });
 
@@ -110,9 +106,7 @@
       replyApi.addReplyTextChangedCallback(text => {
         const label = 'Code-Review';
         const labelValue = replyApi.getLabelValue(label);
-        if (labelValue &&
-            labelValue === ' 0' &&
-            text.indexOf('LGTM') === 0) {
+        if (labelValue && labelValue === ' 0' && text.indexOf('LGTM') === 0) {
           replyApi.setLabelValue(label, '+1');
         }
       });
@@ -122,15 +116,15 @@
     getPluginLoader().loadPlugins([]);
     await getPluginLoader().awaitPluginsLoaded();
     await flush();
-    const textarea = element.$.textarea.getNativeTextarea();
+    const textarea = queryAndAssert(element, 'gr-textarea').getNativeTextarea();
     textarea.value = 'LGTM';
-    textarea.dispatchEvent(new CustomEvent(
-        'input', {bubbles: true, composed: true}));
+    textarea.dispatchEvent(
+        new CustomEvent('input', {bubbles: true, composed: true}));
     await flush();
-    const labelScoreRows = element.$.labelScores.shadowRoot
-        .querySelector('gr-label-score-row[name="Code-Review"]');
-    const selectedBtn = labelScoreRows.shadowRoot
-        .querySelector('gr-button[data-value="+1"].iron-selected');
+    const labelScoreRows = element.getLabelScores().shadowRoot.querySelector(
+        'gr-label-score-row[name="Code-Review"]');
+    const selectedBtn =
+        labelScoreRows.shadowRoot.querySelector('gr-button[data-value="+1"]');
     assert.isOk(selectedBtn);
   });
 });
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 2cabf90..b8932e5 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
@@ -38,10 +38,12 @@
   ReviewerState,
   SpecialFilePath,
 } from '../../../constants/constants';
-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 {
+  accountOrGroupKey,
+  isReviewerOrCC,
+  mapReviewer,
+  removeServiceUsers,
+} from '../../../utils/account-util';
 import {IronA11yAnnouncer} from '@polymer/iron-a11y-announcer/iron-a11y-announcer';
 import {TargetElement} from '../../../api/plugin';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -65,7 +67,6 @@
   GroupInfo,
   isAccount,
   isDetailedLabelInfo,
-  isGroup,
   isReviewerAccountSuggestion,
   isReviewerGroupSuggestion,
   LabelNameToValueMap,
@@ -84,36 +85,37 @@
 import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
 import {
   PolymerDeepPropertyChange,
-  PolymerSplice,
   PolymerSpliceChange,
 } from '@polymer/polymer/interfaces';
 import {
   areSetsEqual,
   assertIsDefined,
-  assertNever,
   containsAll,
+  queryAndAssert,
 } from '../../../utils/common-util';
 import {CommentThread, isUnresolved} from '../../../utils/comment-util';
 import {GrTextarea} from '../../shared/gr-textarea/gr-textarea';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
-import {isAttentionSetEnabled} from '../../../utils/attention-set-util';
 import {
-  CODE_REVIEW,
   getApprovalInfo,
   getMaxAccounts,
+  StandardLabels,
 } from '../../../utils/label-util';
 import {pluralize} from '../../../utils/string-util';
 import {
   fireAlert,
   fireEvent,
   fireIronAnnounce,
+  fireReload,
   fireServerError,
 } from '../../../utils/event-util';
 import {ErrorCallback} from '../../../api/rest';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
-import {Timing} from '../../../constants/reporting';
+import {Interaction, Timing} from '../../../constants/reporting';
+import {getReplyByReason} from '../../../utils/attention-set-util';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
@@ -149,15 +151,6 @@
 
 const EMPTY_REPLY_MESSAGE = 'Cannot send an empty reply.';
 
-interface PendingRemovals {
-  CC: (AccountInfoInput | GroupInfoInput)[];
-  REVIEWER: (AccountInfoInput | GroupInfoInput)[];
-}
-const PENDING_REMOVAL_KEYS: (keyof PendingRemovals)[] = [
-  ReviewerType.CC,
-  ReviewerType.REVIEWER,
-];
-
 export interface GrReplyDialog {
   $: {
     reviewers: GrAccountList;
@@ -171,7 +164,7 @@
 }
 
 @customElement('gr-reply-dialog')
-export class GrReplyDialog extends KeyboardShortcutMixin(PolymerElement) {
+export class GrReplyDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -222,9 +215,9 @@
 
   FocusTarget = FocusTarget;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
 
-  flagsService = appContext.flagsService;
+  private readonly changeService = appContext.changeService;
 
   @property({type: Object})
   change?: ChangeInfo;
@@ -310,12 +303,6 @@
   @property({type: Boolean, observer: '_handleHeightChanged'})
   _previewFormatting = false;
 
-  @property({type: Object})
-  _reviewersPendingRemove: PendingRemovals = {
-    CC: [],
-    REVIEWER: [],
-  };
-
   @property({type: String, computed: '_computeSendButtonLabel(canBeStarted)'})
   _sendButtonLabel?: string;
 
@@ -378,29 +365,36 @@
 
   private storeTask?: DelayedTask;
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-      'ctrl+enter meta+enter': '_handleEnterKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   constructor() {
     super();
-    this.filterReviewerSuggestion = this._filterReviewerSuggestionGenerator(
-      false
-    );
+    this.filterReviewerSuggestion =
+      this._filterReviewerSuggestionGenerator(false);
     this.filterCCSuggestion = this._filterReviewerSuggestionGenerator(true);
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
-    ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
     this._getAccount().then(account => {
       if (account) this._account = account;
     });
 
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]}, _ =>
+        this._submit()
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER, modifiers: [Modifier.META_KEY]}, _ =>
+        this._submit()
+      )
+    );
+    this.cleanups.push(addShortcut(this, {key: Key.ESC}, _ => this.cancel()));
     this.addEventListener('comment-editing-changed', e => {
       this._commentEditing = (e as CustomEvent).detail;
     });
@@ -421,22 +415,22 @@
     });
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this.jsAPI.addElement(TargetElement.REPLY_DIALOG, this);
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.storeTask?.cancel();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
   open(focusTarget?: FocusTarget) {
     assertIsDefined(this.change, 'change');
     this.knownLatestState = LatestPatchState.CHECKING;
-    fetchChangeUpdates(this.change, this.restApiService).then(result => {
+    this.changeService.fetchChangeUpdates(this.change).then(result => {
       this.knownLatestState = result.isLatest
         ? LatestPatchState.LATEST
         : LatestPatchState.NOT_LATEST;
@@ -471,7 +465,7 @@
     return draft.length > 0 || draftCommentThreads.base.length > 0;
   }
 
-  focus() {
+  override focus() {
     this._focusOn(FocusTarget.ANY);
   }
 
@@ -484,7 +478,7 @@
   }
 
   setLabelValue(label: string, value: string) {
-    const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
       `gr-label-score-row[name="${label}"]`
     );
     if (!selectorEl) {
@@ -494,7 +488,7 @@
   }
 
   getLabelValue(label: string) {
-    const selectorEl = this.$.labelScores.shadowRoot?.querySelector(
+    const selectorEl = this.getLabelScores().shadowRoot?.querySelector(
       `gr-label-score-row[name="${label}"]`
     );
     if (!selectorEl) {
@@ -504,31 +498,22 @@
     return (selectorEl as GrLabelScoreRow).selectedValue;
   }
 
-  _handleEscKey() {
-    this.cancel();
-  }
-
-  _handleEnterKey() {
-    this._submit();
-  }
-
   @observe('_ccs.splices')
-  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+  _ccsChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.CC);
   }
 
   @observe('_reviewers.splices')
-  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[]>) {
+  _reviewersChanged(splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>) {
     this._reviewerTypeChanged(splices, ReviewerType.REVIEWER);
   }
 
   _reviewerTypeChanged(
-    splices: PolymerSpliceChange<AccountInfo[]>,
+    splices: PolymerSpliceChange<AccountInfo[] | GroupInfo[]>,
     reviewerType: ReviewerType
   ) {
     if (splices && splices.indexSplices) {
       this._reviewersMutated = true;
-      this._processReviewerChange(splices.indexSplices, reviewerType);
       let key: AccountId | EmailAddress | GroupId | undefined;
       let index;
       let account;
@@ -538,16 +523,16 @@
       for (const splice of splices.indexSplices) {
         for (let i = 0; i < splice.addedCount; i++) {
           account = splice.object[splice.index + i];
-          key = this._accountOrGroupKey(account);
+          key = accountOrGroupKey(account);
           const array = isReviewer ? this._ccs : this._reviewers;
           index = array.findIndex(
-            account => this._accountOrGroupKey(account) === key
+            account => accountOrGroupKey(account) === key
           );
           if (index >= 0) {
             this.splice(isReviewer ? '_ccs' : '_reviewers', index, 1);
             const moveFrom = isReviewer ? 'CC' : 'reviewer';
             const moveTo = isReviewer ? 'reviewer' : 'CC';
-            const id = account.name || account.email || key;
+            const id = account.name || key;
             const message = `${id} moved from ${moveFrom} to ${moveTo}.`;
             fireAlert(this, message);
           }
@@ -556,94 +541,58 @@
     }
   }
 
-  _processReviewerChange(
-    indexSplices: Array<PolymerSplice<AccountInfo[]>>,
-    type: ReviewerType
-  ) {
-    for (const splice of indexSplices) {
-      for (const account of splice.removed) {
-        if (!this._reviewersPendingRemove[type]) {
-          this.reporting.error(new Error(`Invalid type ${type} for reviewer.`));
-          return;
-        }
-        this._reviewersPendingRemove[type].push(account);
-      }
-    }
+  getUnresolvedPatchsetLevelClass(isResolvedPatchsetLevelComment: boolean) {
+    return isResolvedPatchsetLevelComment ? 'resolved' : 'unresolved';
   }
 
-  /**
-   * Resets the state of the _reviewersPendingRemove object, and removes
-   * accounts if necessary.
-   *
-   * @param isCancel true if the action is a cancel.
-   * @param keep map of account IDs that must
-   * not be removed, because they have been readded in another state.
-   */
-  _purgeReviewersPendingRemove(
-    isCancel: boolean,
-    keep = new Map<AccountId | EmailAddress, boolean>()
-  ) {
-    let reviewerArr: (AccountInfoInput | GroupInfoInput)[];
-    for (const type of PENDING_REMOVAL_KEYS) {
-      if (!isCancel) {
-        reviewerArr = this._reviewersPendingRemove[type];
-        for (let i = 0; i < reviewerArr.length; i++) {
-          const reviewer = reviewerArr[i];
-          if (!isAccount(reviewer) || !keep.get(accountKey(reviewer))) {
-            this._removeAccount(reviewer, type as ReviewerType);
-          }
-        }
-      }
-      this._reviewersPendingRemove[type] = [];
-    }
-  }
-
-  /**
-   * Removes an account from the change, both on the backend and the client.
-   * Does nothing if the account is a pending addition.
-   */
-  _removeAccount(
-    account: AccountInfoInput | GroupInfoInput,
-    type: ReviewerType
-  ) {
-    assertIsDefined(this.change, 'change');
-    if (account._pendingAdd || !isAccount(account)) {
-      return;
-    }
-
-    return this.restApiService
-      .removeChangeReviewer(this.change._number, accountKey(account))
-      .then((response?: Response) => {
-        if (!response?.ok || !this.change) return;
-
-        const reviewers = this.change.reviewers[type] || [];
-        for (let i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id === account._account_id) {
-            this.splice(`change.reviewers.${type}`, i, 1);
-            break;
-          }
-        }
+  computeReviewers(change: ChangeInfo) {
+    const reviewers: ReviewerInput[] = [];
+    const addToReviewInput = (
+      additions: AccountAddition[],
+      state?: ReviewerState
+    ) => {
+      additions.forEach(addition => {
+        const reviewer = mapReviewer(addition);
+        if (state) reviewer.state = state;
+        reviewers.push(reviewer);
       });
+    };
+    addToReviewInput(this.$.reviewers.additions(), ReviewerState.REVIEWER);
+    addToReviewInput(this.$.ccs.additions(), ReviewerState.CC);
+    addToReviewInput(
+      this.$.reviewers.removals().filter(
+        r =>
+          isReviewerOrCC(change, r) &&
+          // ignore removal from reviewer request if being added to CC
+          !this.$.ccs
+            .additions()
+            .some(
+              account =>
+                mapReviewer(account).reviewer === mapReviewer(r).reviewer
+            )
+      ),
+      ReviewerState.REMOVED
+    );
+    addToReviewInput(
+      this.$.ccs.removals().filter(
+        r =>
+          isReviewerOrCC(change, r) &&
+          // ignore removal from CC request if being added as reviewer
+          !this.$.reviewers
+            .additions()
+            .some(
+              account =>
+                mapReviewer(account).reviewer === mapReviewer(r).reviewer
+            )
+      ),
+      ReviewerState.REMOVED
+    );
+    return reviewers;
   }
 
-  _mapReviewer(addition: AccountAddition): ReviewerInput {
-    if (addition.account) {
-      return {reviewer: accountKey(addition.account)};
-    }
-    if (addition.group) {
-      const reviewer = decodeURIComponent(addition.group.id) as GroupId;
-      const confirmed = addition.group.confirmed;
-      return {reviewer, confirmed};
-    }
-    throw new Error('Reviewer must be either an account or a group.');
-  }
-
-  send(
-    includeComments: boolean,
-    startReview: boolean
-  ): Promise<Map<AccountId | EmailAddress, boolean>> {
+  send(includeComments: boolean, startReview: boolean) {
     this.reporting.time(Timing.SEND_REPLY);
-    const labels = this.$.labelScores.getLabelValues();
+    const labels = this.getLabelScores().getLabelValues();
 
     const reviewInput: ReviewInput = {
       drafts: includeComments
@@ -656,29 +605,26 @@
       reviewInput.ready = true;
     }
 
-    if (isAttentionSetEnabled(this.serverConfig)) {
-      const selfName = getDisplayName(this.serverConfig, this._account);
-      const reason = `${selfName} replied on the change`;
+    const reason = getReplyByReason(this._account, this.serverConfig);
 
-      reviewInput.ignore_automatic_attention_set_rules = true;
-      reviewInput.add_to_attention_set = [];
-      for (const user of this._newAttentionSet) {
-        if (!this._currentAttentionSet.has(user)) {
-          reviewInput.add_to_attention_set.push({user, reason});
-        }
+    reviewInput.ignore_automatic_attention_set_rules = true;
+    reviewInput.add_to_attention_set = [];
+    for (const user of this._newAttentionSet) {
+      if (!this._currentAttentionSet.has(user)) {
+        reviewInput.add_to_attention_set.push({user, reason});
       }
-      reviewInput.remove_from_attention_set = [];
-      for (const user of this._currentAttentionSet) {
-        if (!this._newAttentionSet.has(user)) {
-          reviewInput.remove_from_attention_set.push({user, reason});
-        }
-      }
-      this.reportAttentionSetChanges(
-        this._attentionExpanded,
-        reviewInput.add_to_attention_set,
-        reviewInput.remove_from_attention_set
-      );
     }
+    reviewInput.remove_from_attention_set = [];
+    for (const user of this._currentAttentionSet) {
+      if (!this._newAttentionSet.has(user)) {
+        reviewInput.remove_from_attention_set.push({user, reason});
+      }
+    }
+    this.reportAttentionSetChanges(
+      this._attentionExpanded,
+      reviewInput.add_to_attention_set,
+      reviewInput.remove_from_attention_set
+    );
 
     if (this.draft) {
       const comment: CommentInput = {
@@ -690,25 +636,8 @@
       };
     }
 
-    const accountAdditions = new Map<AccountId | EmailAddress, boolean>();
-    reviewInput.reviewers = this.$.reviewers.additions().map(reviewer => {
-      if (reviewer.account) {
-        accountAdditions.set(accountKey(reviewer.account), true);
-      }
-      return this._mapReviewer(reviewer);
-    });
-    const ccsEl = this.$.ccs;
-    if (ccsEl) {
-      for (const addition of ccsEl.additions()) {
-        if (addition.account) {
-          accountAdditions.set(accountKey(addition.account), true);
-        }
-        const reviewer = this._mapReviewer(addition);
-        reviewer.state = ReviewerState.CC;
-        reviewInput.reviewers.push(reviewer);
-      }
-    }
-
+    assertIsDefined(this.change, 'change');
+    reviewInput.reviewers = this.computeReviewers(this.change);
     this.disabled = true;
 
     const errFn = (r?: Response | null) => this._handle400Error(r);
@@ -717,11 +646,11 @@
         if (!response) {
           // Null or undefined response indicates that an error handler
           // took responsibility, so just return.
-          return new Map<AccountId | EmailAddress, boolean>();
+          return;
         }
         if (!response.ok) {
           fireServerError(response);
-          return new Map<AccountId | EmailAddress, boolean>();
+          return;
         }
 
         this.draft = '';
@@ -733,7 +662,7 @@
           })
         );
         fireIronAnnounce(this, 'Reply sent');
-        return accountAdditions;
+        return;
       })
       .then(result => {
         this.disabled = false;
@@ -751,7 +680,7 @@
       section = this._chooseFocusTarget();
     }
     if (section === FocusTarget.BODY) {
-      const textarea = this.$.textarea;
+      const textarea = queryAndAssert<GrTextarea>(this, 'gr-textarea');
       setTimeout(() => textarea.getNativeTextarea().focus());
     } else if (section === FocusTarget.REVIEWERS) {
       const reviewerEntry = this.$.reviewers.focusStart;
@@ -854,7 +783,7 @@
     if (changeReviewers) {
       for (const key of Object.keys(changeReviewers)) {
         if (key !== 'REVIEWER' && key !== 'CC') {
-          console.warn('unexpected reviewer state:', key);
+          this.reporting.error(new Error(`Unexpected reviewer state: ${key}`));
           continue;
         }
         if (!changeReviewers[key]) continue;
@@ -889,12 +818,12 @@
     fireEvent(this, 'iron-resize');
   }
 
-  _showAttentionSummary(config?: ServerInfo, attentionExpanded?: boolean) {
-    return isAttentionSetEnabled(config) && !attentionExpanded;
+  _showAttentionSummary(attentionExpanded?: boolean) {
+    return !attentionExpanded;
   }
 
-  _showAttentionDetails(config?: ServerInfo, attentionExpanded?: boolean) {
-    return isAttentionSetEnabled(config) && attentionExpanded;
+  _showAttentionDetails(attentionExpanded?: boolean) {
+    return attentionExpanded;
   }
 
   _computeAttentionButtonTitle(sendDisabled?: boolean) {
@@ -916,12 +845,12 @@
 
     if (this._newAttentionSet.has(id)) {
       this._newAttentionSet.delete(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `REMOVE${self}${role}`,
       });
     } else {
       this._newAttentionSet.add(id);
-      this.reporting.reportInteraction('attention-set-chip', {
+      this.reporting.reportInteraction(Interaction.ATTENTION_SET_CHIP, {
         action: `ADD${self}${role}`,
       });
     }
@@ -1055,7 +984,7 @@
   }
 
   _computeCommentAccounts(threads: CommentThread[]) {
-    const crLabel = this.change?.labels?.[CODE_REVIEW];
+    const crLabel = this.change?.labels?.[StandardLabels.CODE_REVIEW];
     const maxCrVoteAccountIds = getMaxAccounts(crLabel).map(a => a._account_id);
     const accountIds = new Set<AccountId>();
     threads.forEach(thread => {
@@ -1163,12 +1092,6 @@
     return rev.uploader;
   }
 
-  _accountOrGroupKey(entry: AccountInfo | GroupInfo) {
-    if (isAccount(entry)) return accountKey(entry);
-    if (isGroup(entry)) return entry.id;
-    assertNever(entry, 'entry must be account or group');
-  }
-
   /**
    * Generates a function to filter out reviewer/CC entries. When isCCs is
    * truthy, the function filters out entries that already exist in this._ccs.
@@ -1187,16 +1110,15 @@
       } else if (isReviewerGroupSuggestion(suggestion)) {
         entry = suggestion.group;
       } else {
-        console.warn(
-          'received suggestion that was neither account nor group:',
-          suggestion
+        this.reporting.error(
+          new Error(`Suggestion is neither account nor group: ${suggestion}`)
         );
         return false;
       }
 
-      const key = this._accountOrGroupKey(entry);
+      const key = accountOrGroupKey(entry);
       const finder = (entry: AccountInfo | GroupInfo) =>
-        this._accountOrGroupKey(entry) === key;
+        accountOrGroupKey(entry) === key;
       if (isCCs) {
         return this._ccs.find(finder) === undefined;
       }
@@ -1222,8 +1144,8 @@
         bubbles: false,
       })
     );
-    this.$.textarea.closeDropdown();
-    this._purgeReviewersPendingRemove(true);
+    queryAndAssert<GrTextarea>(this, 'gr-textarea').closeDropdown();
+    this.$.reviewers.clearPendingRemovals();
     this._rebuildReviewerArrays(this.change.reviewers, this._owner);
   }
 
@@ -1234,9 +1156,7 @@
       // the text field of the CC entry.
       return;
     }
-    this.send(this._includeComments, false).then(keepReviewers => {
-      this._purgeReviewersPendingRemove(false, keepReviewers);
-    });
+    this.send(this._includeComments, false);
   }
 
   _sendTapHandler(e: Event) {
@@ -1254,19 +1174,15 @@
       fireAlert(this, EMPTY_REPLY_MESSAGE);
       return;
     }
-    return this.send(this._includeComments, this.canBeStarted)
-      .then(keepReviewers => {
-        this._purgeReviewersPendingRemove(false, keepReviewers);
-      })
-      .catch(err => {
-        this.dispatchEvent(
-          new CustomEvent('show-error', {
-            bubbles: true,
-            composed: true,
-            detail: {message: `Error submitting review ${err}`},
-          })
-        );
-      });
+    return this.send(this._includeComments, this.canBeStarted).catch(err => {
+      this.dispatchEvent(
+        new CustomEvent('show-error', {
+          bubbles: true,
+          composed: true,
+          detail: {message: `Error submitting review ${err}`},
+        })
+      );
+    });
   }
 
   _saveReview(review: ReviewInput, errFn?: ErrorCallback) {
@@ -1358,9 +1274,13 @@
     fireEvent(this, 'autogrow');
   }
 
+  getLabelScores() {
+    return this.$.labelScores || queryAndAssert(this, 'gr-label-scores');
+  }
+
   _handleLabelsChanged() {
     this._labelsChanged =
-      Object.keys(this.$.labelScores.getLabelValues(false)).length !== 0;
+      Object.keys(this.getLabelScores().getLabelValues(false)).length !== 0;
   }
 
   _isState(knownLatestState?: LatestPatchState, value?: LatestPatchState) {
@@ -1368,13 +1288,7 @@
   }
 
   _reload() {
-    this.dispatchEvent(
-      new CustomEvent('reload', {
-        detail: {clearPatchset: true},
-        bubbles: false,
-        composed: true,
-      })
-    );
+    fireReload(this, true);
     this.cancel();
   }
 
@@ -1440,7 +1354,7 @@
   _computePatchSetWarning(patchNum?: PatchSetNum, labelsChanged?: boolean) {
     let str = `Patch ${patchNum} is not latest.`;
     if (labelsChanged) {
-      str += ' Voting will have no effect.';
+      str += ' Voting may have no effect.';
     }
     return str;
   }
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 f458994..45a55c1 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
@@ -53,6 +53,9 @@
       /* @see Issue 8602 */
       z-index: 1;
     }
+    .stickyBottom.newReplyDialog {
+      margin-top: unset;
+    }
     .actions {
       display: flex;
       justify-content: space-between;
@@ -99,18 +102,37 @@
       min-height: 12em;
       position: relative;
     }
-    .textareaContainer,
+    .newReplyDialog.textareaContainer {
+      min-height: unset;
+    }
+    textareaContainer,
     #textarea,
     gr-endpoint-decorator[name='reply-text'] {
       display: flex;
       width: 100%;
     }
+    .newReplyDialog .textareaContainer,
+    #textarea,
+    gr-endpoint-decorator[name='reply-text'] {
+      display: block;
+      width: unset;
+      font-family: var(--monospace-font-family);
+      font-size: var(--font-size-code);
+      line-height: calc(var(--font-size-code) + var(--spacing-s));
+      font-weight: var(--font-weight-normal);
+    }
+    .newReplyDialog#textarea {
+      padding: var(--spacing-m);
+    }
     gr-endpoint-decorator[name='reply-text'] {
       flex-direction: column;
     }
     #textarea {
       flex: 1;
     }
+    .previewContainer {
+      border-top: none;
+    }
     .previewContainer gr-formatted-text {
       background: var(--table-header-background-color);
       padding: var(--spacing-l);
@@ -155,7 +177,7 @@
     }
     .attention .edit-attention-button {
       vertical-align: top;
-      --padding: 0px 4px;
+      --gr-button-padding: 0px 4px;
     }
     .attention .edit-attention-button iron-icon {
       color: inherit;
@@ -222,8 +244,24 @@
     .attentionTip div iron-icon {
       margin-right: var(--spacing-s);
     }
+    .patchsetLevelContainer {
+      width: 80ch;
+      border-radius: var(--border-radius);
+      box-shadow: var(--elevation-level-2);
+    }
+    .patchsetLevelContainer.resolved{
+      background-color: var(--comment-background-color);
+    }
+    .patchsetLevelContainer.unresolved{
+      background-color: var(--unresolved-comment-background-color);
+    }
+    .labelContainer {
+      padding-left: var(--spacing-m);
+      padding-bottom: var(--spacing-m);
+    }
+
   </style>
-  <div class="container" tabindex="-1">
+  <div class$="container" tabindex="-1">
     <section class="peopleContainer">
       <gr-endpoint-decorator name="reply-reviewers">
         <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
@@ -281,43 +319,7 @@
         </div>
       </gr-overlay>
     </section>
-    <section class="textareaContainer">
-      <gr-endpoint-decorator name="reply-text">
-        <gr-textarea
-          id="textarea"
-          class="message"
-          autocomplete="on"
-          placeholder="[[_messagePlaceholder]]"
-          fixed-position-dropdown=""
-          hide-border="true"
-          monospace="true"
-          disabled="{{disabled}}"
-          rows="4"
-          text="{{draft}}"
-          on-bind-value-changed="_handleHeightChanged"
-        >
-        </gr-textarea>
-      </gr-endpoint-decorator>
-    </section>
-    <section class="previewContainer">
-      <label>
-        <input
-          id="resolvedPatchsetLevelCommentCheckbox"
-          type="checkbox"
-          checked="{{_isResolvedPatchsetLevelComment::change}}"
-        />
-        Resolved
-      </label>
-      <label class="preview-formatting">
-        <input type="checkbox" checked="{{_previewFormatting::change}}" />
-        Preview formatting
-      </label>
-      <gr-formatted-text
-        content="[[draft]]"
-        hidden$="[[!_previewFormatting]]"
-        config="[[projectConfig.commentlinks]]"
-      ></gr-formatted-text>
-    </section>
+
     <section class="labelsContainer">
       <gr-endpoint-decorator name="reply-label-scores">
         <gr-label-scores
@@ -327,9 +329,54 @@
           on-labels-changed="_handleLabelsChanged"
           permitted-labels="[[permittedLabels]]"
         ></gr-label-scores>
+        <gr-endpoint-param name="change" value="[[change]]"></gr-endpoint-param>
       </gr-endpoint-decorator>
       <div id="pluginMessage">[[_pluginMessage]]</div>
     </section>
+    <section class="newReplyDialog textareaContainer">
+      <div class$="patchsetLevelContainer [[getUnresolvedPatchsetLevelClass(_isResolvedPatchsetLevelComment)]]">
+        <gr-endpoint-decorator name="reply-text">
+          <gr-textarea
+            id="textarea"
+            class="message newReplyDialog"
+            autocomplete="on"
+            placeholder="[[_messagePlaceholder]]"
+            fixed-position-dropdown=""
+            monospace="true"
+            disabled="{{disabled}}"
+            rows="4"
+            text="{{draft}}"
+            on-bind-value-changed="_handleHeightChanged"
+          >
+          </gr-textarea>
+          <gr-endpoint-param name="change" value="[[change]]">
+          </gr-endpoint-param>
+        </gr-endpoint-decorator>
+        <div class="labelContainer">
+          <label>
+            <input
+              id="resolvedPatchsetLevelCommentCheckbox"
+              type="checkbox"
+              checked="{{_isResolvedPatchsetLevelComment::change}}"
+            />
+            Resolved
+          </label>
+          <label class="preview-formatting">
+            <input type="checkbox" checked="{{_previewFormatting::change}}" />
+            Preview formatting
+          </label>
+        </div>
+      </div>
+    </section>
+    <template is="dom-if" if="[[_previewFormatting]]">
+      <section class="previewContainer">
+        <gr-formatted-text
+          content="[[draft]]"
+          config="[[projectConfig.commentlinks]]"
+        ></gr-formatted-text>
+    </template>
+    </section>
+
     <section
       class="draftsContainer"
       hidden$="[[_computeHideDraftList(draftCommentThreads)]]"
@@ -351,7 +398,7 @@
         change="[[change]]"
         change-num="[[change._number]]"
         logged-in="true"
-        hide-toggle-buttons=""
+        hide-dropdown=""
       >
       </gr-thread-list>
       <span
@@ -361,9 +408,9 @@
         Saving comments...
       </span>
     </section>
-    <div class="stickyBottom">
+    <div class$="stickyBottom newReplyDialog">
       <section
-        hidden$="[[!_showAttentionSummary(serverConfig, _attentionExpanded)]]"
+        hidden$="[[!_showAttentionSummary(_attentionExpanded)]]"
         class="attention"
       >
         <div class="attentionSummary">
@@ -391,29 +438,32 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 ></gr-account-label>
               </template>
             </template>
-            <gr-button
-              class="edit-attention-button"
-              on-click="_handleAttentionModify"
-              disabled="[[_sendDisabled]]"
-              link=""
-              position-below=""
-              data-label="Edit"
-              data-action-type="change"
-              data-action-key="edit"
-              has-tooltip=""
+            <gr-tooltip-content
+              has-tooltip
               title="[[_computeAttentionButtonTitle(_sendDisabled)]]"
-              role="button"
-              tabindex="0"
             >
-              <iron-icon icon="gr-icons:edit"></iron-icon>
-              Modify
-            </gr-button>
+              <gr-button
+                class="edit-attention-button"
+                on-click="_handleAttentionModify"
+                disabled="[[_sendDisabled]]"
+                link=""
+                position-below=""
+                data-label="Edit"
+                data-action-type="change"
+                data-action-key="edit"
+                role="button"
+                tabindex="0"
+              >
+                <iron-icon icon="gr-icons:edit"></iron-icon>
+                Modify
+              </gr-button>
+            </gr-tooltip-content>
           </div>
           <div>
             <a
@@ -429,7 +479,7 @@
         </div>
       </section>
       <section
-        hidden$="[[!_showAttentionDetails(serverConfig, _attentionExpanded)]]"
+        hidden$="[[!_showAttentionDetails(_attentionExpanded)]]"
         class="attention-detail"
       >
         <div class="attentionDetailsTitle">
@@ -462,8 +512,8 @@
               account="[[_owner]]"
               force-attention="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
               selected="[[_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              deselected="[[!_computeHasNewAttention(_owner, _newAttentionSet)]]"
-              hide-hovercard=""
+              hideHovercard
+              selectionChipStyle
               on-click="_handleAttentionClick"
             >
             </gr-account-label>
@@ -477,8 +527,8 @@
                 account="[[_uploader]]"
                 force-attention="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(_uploader, _newAttentionSet)]]"
-                hide-hovercard=""
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -497,8 +547,8 @@
                 account="[[account]]"
                 force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                 selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
-                hide-hovercard=""
+                hideHovercard
+                selectionChipStyle
                 on-click="_handleAttentionClick"
               >
               </gr-account-label>
@@ -518,8 +568,8 @@
                   account="[[account]]"
                   force-attention="[[_computeHasNewAttention(account, _newAttentionSet)]]"
                   selected="[[_computeHasNewAttention(account, _newAttentionSet)]]"
-                  deselected="[[!_computeHasNewAttention(account, _newAttentionSet)]]"
-                  hide-hovercard=""
+                  hideHovercard
+                  selectionChipStyle
                   on-click="_handleAttentionClick"
                 >
                 </gr-account-label>
@@ -568,26 +618,32 @@
             <!-- Use 'Send' here as the change may only about reviewers / ccs
                 and when this button is visible, the next button will always
                 be 'Start review' -->
-            <gr-button
-              link=""
-              disabled="[[_isState(knownLatestState, 'not-latest')]]"
-              class="action save"
+            <gr-tooltip-content
               has-tooltip=""
-              title="[[_saveTooltip]]"
-              on-click="_saveClickHandler"
-              >Send As WIP</gr-button
+              title$="[[_saveTooltip]]"
             >
+              <gr-button
+                link=""
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                on-click="_saveClickHandler"
+                >Send As WIP</gr-button
+              >
+            </gr-tooltip-content>
           </template>
-          <gr-button
-            id="sendButton"
-            primary=""
-            disabled="[[_sendDisabled]]"
-            class="action send"
+          <gr-tooltip-content
             has-tooltip=""
             title$="[[_computeSendButtonTooltip(canBeStarted, _commentEditing)]]"
-            on-click="_sendTapHandler"
-            >[[_sendButtonLabel]]</gr-button
           >
+            <gr-button
+              id="sendButton"
+              primary=""
+              disabled="[[_sendDisabled]]"
+              class="action send"
+              on-click="_sendTapHandler"
+              >[[_sendButtonLabel]]
+            </gr-button>
+          </gr-tooltip-content>
         </div>
       </section>
     </div>
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
deleted file mode 100644
index c1e2564..0000000
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.js
+++ /dev/null
@@ -1,1589 +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 {IronOverlayManager} from '@polymer/iron-overlay-behavior/iron-overlay-manager.js';
-import './gr-reply-dialog.js';
-import {mockPromise, stubStorage} 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');
-
-function cloneableResponse(status, text) {
-  return {
-    ok: false,
-    status,
-    text() {
-      return Promise.resolve(text);
-    },
-    clone() {
-      return {
-        ok: false,
-        status,
-        text() {
-          return Promise.resolve(text);
-        },
-      };
-    },
-  };
-}
-
-suite('gr-reply-dialog tests', () => {
-  let element;
-  let changeNum;
-  let patchNum;
-
-  let getDraftCommentStub;
-  let setDraftCommentStub;
-  let eraseDraftCommentStub;
-
-  let lastId = 0;
-  const makeAccount = function() { return {_account_id: lastId++}; };
-  const makeGroup = function() { return {id: lastId++}; };
-
-  setup(() => {
-    changeNum = 42;
-    patchNum = 1;
-
-    stubRestApi('getAccount').returns(Promise.resolve({}));
-    stubRestApi('getChange').returns(Promise.resolve({}));
-    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
-
-    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
-
-    element = basicFixture.instantiate();
-    element.change = {
-      _number: changeNum,
-      owner: {
-        _account_id: 999,
-        display_name: 'Kermit',
-      },
-      labels: {
-        'Verified': {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified',
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-          default_value: 0,
-        },
-      },
-    };
-    element.patchNum = patchNum;
-    element.permittedLabels = {
-      'Code-Review': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-      'Verified': [
-        '-1',
-        ' 0',
-        '+1',
-      ],
-    };
-
-    getDraftCommentStub = stubStorage('getDraftComment');
-    setDraftCommentStub = stubStorage('setDraftComment');
-    eraseDraftCommentStub = stubStorage('eraseDraftComment');
-
-    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
-    //     .returns(Promise.resolve({isLatest: true}));
-
-    // Allow the elements created by dom-repeat to be stamped.
-    flush();
-  });
-
-  function stubSaveReview(jsonResponseProducer) {
-    return sinon.stub(
-        element,
-        '_saveReview')
-        .callsFake(review => new Promise((resolve, reject) => {
-          try {
-            const result = jsonResponseProducer(review) || {};
-            const resultStr = JSON_PREFIX + JSON.stringify(result);
-            resolve({
-              ok: true,
-              text() {
-                return Promise.resolve(resultStr);
-              },
-            });
-          } catch (err) {
-            reject(err);
-          }
-        }));
-  }
-
-  test('default to publishing draft comments with reply', done => {
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: false,
-              }],
-            },
-            reviewers: [],
-          });
-          assert.isFalse(element.$.commentList.hidden);
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('modified attention set', done => {
-    element.serverConfig = {
-      change: {enable_attention_set: true},
-    };
-    element._newAttentionSet = new Set([314]);
-    const buttonEl = element.shadowRoot.querySelector('.edit-attention-button');
-    MockInteractions.tap(buttonEl);
-    flush();
-
-    stubSaveReview(review => {
-      assert.isTrue(review.ignore_automatic_attention_set_rules);
-      assert.deepEqual(review.add_to_attention_set, [{
-        user: 314,
-        reason: 'Anonymous replied on the change',
-      }]);
-      assert.deepEqual(review.remove_from_attention_set, []);
-      done();
-    });
-    MockInteractions.tap(element.shadowRoot.querySelector('.send'));
-  });
-
-  function checkComputeAttention(status, userId, reviewerIds, ownerId,
-      attSetIds, replyToIds, expectedIds, uploaderId, hasDraft,
-      includeComments = true) {
-    const user = {_account_id: userId};
-    const reviewers = {base: reviewerIds.map(id => {
-      return {_account_id: id};
-    })};
-    const draftThreads = [
-      {comments: []},
-    ];
-    if (hasDraft) {
-      draftThreads[0].comments.push({__draft: true, unresolved: true});
-    }
-    replyToIds.forEach(id => draftThreads[0].comments.push({
-      author: {_account_id: id},
-    }));
-    const change = {
-      owner: {_account_id: ownerId},
-      status,
-      attention_set: {},
-    };
-    attSetIds.forEach(id => change.attention_set[id] = {});
-    if (uploaderId) {
-      change.current_revision = 1;
-      change.revisions = [{}, {uploader: {_account_id: uploaderId}}];
-    }
-    element.change = change;
-    element._reviewers = reviewers.base;
-
-    flush();
-    const hasDrafts = draftThreads.length > 0;
-    element._computeNewAttention(
-        user, reviewers, [], change, draftThreads, includeComments, undefined,
-        hasDrafts);
-    assert.sameMembers([...element._newAttentionSet], expectedIds);
-  }
-
-  test('computeNewAttention NEW', () => {
-    checkComputeAttention('NEW', null, [], 999, [], [], [999]);
-    checkComputeAttention('NEW', 1, [], 999, [], [], [999]);
-    checkComputeAttention('NEW', 1, [], 999, [1], [], [999]);
-    checkComputeAttention('NEW', 1, [22], 999, [], [], [999]);
-    checkComputeAttention('NEW', 1, [22], 999, [22], [], [22, 999]);
-    checkComputeAttention('NEW', 1, [22], 999, [], [22], [22, 999]);
-    checkComputeAttention('NEW', 1, [22, 33], 999, [33], [22], [22, 33, 999]);
-    // If the owner replies, then do not add them.
-    checkComputeAttention('NEW', 1, [], 1, [], [], []);
-    checkComputeAttention('NEW', 1, [], 1, [1], [], []);
-    checkComputeAttention('NEW', 1, [22], 1, [], [], []);
-
-    checkComputeAttention('NEW', 1, [22], 1, [], [22], [22]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [33], [22], [22, 33]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22], [22]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [], [22, 33], [22, 33]);
-    checkComputeAttention('NEW', 1, [22, 33], 1, [22, 33], [], [22, 33]);
-    // with uploader
-    checkComputeAttention('NEW', 1, [], 1, [], [2], [2], 2);
-    checkComputeAttention('NEW', 1, [], 1, [2], [], [2], 2);
-    checkComputeAttention('NEW', 1, [], 3, [], [], [2, 3], 2);
-  });
-
-  test('computeNewAttention MERGED', () => {
-    checkComputeAttention('MERGED', null, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 999, [], [], [999], undefined, true);
-    checkComputeAttention(
-        'MERGED', 1, [], 999, [], [], [], undefined, true, false);
-    checkComputeAttention('MERGED', 1, [], 999, [1], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [], [], []);
-    checkComputeAttention('MERGED', 1, [22], 999, [22], [], [22]);
-    checkComputeAttention('MERGED', 1, [22], 999, [], [22], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 999, [33], [22], [33]);
-    checkComputeAttention('MERGED', 1, [], 1, [], [], []);
-    checkComputeAttention('MERGED', 1, [], 1, [], [], [], undefined, true);
-    checkComputeAttention('MERGED', 1, [], 1, [1], [], []);
-    checkComputeAttention('MERGED', 1, [], 1, [1], [], [], undefined, true);
-    checkComputeAttention('MERGED', 1, [22], 1, [], [], []);
-    checkComputeAttention('MERGED', 1, [22], 1, [], [22], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [33], [22], [33]);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [], [22, 33], []);
-    checkComputeAttention('MERGED', 1, [22, 33], 1, [22, 33], [], [22, 33]);
-  });
-
-  test('computeNewAttention when adding reviewers', () => {
-    const user = {_account_id: 1};
-    const reviewers = {base: [
-      {_account_id: 1, _pendingAdd: true},
-      {_account_id: 2, _pendingAdd: true},
-    ]};
-    const change = {
-      owner: {_account_id: 5},
-      status: 'NEW',
-      attention_set: {},
-    };
-    element.change = change;
-    element._reviewers = reviewers.base;
-    flush();
-
-    element._computeNewAttention(user, reviewers, [], change, [], true);
-    assert.sameMembers([...element._newAttentionSet], [1, 2]);
-
-    // If the user votes on the change, then they should not be added to the
-    // attention set, even if they have just added themselves as reviewer.
-    // But voting should also add the owner (5).
-    const labelsChanged = true;
-    element._computeNewAttention(
-        user, reviewers, [], change, [], true, labelsChanged);
-    assert.sameMembers([...element._newAttentionSet], [2, 5]);
-  });
-
-  test('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'},
-      {_account_id: 321, display_name: 'Bert'},
-    ];
-    element._ccs = [
-      {_account_id: 7, display_name: 'Elmo'},
-    ];
-    const compute = (currentAtt, newAtt) =>
-      element._computeNewAttentionAccounts(
-          undefined, new Set(currentAtt), new Set(newAtt))
-          .map(a => a._account_id);
-
-    assert.sameMembers(compute([], []), []);
-    assert.sameMembers(compute([], [999]), [999]);
-    assert.sameMembers(compute([999], []), []);
-    assert.sameMembers(compute([999], [999]), []);
-    assert.sameMembers(compute([123, 321], [999]), [999]);
-    assert.sameMembers(compute([999], [7, 123, 999]), [7, 123]);
-  });
-
-  test('_computeCommentAccounts', () => {
-    element.change = {
-      labels: {
-        'Code-Review': {
-          all: [
-            {_account_id: 1, value: 0},
-            {_account_id: 2, value: 1},
-            {_account_id: 3, value: 2},
-          ],
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didnt submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved',
-          },
-        },
-      },
-    };
-    const threads = [
-      {
-        comments: [
-          {author: {_account_id: 1}, unresolved: false},
-          {author: {_account_id: 2}, unresolved: true},
-        ],
-      },
-      {
-        comments: [
-          {author: {_account_id: 3}, unresolved: false},
-          {author: {_account_id: 4}, unresolved: false},
-        ],
-      },
-    ];
-    const actualAccounts = [...element._computeCommentAccounts(threads)];
-    // Account 3 is not included, because the comment is resolved *and* they
-    // have given the highest possible vote on the Code-Review label.
-    assert.sameMembers(actualAccounts, [1, 2, 4]);
-  });
-
-  test('toggle resolved checkbox', done => {
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    const checkboxEl = element.shadowRoot.querySelector(
-        '#resolvedPatchsetLevelCommentCheckbox');
-    MockInteractions.tap(checkboxEl);
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'PUBLISH_ALL_REVISIONS',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: true,
-              }],
-            },
-            reviewers: [],
-          });
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('keep draft comments with reply', done => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#includeComments'));
-    assert.equal(element._includeComments, false);
-
-    // Async tick is needed because iron-selector content is distributed and
-    // distributed content requires an observer to be set up.
-    // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-    flush(() => {
-      flush(() => {
-        element.draft = 'I wholeheartedly disapprove';
-
-        stubSaveReview(review => {
-          assert.deepEqual(review, {
-            drafts: 'KEEP',
-            labels: {
-              'Code-Review': 0,
-              'Verified': 0,
-            },
-            comments: {
-              [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-                message: 'I wholeheartedly disapprove',
-                unresolved: false,
-              }],
-            },
-            reviewers: [],
-          });
-          assert.isTrue(element.$.commentList.hidden);
-          done();
-        });
-
-        // This is needed on non-Blink engines most likely due to the ways in
-        // which the dom-repeat elements are stamped.
-        flush(() => {
-          MockInteractions.tap(element.shadowRoot
-              .querySelector('.send'));
-        });
-      });
-    });
-  });
-
-  test('label picker', done => {
-    element.draft = 'I wholeheartedly disapprove';
-    stubSaveReview(review => {
-      assert.deepEqual(review, {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {
-          'Code-Review': -1,
-          'Verified': -1,
-        },
-        comments: {
-          [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [{
-            message: 'I wholeheartedly disapprove',
-            unresolved: false,
-          }],
-        },
-        reviewers: [],
-      });
-    });
-
-    sinon.stub(element.$.labelScores, 'getLabelValues').callsFake( () => {
-      return {
-        'Code-Review': -1,
-        'Verified': -1,
-      };
-    });
-
-    element.addEventListener('send', () => {
-      // Flush to ensure properties are updated.
-      flush(() => {
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done sending reply.');
-        assert.equal(element.draft.length, 0);
-        done();
-      });
-    });
-
-    // This is needed on non-Blink engines most likely due to the ways in
-    // which the dom-repeat elements are stamped.
-    flush(() => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      assert.isTrue(element.disabled);
-    });
-  });
-
-  test('getlabelValue returns value', done => {
-    flush(() => {
-      element.shadowRoot
-          .querySelector('gr-label-scores')
-          .shadowRoot
-          .querySelector(`gr-label-score-row[name="Verified"]`)
-          .setSelectedValue(-1);
-      assert.equal('-1', element.getLabelValue('Verified'));
-      done();
-    });
-  });
-
-  test('getlabelValue when no score is selected', done => {
-    flush(() => {
-      element.shadowRoot
-          .querySelector('gr-label-scores')
-          .shadowRoot
-          .querySelector(`gr-label-score-row[name="Code-Review"]`)
-          .setSelectedValue(-1);
-      assert.strictEqual(element.getLabelValue('Verified'), ' 0');
-      done();
-    });
-  });
-
-  test('setlabelValue', done => {
-    element._account = {_account_id: 1};
-    flush(() => {
-      const label = 'Verified';
-      const value = '+1';
-      element.setLabelValue(label, value);
-
-      const labels = element.$.labelScores.getLabelValues();
-      assert.deepEqual(labels, {
-        'Code-Review': 0,
-        'Verified': 1,
-      });
-      done();
-    });
-  });
-
-  function getActiveElement() {
-    return IronOverlayManager.deepActiveElement;
-  }
-
-  function isVisible(el) {
-    assert.ok(el);
-    return getComputedStyle(el).getPropertyValue('display') != 'none';
-  }
-
-  function overlayObserver(mode) {
-    return new Promise(resolve => {
-      function listener() {
-        element.removeEventListener('iron-overlay-' + mode, listener);
-        resolve();
-      }
-      element.addEventListener('iron-overlay-' + mode, listener);
-    });
-  }
-
-  function isFocusInsideElement(element) {
-    // In Polymer 2 focused element either <paper-input> or nested
-    // native input <input> element depending on the current focus
-    // in browser window.
-    // For example, the focus is changed if the developer console
-    // get a focus.
-    let activeElement = getActiveElement();
-    while (activeElement) {
-      if (activeElement === element) {
-        return true;
-      }
-      if (activeElement.parentElement) {
-        activeElement = activeElement.parentElement;
-      } else {
-        activeElement = activeElement.getRootNode().host;
-      }
-    }
-    return false;
-  }
-
-  async function testConfirmationDialog(cc) {
-    const yesButton = element
-        .shadowRoot
-        .querySelector('.reviewerConfirmationButtons gr-button:first-child');
-    const noButton = element
-        .shadowRoot
-        .querySelector('.reviewerConfirmationButtons gr-button:last-child');
-
-    element._ccPendingConfirmation = null;
-    element._reviewerPendingConfirmation = null;
-    flush();
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-    // Cause the confirmation dialog to display.
-    let observer = overlayObserver('opened');
-    const group = {
-      id: 'id',
-      name: 'name',
-    };
-    if (cc) {
-      element._ccPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    } else {
-      element._reviewerPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    }
-    flush();
-
-    if (cc) {
-      assert.deepEqual(
-          element._ccPendingConfirmation,
-          element._pendingConfirmationDetails);
-    } else {
-      assert.deepEqual(
-          element._reviewerPendingConfirmation,
-          element._pendingConfirmationDetails);
-    }
-
-    await observer;
-    assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-    observer = overlayObserver('closed');
-    const expected = 'Group name has 10 members';
-    assert.notEqual(
-        element.$.reviewerConfirmationOverlay.innerText
-            .indexOf(expected),
-        -1);
-    MockInteractions.tap(noButton); // close the overlay
-
-    await observer;
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-
-    // We should be focused on account entry input.
-    assert.isTrue(
-        isFocusInsideElement(
-            element.$.reviewers.$.entry.$.input.$.input
-        )
-    );
-
-    // No reviewer/CC should have been added.
-    assert.equal(element.$.ccs.additions().length, 0);
-    assert.equal(element.$.reviewers.additions().length, 0);
-
-    // Reopen confirmation dialog.
-    observer = overlayObserver('opened');
-    if (cc) {
-      element._ccPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    } else {
-      element._reviewerPendingConfirmation = {
-        group,
-        count: 10,
-      };
-    }
-
-    await observer;
-    assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
-    observer = overlayObserver('closed');
-    MockInteractions.tap(yesButton); // Confirm the group.
-
-    await observer;
-    assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-    const additions = cc ?
-      element.$.ccs.additions() :
-      element.$.reviewers.additions();
-    assert.deepEqual(
-        additions,
-        [
-          {
-            group: {
-              id: 'id',
-              name: 'name',
-              confirmed: true,
-              _group: true,
-              _pendingAdd: true,
-            },
-          },
-        ]);
-
-    // We should be focused on account entry input.
-    if (cc) {
-      assert.isTrue(
-          isFocusInsideElement(
-              element.$.ccs.$.entry.$.input.$.input
-          )
-      );
-    } else {
-      assert.isTrue(
-          isFocusInsideElement(
-              element.$.reviewers.$.entry.$.input.$.input
-          )
-      );
-    }
-  }
-
-  test('cc confirmation', async () => {
-    testConfirmationDialog(true);
-  });
-
-  test('reviewer confirmation', async () => {
-    testConfirmationDialog(false);
-  });
-
-  test('_getStorageLocation', () => {
-    const actual = element._getStorageLocation();
-    assert.equal(actual.changeNum, changeNum);
-    assert.equal(actual.patchNum, '@change');
-    assert.equal(actual.path, '@change');
-  });
-
-  test('_reviewersMutated when account-text-change is fired from ccs', () => {
-    flush();
-    assert.isFalse(element._reviewersMutated);
-    assert.isTrue(element.$.ccs.allowAnyInput);
-    assert.isFalse(element.shadowRoot
-        .querySelector('#reviewers').allowAnyInput);
-    element.$.ccs.dispatchEvent(new CustomEvent('account-text-changed',
-        {bubbles: true, composed: true}));
-    assert.isTrue(element._reviewersMutated);
-  });
-
-  test('gets draft from storage on open', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('gets draft from storage even when text is already present', () => {
-    const storedDraft = 'hello world';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, storedDraft);
-  });
-
-  test('blank if no stored draft', () => {
-    getDraftCommentStub.returns(null);
-    element.draft = 'foo bar';
-    element.open();
-    assert.isTrue(getDraftCommentStub.called);
-    assert.equal(element.draft, '');
-  });
-
-  test('does not check stored draft when quote is present', () => {
-    const storedDraft = 'hello world';
-    const quote = '> foo bar';
-    getDraftCommentStub.returns({message: storedDraft});
-    element.quote = quote;
-    element.open();
-    assert.isFalse(getDraftCommentStub.called);
-    assert.equal(element.draft, quote);
-    assert.isNotOk(element.quote);
-  });
-
-  test('updates stored draft on edits', () => {
-    const firstEdit = 'hello';
-    const location = element._getStorageLocation();
-
-    element.draft = firstEdit;
-    element.storeTask.flush();
-
-    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
-
-    element.draft = '';
-    element.storeTask.flush();
-
-    assert.isTrue(eraseDraftCommentStub.calledWith(location));
-  });
-
-  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);
-        }
-    );
-
-    const listener = event => {
-      if (event.target !== document) return;
-      event.detail.response.text().then(body => {
-        if (body === 'human readable') {
-          done();
-        }
-      });
-    };
-    addListenerForTest(document, 'server-error', listener);
-
-    flush(() => { element.send(); });
-  });
-
-  test('non-json 400 is treated as a normal server-error', done => {
-    stubRestApi('saveChangeReview').callsFake(
-        (changeNum, patchNum, review, errFn) => {
-          errFn(cloneableResponse(400, 'Comment validation error!'));
-          return Promise.resolve(undefined);
-        }
-    );
-
-    const listener = event => {
-      if (event.target !== document) return;
-      event.detail.response.text().then(body => {
-        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.
-    flush(() => { element.send(); });
-  });
-
-  test('filterReviewerSuggestion', () => {
-    const owner = makeAccount();
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeGroup();
-    const cc1 = makeAccount();
-    const cc2 = makeGroup();
-    let filter = element._filterReviewerSuggestionGenerator(false);
-
-    element._owner = owner;
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2];
-
-    assert.isTrue(filter({account: makeAccount()}));
-    assert.isTrue(filter({group: makeGroup()}));
-
-    // Owner should be excluded.
-    assert.isFalse(filter({account: owner}));
-
-    // Existing and pending reviewers should be excluded when isCC = false.
-    assert.isFalse(filter({account: reviewer1}));
-    assert.isFalse(filter({group: reviewer2}));
-
-    filter = element._filterReviewerSuggestionGenerator(true);
-
-    // Existing and pending CCs should be excluded when isCC = true;.
-    assert.isFalse(filter({account: cc1}));
-    assert.isFalse(filter({group: cc2}));
-  });
-
-  test('_focusOn', async () => {
-    sinon.spy(element, '_chooseFocusTarget');
-    element._focusOn();
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 1);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
-    assert.equal(element.shadowRoot.activeElement.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.ANY);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
-    assert.equal(element.shadowRoot.activeElement.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.BODY);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-TEXTAREA');
-    assert.equal(element.shadowRoot.activeElement.id, 'textarea');
-
-    element._focusOn(element.FocusTarget.REVIEWERS);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
-    assert.equal(element.shadowRoot.activeElement.id, 'reviewers');
-
-    element._focusOn(element.FocusTarget.CCS);
-    await flush();
-    assert.equal(element._chooseFocusTarget.callCount, 2);
-    assert.equal(element.shadowRoot.activeElement.tagName, 'GR-ACCOUNT-LIST');
-    assert.equal(element.shadowRoot.activeElement.id, 'ccs');
-  });
-
-  test('_chooseFocusTarget', () => {
-    element._account = undefined;
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element._account = {_account_id: 1};
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element.change.owner = {_account_id: 2};
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-
-    element.change.owner._account_id = 1;
-    element.change._reviewers = null;
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-    element._reviewers = [];
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.REVIEWERS);
-
-    element._reviewers.push({});
-    assert.strictEqual(
-        element._chooseFocusTarget(), element.FocusTarget.BODY);
-  });
-
-  test('only send labels that have changed', done => {
-    flush(() => {
-      stubSaveReview(review => {
-        assert.deepEqual(review.labels, {
-          'Code-Review': 0,
-          'Verified': -1,
-        });
-      });
-
-      element.addEventListener('send', () => {
-        done();
-      });
-      // Without wrapping this test in flush(), the below two calls to
-      // MockInteractions.tap() cause a race in some situations in shadow DOM.
-      // The send button can be tapped before the others, causing the test to
-      // fail.
-
-      element.shadowRoot
-          .querySelector('gr-label-scores').shadowRoot
-          .querySelector(
-              'gr-label-score-row[name="Verified"]')
-          .setSelectedValue(-1);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-    });
-  });
-
-  test('_processReviewerChange', () => {
-    const mockIndexSplices = function(toRemove) {
-      return [{
-        removed: [toRemove],
-      }];
-    };
-
-    element._processReviewerChange(
-        mockIndexSplices(makeAccount()), 'REVIEWER');
-    assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
-  });
-
-  test('_purgeReviewersPendingRemove', () => {
-    const removeStub = sinon.stub(element, '_removeAccount');
-    const mock = function() {
-      element._reviewersPendingRemove = {
-        CC: [makeAccount()],
-        REVIEWER: [makeAccount(), makeAccount()],
-      };
-    };
-    const checkObjEmpty = function(obj) {
-      for (const prop of Object.keys(obj)) {
-        if (obj[prop].length) { return false; }
-      }
-      return true;
-    };
-    mock();
-    element._purgeReviewersPendingRemove(true); // Cancel
-    assert.isFalse(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-
-    mock();
-    element._purgeReviewersPendingRemove(false); // Submit
-    assert.isTrue(removeStub.called);
-    assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
-  });
-
-  test('_removeAccount', done => {
-    stubRestApi('removeChangeReviewer')
-        .returns(Promise.resolve({ok: true}));
-    const arr = [makeAccount(), makeAccount()];
-    element.change.reviewers = {
-      REVIEWER: arr.slice(),
-    };
-
-    element._removeAccount(arr[1], 'REVIEWER').then(() => {
-      assert.equal(element.change.reviewers.REVIEWER.length, 1);
-      assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
-      done();
-    });
-  });
-
-  test('moving from cc to reviewer', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flush();
-
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const reviewer3 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_reviewers', cc1);
-    flush();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer1, reviewer2, reviewer3, cc1]);
-    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
-
-    element.push('_reviewers', cc4, cc3);
-    flush();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
-    assert.deepEqual(element._ccs, [cc2]);
-    assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
-  });
-
-  test('update attention section when reviewers and ccs change', () => {
-    element._account = makeAccount();
-    element._reviewers = [makeAccount(), makeAccount()];
-    element._ccs = [makeAccount(), makeAccount()];
-    element.draftCommentThreads = [];
-    const modifyButton =
-        element.shadowRoot.querySelector('.edit-attention-button');
-    MockInteractions.tap(modifyButton);
-    flush();
-
-    // "Modify" button disabled, because "Send" button is disabled.
-    assert.isFalse(element._attentionExpanded);
-    element.draft = 'a test comment';
-    MockInteractions.tap(modifyButton);
-    flush();
-    assert.isTrue(element._attentionExpanded);
-
-    let accountLabels = Array.from(element.shadowRoot.querySelectorAll(
-        '.attention-detail gr-account-label'));
-    assert.equal(accountLabels.length, 5);
-
-    element.push('_reviewers', makeAccount());
-    element.push('_ccs', makeAccount());
-    flush();
-
-    // The 'attention modified' section collapses and resets when reviewers or
-    // ccs change.
-    assert.isFalse(element._attentionExpanded);
-
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('.edit-attention-button'));
-    flush();
-
-    assert.isTrue(element._attentionExpanded);
-    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
-        '.attention-detail gr-account-label'));
-    assert.equal(accountLabels.length, 7);
-
-    element.pop('_reviewers', makeAccount());
-    element.pop('_reviewers', makeAccount());
-    element.pop('_ccs', makeAccount());
-    element.pop('_ccs', makeAccount());
-
-    MockInteractions.tap(
-        element.shadowRoot.querySelector('.edit-attention-button'));
-    flush();
-
-    accountLabels = Array.from(element.shadowRoot.querySelectorAll(
-        '.attention-detail gr-account-label'));
-    assert.equal(accountLabels.length, 3);
-  });
-
-  test('moving from reviewer to cc', () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flush();
-
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const reviewer3 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    const cc4 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2, reviewer3];
-    element._ccs = [cc1, cc2, cc3, cc4];
-    element.push('_ccs', reviewer1);
-    flush();
-
-    assert.deepEqual(element._reviewers,
-        [reviewer2, reviewer3]);
-    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER, [reviewer1]);
-
-    element.push('_ccs', reviewer3, reviewer2);
-    flush();
-
-    assert.deepEqual(element._reviewers, []);
-    assert.deepEqual(element._ccs,
-        [cc1, cc2, cc3, cc4, reviewer1, reviewer3, reviewer2]);
-    assert.deepEqual(element._reviewersPendingRemove.REVIEWER,
-        [reviewer1, reviewer3, reviewer2]);
-  });
-
-  test('migrate reviewers between states', async () => {
-    element._reviewersPendingRemove = {
-      CC: [],
-      REVIEWER: [],
-    };
-    flush();
-    const reviewers = element.$.reviewers;
-    const ccs = element.$.ccs;
-    const reviewer1 = makeAccount();
-    const reviewer2 = makeAccount();
-    const cc1 = makeAccount();
-    const cc2 = makeAccount();
-    const cc3 = makeAccount();
-    element._reviewers = [reviewer1, reviewer2];
-    element._ccs = [cc1, cc2, cc3];
-
-    const mutations = [];
-
-    stubSaveReview(review => mutations.push(...review.reviewers));
-
-    sinon.stub(element, '_removeAccount').callsFake((account, type) => {
-      mutations.push({state: 'REMOVED', account});
-      return Promise.resolve();
-    });
-
-    // Remove and add to other field.
-    reviewers.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: reviewer1},
-          composed: true, bubbles: true,
-        }));
-    ccs.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: reviewer1}},
-          composed: true, bubbles: true,
-        }));
-    ccs.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: cc1},
-          composed: true, bubbles: true,
-        }));
-    ccs.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: cc3},
-          composed: true, bubbles: true,
-        }));
-    reviewers.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: cc1}},
-          composed: true, bubbles: true,
-        }));
-
-    // Add to other field without removing from former field.
-    // (Currently not possible in UI, but this is a good consistency check).
-    reviewers.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: cc2}},
-          composed: true, bubbles: true,
-        }));
-    ccs.$.entry.dispatchEvent(
-        new CustomEvent('add', {
-          detail: {value: {account: reviewer2}},
-          composed: true, bubbles: true,
-        }));
-    const mapReviewer = function(reviewer, opt_state) {
-      const result = {reviewer: reviewer._account_id};
-      if (opt_state) {
-        result.state = opt_state;
-      }
-      return result;
-    };
-
-    // Send and purge and verify moves, delete cc3.
-    await element.send()
-        .then(keepReviewers =>
-          element._purgeReviewersPendingRemove(false, keepReviewers));
-    expect(mutations).to.have.lengthOf(5);
-    expect(mutations[0]).to.deep.equal(mapReviewer(cc1));
-    expect(mutations[1]).to.deep.equal(mapReviewer(cc2));
-    expect(mutations[2]).to.deep.equal(mapReviewer(reviewer1, 'CC'));
-    expect(mutations[3]).to.deep.equal(mapReviewer(reviewer2, 'CC'));
-    expect(mutations[4]).to.deep.equal({account: cc3, state: 'REMOVED'});
-  });
-
-  test('emits cancel on esc key', () => {
-    const cancelHandler = sinon.spy();
-    element.addEventListener('cancel', cancelHandler);
-    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
-    flush();
-
-    assert.isTrue(cancelHandler.called);
-  });
-
-  test('should not send on enter key', () => {
-    stubSaveReview(() => undefined);
-    element.addEventListener('send', () => assert.fail('wrongly called'));
-    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-    flush();
-  });
-
-  test('emit send on ctrl+enter key', done => {
-    stubSaveReview(() => undefined);
-    element.addEventListener('send', () => done());
-    MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
-    flush();
-  });
-
-  test('_computeMessagePlaceholder', () => {
-    assert.equal(
-        element._computeMessagePlaceholder(false),
-        'Say something nice...');
-    assert.equal(
-        element._computeMessagePlaceholder(true),
-        'Add a note for your reviewers...');
-  });
-
-  test('_computeSendButtonLabel', () => {
-    assert.equal(
-        element._computeSendButtonLabel(false),
-        'Send');
-    assert.equal(
-        element._computeSendButtonLabel(true),
-        'Send and Start review');
-  });
-
-  test('_handle400Error reviewers and CCs', done => {
-    const error1 = 'error 1';
-    const error2 = 'error 2';
-    const error3 = 'error 3';
-    const text = ')]}\'' + JSON.stringify({
-      reviewers: {
-        username1: {
-          input: 'username1',
-          error: error1,
-        },
-        username2: {
-          input: 'username2',
-          error: error2,
-        },
-        username3: {
-          input: 'username3',
-          error: error3,
-        },
-      },
-    });
-    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));
-  });
-
-  test('fires height change when the drafts comments load', done => {
-    // Flush DOM operations before binding to the autogrow event so we don't
-    // catch the events fired from the initial layout.
-    flush(() => {
-      const autoGrowHandler = sinon.stub();
-      element.addEventListener('autogrow', autoGrowHandler);
-      element.draftCommentThreads = [];
-      flush(() => {
-        assert.isTrue(autoGrowHandler.called);
-        done();
-      });
-    });
-  });
-
-  suite('start review and save buttons', () => {
-    let sendStub;
-
-    setup(() => {
-      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
-      element.canBeStarted = true;
-      // Flush to make both Start/Save buttons appear in DOM.
-      flush();
-    });
-
-    test('start review sets ready', () => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.send'));
-      flush();
-      assert.isTrue(sendStub.calledWith(true, true));
-    });
-
-    test('save review doesn\'t set ready', () => {
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      flush();
-      assert.isTrue(sendStub.calledWith(true, false));
-    });
-  });
-
-  test('buttons disabled until all API calls are resolved', () => {
-    stubSaveReview(review => {
-      return {ready: true};
-    });
-    return element.send(true, true).then(() => {
-      assert.isFalse(element.disabled);
-    });
-  });
-
-  suite('error handling', () => {
-    const expectedDraft = 'draft';
-    const expectedError = new Error('test');
-
-    setup(() => {
-      element.draft = expectedDraft;
-    });
-
-    function assertDialogOpenAndEnabled() {
-      assert.strictEqual(expectedDraft, element.draft);
-      assert.isFalse(element.disabled);
-    }
-
-    test('error occurs in _saveReview', () => {
-      stubSaveReview(review => {
-        throw expectedError;
-      });
-      return element.send(true, true).catch(err => {
-        assert.strictEqual(expectedError, err);
-        assertDialogOpenAndEnabled();
-      });
-    });
-
-    suite('pending diff drafts?', () => {
-      test('yes', async () => {
-        const promise = mockPromise();
-        const refreshSpy = sinon.spy();
-        element.addEventListener('comment-refresh', refreshSpy);
-        stubRestApi('hasPendingDiffDrafts').returns(true);
-        stubRestApi('awaitPendingDiffDrafts').returns(promise);
-
-        element.open();
-
-        assert.isFalse(refreshSpy.called);
-        assert.isTrue(element._savingComments);
-
-        promise.resolve();
-        await flush();
-
-        assert.isTrue(refreshSpy.called);
-        assert.isFalse(element._savingComments);
-      });
-
-      test('no', () => {
-        stubRestApi('hasPendingDiffDrafts').returns(false);
-        element.open();
-        assert.isFalse(element._savingComments);
-      });
-    });
-  });
-
-  test('_computeSendButtonDisabled_canBeStarted', () => {
-    // Mock canBeStarted
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ true,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_allFalse', () => {
-    // Mock everything false
-    assert.isTrue(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_draftCommentsSend', () => {
-    // Mock nonempty comment draft array, with sending comments.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ true,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
-    // Mock nonempty comment draft array, without sending comments.
-    assert.isTrue(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ [{comments: [{__draft: true}]}],
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_changeMessage', () => {
-    // Mock nonempty change message.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ 'test',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_reviewersChanged', () => {
-    // Mock reviewers mutated.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ true,
-        /* labelsChanged= */ false,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_labelsChanged', () => {
-    // Mock labels changed.
-    assert.isFalse(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ false,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  test('_computeSendButtonDisabled_dialogDisabled', () => {
-    // Whole dialog is disabled.
-    assert.isTrue(element._computeSendButtonDisabled(
-        /* canBeStarted= */ false,
-        /* draftCommentThreads= */ {},
-        /* text= */ '',
-        /* reviewersMutated= */ false,
-        /* labelsChanged= */ true,
-        /* includeComments= */ false,
-        /* disabled= */ true,
-        /* commentEditing= */ false,
-        /* change= */ element.change,
-        /* account= */ makeAccount()
-    ));
-  });
-
-  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 = [];
-    await flush();
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isFalse(sendStub.called);
-
-    element.draftCommentThreads = [{comments: [
-      {__draft: true, path: 'test', line: 1, patch_set: 1},
-    ]}];
-    await flush();
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.send'));
-    assert.isTrue(sendStub.called);
-  });
-
-  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}]},
-    ];
-    await flush();
-
-    assert.equal(element.getFocusStops().end, element.$.sendButton);
-  });
-
-  test('setPluginMessage', () => {
-    element.setPluginMessage('foo');
-    assert.equal(element.$.pluginMessage.textContent, 'foo');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
new file mode 100644
index 0000000..5a11f47
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.ts
@@ -0,0 +1,2191 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-reply-dialog';
+import {
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubStorage,
+} from '../../../test/test-utils';
+import {
+  ChangeStatus,
+  ReviewerState,
+  SpecialFilePath,
+} from '../../../constants/constants';
+import {appContext} from '../../../services/app-context';
+import {addListenerForTest} from '../../../test/test-utils';
+import {stubRestApi} from '../../../test/test-utils';
+import {JSON_PREFIX} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {StandardLabels} from '../../../utils/label-util';
+import {
+  createAccountWithId,
+  createChange,
+  createCommentThread,
+  createDraft,
+  createRevision,
+} from '../../../test/test-data-generators';
+import {
+  pressAndReleaseKeyOn,
+  tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrReplyDialog} from './gr-reply-dialog';
+import {
+  AccountId,
+  AccountInfo,
+  CommitId,
+  DetailedLabelInfo,
+  GroupId,
+  GroupName,
+  NumericChangeId,
+  PatchSetNum,
+  ReviewerInput,
+  ReviewInput,
+  ReviewResult,
+  Suggestion,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {CommentThread} from '../../../utils/comment-util';
+import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  AccountInfoInput,
+  GrAccountList,
+} from '../../shared/gr-account-list/gr-account-list';
+import {GrLabelScoreRow} from '../gr-label-score-row/gr-label-score-row';
+import {GrLabelScores} from '../gr-label-scores/gr-label-scores';
+import {GrThreadList} from '../gr-thread-list/gr-thread-list';
+import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
+
+const basicFixture = fixtureFromElement('gr-reply-dialog');
+
+function cloneableResponse(status: number, text: string) {
+  return {
+    ...new Response(),
+    ok: false,
+    status,
+    text() {
+      return Promise.resolve(text);
+    },
+    clone() {
+      return {
+        ok: false,
+        status,
+        text() {
+          return Promise.resolve(text);
+        },
+      };
+    },
+  };
+}
+
+suite('gr-reply-dialog tests', () => {
+  let element: GrReplyDialog;
+  let changeNum: NumericChangeId;
+  let patchNum: PatchSetNum;
+
+  let getDraftCommentStub: sinon.SinonStub;
+  let setDraftCommentStub: sinon.SinonStub;
+  let eraseDraftCommentStub: sinon.SinonStub;
+
+  const emptyAccountInfoInputChanges =
+    [] as unknown as PolymerDeepPropertyChange<
+      AccountInfoInput[],
+      AccountInfoInput[]
+    >;
+
+  let lastId = 1;
+  const makeAccount = function () {
+    return {_account_id: lastId++ as AccountId};
+  };
+  const makeGroup = function () {
+    return {id: `${lastId++}` as GroupId};
+  };
+
+  setup(async () => {
+    changeNum = 42 as NumericChangeId;
+    patchNum = 1 as PatchSetNum;
+
+    stubRestApi('getChange').returns(Promise.resolve({...createChange()}));
+    stubRestApi('getChangeSuggestedReviewers').returns(Promise.resolve([]));
+
+    sinon.stub(appContext.flagsService, 'isEnabled').returns(true);
+
+    element = basicFixture.instantiate();
+    element.change = {
+      ...createChange(),
+      _number: changeNum,
+      owner: {
+        _account_id: 999 as AccountId as AccountId,
+        display_name: 'Kermit',
+      },
+      labels: {
+        Verified: {
+          values: {
+            '-1': 'Fails',
+            ' 0': 'No score',
+            '+1': 'Verified',
+          },
+          default_value: 0,
+        },
+        'Code-Review': {
+          values: {
+            '-2': 'Do not submit',
+            '-1': "I would prefer that you didn't submit this",
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+          default_value: 0,
+        },
+      },
+    };
+    element.patchNum = patchNum;
+    element.permittedLabels = {
+      'Code-Review': ['-1', ' 0', '+1'],
+      Verified: ['-1', ' 0', '+1'],
+    };
+
+    getDraftCommentStub = stubStorage('getDraftComment');
+    setDraftCommentStub = stubStorage('setDraftComment');
+    eraseDraftCommentStub = stubStorage('eraseDraftComment');
+
+    // sinon.stub(patchSetUtilMockProxy, 'fetchChangeUpdates')
+    //     .returns(Promise.resolve({isLatest: true}));
+
+    // Allow the elements created by dom-repeat to be stamped.
+    await flush();
+  });
+
+  function stubSaveReview(
+    jsonResponseProducer: (input: ReviewInput) => ReviewResult | void
+  ) {
+    return sinon.stub(element, '_saveReview').callsFake(
+      review =>
+        new Promise((resolve, reject) => {
+          try {
+            const result = jsonResponseProducer(review) || {};
+            const resultStr = JSON_PREFIX + JSON.stringify(result);
+            resolve({
+              ...new Response(),
+              ok: true,
+              text() {
+                return Promise.resolve(resultStr);
+              },
+            });
+          } catch (err) {
+            reject(err);
+          }
+        })
+    );
+  }
+
+  function interceptSaveReview() {
+    let resolver: (review: ReviewInput) => void;
+    const promise = new Promise(resolve => {
+      resolver = resolve;
+    });
+    stubSaveReview((review: ReviewInput) => {
+      resolver(review);
+    });
+    return promise;
+  }
+
+  test('default to publishing draft comments with reply', async () => {
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+    await flush();
+
+    const review = await saveReviewPromise;
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+    assert.isFalse(
+      (queryAndAssert(element, '#commentList') as GrThreadList).hidden
+    );
+  });
+
+  test('modified attention set', async () => {
+    await flush();
+    element._account = {_account_id: 123 as AccountId};
+    element._newAttentionSet = new Set([314 as AccountId]);
+    const saveReviewPromise = interceptSaveReview();
+    const modifyButton = queryAndAssert(element, '.edit-attention-button');
+    tap(modifyButton);
+    await flush();
+
+    tap(queryAndAssert(element, '.send'));
+    const review = await saveReviewPromise;
+
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      add_to_attention_set: [
+        {reason: '<GERRIT_ACCOUNT_123> replied on the change', user: 314},
+      ],
+      reviewers: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('modified attention set by anonymous', async () => {
+    await flush();
+    element._account = {};
+    element._newAttentionSet = new Set([314 as AccountId]);
+    const saveReviewPromise = interceptSaveReview();
+    const modifyButton = queryAndAssert(element, '.edit-attention-button');
+    tap(modifyButton);
+    await flush();
+
+    tap(queryAndAssert(element, '.send'));
+    const review = await saveReviewPromise;
+
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      add_to_attention_set: [
+        {reason: 'Anonymous replied on the change', user: 314},
+      ],
+      reviewers: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+    element._newAttentionSet = new Set();
+    await flush();
+  });
+
+  function checkComputeAttention(
+    status: ChangeStatus,
+    userId?: AccountId,
+    reviewerIds?: AccountId[],
+    ownerId?: AccountId,
+    attSetIds?: AccountId[],
+    replyToIds?: AccountId[],
+    expectedIds?: AccountId[],
+    uploaderId?: AccountId,
+    hasDraft = true,
+    includeComments = true
+  ) {
+    const user = {_account_id: userId};
+    const reviewers = {
+      base: reviewerIds?.map(id => {
+        return {_account_id: id};
+      }),
+    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+    let draftThreads: CommentThread[] = [];
+    if (hasDraft) {
+      draftThreads = [
+        {
+          ...createCommentThread([
+            {
+              ...createDraft(),
+              __draft: true,
+              unresolved: true,
+            },
+          ]),
+        },
+      ];
+    }
+    replyToIds?.forEach(id =>
+      draftThreads[0].comments.push({
+        author: {_account_id: id},
+      })
+    );
+    const change = {
+      ...createChange(),
+      owner: {_account_id: ownerId},
+      status,
+    };
+    attSetIds?.forEach(id => {
+      if (!change.attention_set) change.attention_set = {};
+      change.attention_set[id.toString()] = {
+        account: createAccountWithId(id),
+      };
+    });
+    if (uploaderId) {
+      change.current_revision = 'b' as CommitId;
+      change.revisions = {
+        a: createRevision(1),
+        b: {...createRevision(2), uploader: {_account_id: uploaderId}},
+      };
+    }
+    element.change = change;
+    element._reviewers = reviewers.base!;
+
+    flush();
+    const hasDrafts = draftThreads.length > 0;
+    element._computeNewAttention(
+      user,
+      reviewers!,
+      emptyAccountInfoInputChanges,
+      change,
+      draftThreads,
+      includeComments,
+      undefined,
+      hasDrafts
+    );
+    assert.sameMembers([...element._newAttentionSet], expectedIds!);
+  }
+
+  test('computeNewAttention NEW', () => {
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [1 as AccountId],
+      [],
+      [999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [],
+      [999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [22 as AccountId],
+      [],
+      [22 as AccountId, 999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [22 as AccountId],
+      [22 as AccountId, 999 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      999 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [22 as AccountId, 33 as AccountId, 999 as AccountId]
+    );
+    // If the owner replies, then do not add them.
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [1 as AccountId],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      [22 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [22 as AccountId, 33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      [22 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId, 33 as AccountId],
+      [22 as AccountId, 33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      [],
+      [22 as AccountId, 33 as AccountId]
+    );
+    // with uploader
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [2 as AccountId],
+      [2 as AccountId],
+      2 as AccountId
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [2 as AccountId],
+      [],
+      [2 as AccountId],
+      2 as AccountId
+    );
+    checkComputeAttention(
+      ChangeStatus.NEW,
+      1 as AccountId,
+      [],
+      3 as AccountId,
+      [],
+      [],
+      [2 as AccountId, 3 as AccountId],
+      2 as AccountId
+    );
+  });
+
+  test('computeNewAttention MERGED', () => {
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      undefined,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [999 as AccountId],
+      undefined,
+      true
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      true,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      999 as AccountId,
+      [1 as AccountId],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [22 as AccountId],
+      [],
+      [22 as AccountId],
+      undefined,
+      false
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      999 as AccountId,
+      [],
+      [22 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      999 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [],
+      [],
+      [],
+      undefined,
+      true
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [1 as AccountId],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [],
+      1 as AccountId,
+      [1 as AccountId],
+      [],
+      [],
+      undefined,
+      true
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [33 as AccountId],
+      [22 as AccountId],
+      [33 as AccountId]
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [],
+      [22 as AccountId, 33 as AccountId],
+      []
+    );
+    checkComputeAttention(
+      ChangeStatus.MERGED,
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      1 as AccountId,
+      [22 as AccountId, 33 as AccountId],
+      [],
+      [22 as AccountId, 33 as AccountId]
+    );
+  });
+
+  test('computeNewAttention when adding reviewers', () => {
+    const user = {_account_id: 1 as AccountId};
+    const reviewers = {
+      base: [
+        {_account_id: 1 as AccountId, _pendingAdd: true},
+        {_account_id: 2 as AccountId, _pendingAdd: true},
+      ],
+    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+    const change = {
+      ...createChange(),
+      owner: {_account_id: 5 as AccountId},
+      status: ChangeStatus.NEW,
+      attention_set: {},
+    };
+    element.change = change;
+    element._reviewers = reviewers.base;
+    flush();
+
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      true
+    );
+    assert.sameMembers([...element._newAttentionSet], [1, 2]);
+
+    // If the user votes on the change, then they should not be added to the
+    // attention set, even if they have just added themselves as reviewer.
+    // But voting should also add the owner (5).
+    const labelsChanged = true;
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      true,
+      labelsChanged
+    );
+    assert.sameMembers([...element._newAttentionSet], [2, 5]);
+  });
+
+  test('computeNewAttention when sending wip change for review', () => {
+    const reviewers = {
+      base: [{...createAccountWithId(2)}, {...createAccountWithId(3)}],
+    } as PolymerDeepPropertyChange<AccountInfoInput[], AccountInfoInput[]>;
+    const change = {
+      ...createChange(),
+      owner: {_account_id: 1 as AccountId},
+      status: ChangeStatus.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 as AccountId};
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      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 as AccountId};
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      false
+    );
+    assert.sameMembers([...element._newAttentionSet], [2, 3]);
+
+    // ... but not when someone else replies.
+    user = {_account_id: 4 as AccountId};
+    element._computeNewAttention(
+      user,
+      reviewers,
+      emptyAccountInfoInputChanges,
+      change,
+      [],
+      false
+    );
+    assert.sameMembers([...element._newAttentionSet], []);
+  });
+
+  test('computeNewAttentionAccounts', () => {
+    element._reviewers = [
+      {_account_id: 123 as AccountId, display_name: 'Ernie'},
+      {_account_id: 321 as AccountId, display_name: 'Bert'},
+    ];
+    element._ccs = [{_account_id: 7 as AccountId, display_name: 'Elmo'}];
+    const compute = (currentAtt: AccountId[], newAtt: AccountId[]) =>
+      element
+        ._computeNewAttentionAccounts(
+          undefined,
+          new Set(currentAtt),
+          new Set(newAtt)
+        )
+        .map(a => a!._account_id);
+
+    assert.sameMembers(compute([], []), []);
+    assert.sameMembers(compute([], [999 as AccountId]), [999 as AccountId]);
+    assert.sameMembers(compute([999 as AccountId], []), []);
+    assert.sameMembers(compute([999 as AccountId], [999 as AccountId]), []);
+    assert.sameMembers(
+      compute([123 as AccountId, 321 as AccountId], [999 as AccountId]),
+      [999 as AccountId]
+    );
+    assert.sameMembers(
+      compute(
+        [999 as AccountId],
+        [7 as AccountId, 123 as AccountId, 999 as AccountId]
+      ),
+      [7 as AccountId, 123 as AccountId]
+    );
+  });
+
+  test('_computeCommentAccounts', () => {
+    element.change = {
+      ...createChange(),
+      labels: {
+        'Code-Review': {
+          all: [
+            {_account_id: 1 as AccountId, value: 0},
+            {_account_id: 2 as AccountId, value: 1},
+            {_account_id: 3 as AccountId, value: 2},
+          ],
+          values: {
+            '-2': 'Do not submit',
+            '-1': 'I would prefer that you didnt submit this',
+            ' 0': 'No score',
+            '+1': 'Looks good to me, but someone else must approve',
+            '+2': 'Looks good to me, approved',
+          },
+        },
+      },
+    };
+    const threads = [
+      {
+        ...createCommentThread([
+          {
+            id: '1' as UrlEncodedCommentId,
+            author: {_account_id: 1 as AccountId},
+            unresolved: false,
+          },
+          {
+            id: '2' as UrlEncodedCommentId,
+            in_reply_to: '1' as UrlEncodedCommentId,
+            author: {_account_id: 2 as AccountId},
+            unresolved: true,
+          },
+        ]),
+      },
+      {
+        ...createCommentThread([
+          {
+            id: '3' as UrlEncodedCommentId,
+            author: {_account_id: 3 as AccountId},
+            unresolved: false,
+          },
+          {
+            id: '4' as UrlEncodedCommentId,
+            in_reply_to: '3' as UrlEncodedCommentId,
+            author: {_account_id: 4 as AccountId},
+            unresolved: false,
+          },
+        ]),
+      },
+    ];
+    const actualAccounts = [...element._computeCommentAccounts(threads)];
+    // Account 3 is not included, because the comment is resolved *and* they
+    // have given the highest possible vote on the Code-Review label.
+    assert.sameMembers(actualAccounts, [1, 2, 4]);
+  });
+
+  test('toggle resolved checkbox', async () => {
+    const checkboxEl = queryAndAssert(
+      element,
+      '#resolvedPatchsetLevelCommentCheckbox'
+    );
+    tap(checkboxEl);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+
+    const review = await saveReviewPromise;
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: true,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('label picker', async () => {
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    sinon.stub(element.getLabelScores(), 'getLabelValues').callsFake(() => {
+      return {
+        'Code-Review': -1,
+        Verified: -1,
+      };
+    });
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+    assert.isTrue(element.disabled);
+
+    const review = await saveReviewPromise;
+    await flush();
+    assert.isFalse(
+      element.disabled,
+      'Element should be enabled when done sending reply.'
+    );
+    assert.equal(element.draft.length, 0);
+    assert.deepEqual(review, {
+      drafts: 'PUBLISH_ALL_REVISIONS',
+      labels: {
+        'Code-Review': -1,
+        Verified: -1,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('keep draft comments with reply', async () => {
+    tap(queryAndAssert(element, '#includeComments'));
+    assert.equal(element._includeComments, false);
+
+    // Async tick is needed because iron-selector content is distributed and
+    // distributed content requires an observer to be set up.
+    await flush();
+    element.draft = 'I wholeheartedly disapprove';
+    const saveReviewPromise = interceptSaveReview();
+
+    // This is needed on non-Blink engines most likely due to the ways in
+    // which the dom-repeat elements are stamped.
+    await flush();
+    tap(queryAndAssert(element, '.send'));
+
+    const review = await saveReviewPromise;
+    await flush();
+    assert.deepEqual(review, {
+      drafts: 'KEEP',
+      labels: {
+        'Code-Review': 0,
+        Verified: 0,
+      },
+      comments: {
+        [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: [
+          {
+            message: 'I wholeheartedly disapprove',
+            unresolved: false,
+          },
+        ],
+      },
+      reviewers: [],
+      add_to_attention_set: [],
+      remove_from_attention_set: [],
+      ignore_automatic_attention_set_rules: true,
+    });
+  });
+
+  test('getlabelValue returns value', async () => {
+    await flush();
+    const el = queryAndAssert(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Verified"]'
+    ) as GrLabelScoreRow;
+    el.setSelectedValue('-1');
+    assert.equal('-1', element.getLabelValue('Verified'));
+  });
+
+  test('getlabelValue when no score is selected', async () => {
+    await flush();
+    const el = queryAndAssert(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Code-Review"]'
+    ) as GrLabelScoreRow;
+    el.setSelectedValue('-1');
+    assert.strictEqual(element.getLabelValue('Verified'), ' 0');
+  });
+
+  test('setlabelValue', async () => {
+    element._account = {_account_id: 1 as AccountId};
+    await flush();
+    const label = 'Verified';
+    const value = '+1';
+    element.setLabelValue(label, value);
+    await flush();
+
+    const labels = (
+      queryAndAssert(element, '#labelScores') as GrLabelScores
+    ).getLabelValues();
+    assert.deepEqual(labels, {
+      'Code-Review': 0,
+      Verified: 1,
+    });
+  });
+
+  function getActiveElement() {
+    return document.activeElement;
+  }
+
+  function isVisible(el: Element) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') !== 'none';
+  }
+
+  function overlayObserver(mode: string) {
+    return new Promise(resolve => {
+      function listener() {
+        element.removeEventListener('iron-overlay-' + mode, listener);
+        resolve(mode);
+      }
+      element.addEventListener('iron-overlay-' + mode, listener);
+    });
+  }
+
+  function isFocusInsideElement(element: Element) {
+    // In Polymer 2 focused element either <paper-input> or nested
+    // native input <input> element depending on the current focus
+    // in browser window.
+    // For example, the focus is changed if the developer console
+    // get a focus.
+    let activeElement = getActiveElement();
+    while (activeElement) {
+      if (activeElement === element) {
+        return true;
+      }
+      if (activeElement.parentElement) {
+        activeElement = activeElement.parentElement;
+      } else {
+        activeElement = (activeElement.getRootNode() as ShadowRoot).host;
+      }
+    }
+    return false;
+  }
+
+  async function testConfirmationDialog(cc?: boolean) {
+    const yesButton = queryAndAssert(
+      element,
+      '.reviewerConfirmationButtons gr-button:first-child'
+    );
+    const noButton = queryAndAssert(
+      element,
+      '.reviewerConfirmationButtons gr-button:last-child'
+    );
+
+    element._ccPendingConfirmation = null;
+    element._reviewerPendingConfirmation = null;
+    flush();
+    assert.isFalse(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+
+    // Cause the confirmation dialog to display.
+    let observer = overlayObserver('opened');
+    const group = {
+      id: 'id' as GroupId,
+      name: 'name' as GroupName,
+    };
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    }
+    flush();
+
+    if (cc) {
+      assert.deepEqual(
+        element._ccPendingConfirmation,
+        element._pendingConfirmationDetails
+      );
+    } else {
+      assert.deepEqual(
+        element._reviewerPendingConfirmation,
+        element._pendingConfirmationDetails
+      );
+    }
+
+    await observer;
+    assert.isTrue(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+    observer = overlayObserver('closed');
+    const expected = 'Group name has 10 members';
+    assert.notEqual(
+      (
+        queryAndAssert(element, 'reviewerConfirmationOverlay') as GrOverlay
+      ).innerText.indexOf(expected),
+      -1
+    );
+    tap(noButton); // close the overlay
+
+    await observer;
+    assert.isFalse(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+
+    // We should be focused on account entry input.
+    assert.isTrue(
+      isFocusInsideElement(
+        (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$.input
+          .$.input
+      )
+    );
+
+    // No reviewer/CC should have been added.
+    assert.equal(
+      (queryAndAssert(element, '#ccs') as GrAccountList).additions().length,
+      0
+    );
+    assert.equal(
+      (queryAndAssert(element, '#reviewers') as GrAccountList).additions()
+        .length,
+      0
+    );
+
+    // Reopen confirmation dialog.
+    observer = overlayObserver('opened');
+    if (cc) {
+      element._ccPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    } else {
+      element._reviewerPendingConfirmation = {
+        group,
+        confirm: false,
+      };
+    }
+
+    await observer;
+    assert.isTrue(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+    observer = overlayObserver('closed');
+    tap(yesButton); // Confirm the group.
+
+    await observer;
+    assert.isFalse(
+      isVisible(queryAndAssert(element, 'reviewerConfirmationOverlay'))
+    );
+    const additions = cc
+      ? (queryAndAssert(element, '#ccs') as GrAccountList).additions()
+      : (queryAndAssert(element, '#reviewers') as GrAccountList).additions();
+    assert.deepEqual(additions, [
+      {
+        group: {
+          id: 'id' as GroupId,
+          name: 'name' as GroupName,
+          confirmed: true,
+          _group: true,
+          _pendingAdd: true,
+        },
+      },
+    ]);
+
+    // We should be focused on account entry input.
+    if (cc) {
+      assert.isTrue(
+        isFocusInsideElement(
+          (queryAndAssert(element, '#ccs') as GrAccountList).$.entry.$.input.$
+            .input
+        )
+      );
+    } else {
+      assert.isTrue(
+        isFocusInsideElement(
+          (queryAndAssert(element, '#reviewers') as GrAccountList).$.entry.$
+            .input.$.input
+        )
+      );
+    }
+  }
+
+  test('cc confirmation', async () => {
+    testConfirmationDialog(true);
+  });
+
+  test('reviewer confirmation', async () => {
+    testConfirmationDialog(false);
+  });
+
+  test('_getStorageLocation', () => {
+    const actual = element._getStorageLocation();
+    assert.equal(actual.changeNum, changeNum);
+    assert.equal(actual.patchNum, '@change');
+    assert.equal(actual.path, '@change');
+  });
+
+  test('_reviewersMutated when account-text-change is fired from ccs', () => {
+    flush();
+    assert.isFalse(element._reviewersMutated);
+    assert.isTrue(
+      (queryAndAssert(element, '#ccs') as GrAccountList).allowAnyInput
+    );
+    assert.isFalse(
+      (queryAndAssert(element, '#reviewers') as GrAccountList).allowAnyInput
+    );
+    queryAndAssert(element, '#ccs').dispatchEvent(
+      new CustomEvent('account-text-changed', {bubbles: true, composed: true})
+    );
+    assert.isTrue(element._reviewersMutated);
+  });
+
+  test('gets draft from storage on open', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('gets draft from storage even when text is already present', () => {
+    const storedDraft = 'hello world';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, storedDraft);
+  });
+
+  test('blank if no stored draft', () => {
+    getDraftCommentStub.returns(null);
+    element.draft = 'foo bar';
+    element.open();
+    assert.isTrue(getDraftCommentStub.called);
+    assert.equal(element.draft, '');
+  });
+
+  test('does not check stored draft when quote is present', () => {
+    const storedDraft = 'hello world';
+    const quote = '> foo bar';
+    getDraftCommentStub.returns({message: storedDraft});
+    element.quote = quote;
+    element.open();
+    assert.isFalse(getDraftCommentStub.called);
+    assert.equal(element.draft, quote);
+    assert.isNotOk(element.quote);
+  });
+
+  test('updates stored draft on edits', async () => {
+    const clock = sinon.useFakeTimers();
+
+    const firstEdit = 'hello';
+    const location = element._getStorageLocation();
+
+    element.draft = firstEdit;
+    clock.tick(1000);
+    await flush();
+
+    assert.isTrue(setDraftCommentStub.calledWith(location, firstEdit));
+
+    element.draft = '';
+    clock.tick(1000);
+    await flush();
+
+    assert.isTrue(eraseDraftCommentStub.calledWith(location));
+  });
+
+  test('400 converts to human-readable server-error', async () => {
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) => {
+        errFn!(
+          cloneableResponse(
+            400,
+            '....{"reviewers":{"id1":{"error":"human readable"}}}'
+          ) as Response
+        );
+        return Promise.resolve(new Response());
+      }
+    );
+
+    const promise = mockPromise();
+    const listener = (event: Event) => {
+      if (event.target !== document) return;
+      (event as CustomEvent).detail.response.text().then((body: string) => {
+        if (body === 'human readable') {
+          promise.resolve();
+        }
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
+
+    await flush();
+    element.send(false, false);
+    await promise;
+  });
+
+  test('non-json 400 is treated as a normal server-error', async () => {
+    stubRestApi('saveChangeReview').callsFake(
+      (_changeNum, _patchNum, _review, errFn) => {
+        errFn!(cloneableResponse(400, 'Comment validation error!') as Response);
+        return Promise.resolve(new Response());
+      }
+    );
+    const promise = mockPromise();
+    const listener = (event: Event) => {
+      if (event.target !== document) return;
+      (event as CustomEvent).detail.response.text().then((body: string) => {
+        if (body === 'Comment validation error!') {
+          promise.resolve();
+        }
+      });
+    };
+    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.
+    await flush();
+    element.send(false, false);
+    await promise;
+  });
+
+  test('filterReviewerSuggestion', () => {
+    const owner = makeAccount();
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeGroup();
+    const cc1 = makeAccount();
+    const cc2 = makeGroup();
+    let filter = element._filterReviewerSuggestionGenerator(false);
+
+    element._owner = owner;
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2];
+
+    assert.isTrue(filter({account: makeAccount()} as Suggestion));
+    assert.isTrue(filter({group: makeGroup()} as Suggestion));
+
+    // Owner should be excluded.
+    assert.isFalse(filter({account: owner} as Suggestion));
+
+    // Existing and pending reviewers should be excluded when isCC = false.
+    assert.isFalse(filter({account: reviewer1} as Suggestion));
+    assert.isFalse(filter({group: reviewer2} as Suggestion));
+
+    filter = element._filterReviewerSuggestionGenerator(true);
+
+    // Existing and pending CCs should be excluded when isCC = true;.
+    assert.isFalse(filter({account: cc1} as Suggestion));
+    assert.isFalse(filter({group: cc2} as Suggestion));
+  });
+
+  test('_focusOn', async () => {
+    const chooseFocusTargetSpy = sinon.spy(element, '_chooseFocusTarget');
+    element._focusOn();
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 1);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
+
+    element._focusOn(element.FocusTarget.ANY);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
+
+    element._focusOn(element.FocusTarget.BODY);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(element?.shadowRoot?.activeElement?.tagName, 'GR-TEXTAREA');
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'textarea');
+
+    element._focusOn(element.FocusTarget.REVIEWERS);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(
+      element?.shadowRoot?.activeElement?.tagName,
+      'GR-ACCOUNT-LIST'
+    );
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'reviewers');
+
+    element._focusOn(element.FocusTarget.CCS);
+    await flush();
+    assert.equal(chooseFocusTargetSpy.callCount, 2);
+    assert.equal(
+      element?.shadowRoot?.activeElement?.tagName,
+      'GR-ACCOUNT-LIST'
+    );
+    assert.equal(element?.shadowRoot?.activeElement?.id, 'ccs');
+  });
+
+  test('_chooseFocusTarget', () => {
+    element._account = undefined;
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element._account = {_account_id: 1 as AccountId};
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change!.owner = {_account_id: 2 as AccountId};
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+
+    element.change!.owner._account_id = 1 as AccountId;
+    assert.strictEqual(
+      element._chooseFocusTarget(),
+      element.FocusTarget.REVIEWERS
+    );
+
+    element._reviewers = [];
+    assert.strictEqual(
+      element._chooseFocusTarget(),
+      element.FocusTarget.REVIEWERS
+    );
+
+    element._reviewers.push({});
+    assert.strictEqual(element._chooseFocusTarget(), element.FocusTarget.BODY);
+  });
+
+  test('only send labels that have changed', async () => {
+    await flush();
+    stubSaveReview((review: ReviewInput) => {
+      assert.deepEqual(review?.labels, {
+        'Code-Review': 0,
+        Verified: -1,
+      });
+    });
+
+    const promise = mockPromise();
+    element.addEventListener('send', () => {
+      promise.resolve();
+    });
+    // Without wrapping this test in flush(), the below two calls to
+    // tap() cause a race in some situations in shadow DOM.
+    // The send button can be tapped before the others, causing the test to
+    // fail.
+    const el = queryAndAssert(
+      queryAndAssert(element, 'gr-label-scores'),
+      'gr-label-score-row[name="Verified"]'
+    ) as GrLabelScoreRow;
+    el.setSelectedValue('-1');
+    tap(queryAndAssert(element, '.send'));
+    await promise;
+  });
+
+  test('moving from cc to reviewer', () => {
+    flush();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_reviewers', cc1);
+    flush();
+
+    assert.deepEqual(element._reviewers, [
+      reviewer1,
+      reviewer2,
+      reviewer3,
+      cc1,
+    ]);
+    assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+
+    element.push('_reviewers', cc4, cc3);
+    flush();
+
+    assert.deepEqual(element._reviewers, [
+      reviewer1,
+      reviewer2,
+      reviewer3,
+      cc1,
+      cc4,
+      cc3,
+    ]);
+    assert.deepEqual(element._ccs, [cc2]);
+  });
+
+  test('update attention section when reviewers and ccs change', () => {
+    element._account = makeAccount();
+    element._reviewers = [makeAccount(), makeAccount()];
+    element._ccs = [makeAccount(), makeAccount()];
+    element.draftCommentThreads = [];
+    const modifyButton = queryAndAssert(element, '.edit-attention-button');
+    tap(modifyButton);
+    flush();
+
+    // "Modify" button disabled, because "Send" button is disabled.
+    assert.isFalse(element._attentionExpanded);
+    element.draft = 'a test comment';
+    tap(modifyButton);
+    flush();
+    assert.isTrue(element._attentionExpanded);
+
+    let accountLabels = Array.from(
+      queryAll(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountLabels.length, 5);
+
+    element.push('_reviewers', makeAccount());
+    element.push('_ccs', makeAccount());
+    flush();
+
+    // The 'attention modified' section collapses and resets when reviewers or
+    // ccs change.
+    assert.isFalse(element._attentionExpanded);
+
+    tap(queryAndAssert(element, '.edit-attention-button'));
+    flush();
+
+    assert.isTrue(element._attentionExpanded);
+    accountLabels = Array.from(
+      queryAll(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountLabels.length, 7);
+
+    element.pop('_reviewers');
+    element.pop('_reviewers');
+    element.pop('_ccs');
+    element.pop('_ccs');
+
+    tap(queryAndAssert(element, '.edit-attention-button'));
+    flush();
+
+    accountLabels = Array.from(
+      queryAll(element, '.attention-detail gr-account-label')
+    );
+    assert.equal(accountLabels.length, 3);
+  });
+
+  test('moving from reviewer to cc', () => {
+    flush();
+
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const reviewer3 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    const cc4 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2, reviewer3];
+    element._ccs = [cc1, cc2, cc3, cc4];
+    element.push('_ccs', reviewer1);
+    flush();
+
+    assert.deepEqual(element._reviewers, [reviewer2, reviewer3]);
+    assert.deepEqual(element._ccs, [cc1, cc2, cc3, cc4, reviewer1]);
+
+    element.push('_ccs', reviewer3, reviewer2);
+    flush();
+
+    assert.deepEqual(element._reviewers, []);
+    assert.deepEqual(element._ccs, [
+      cc1,
+      cc2,
+      cc3,
+      cc4,
+      reviewer1,
+      reviewer3,
+      reviewer2,
+    ]);
+  });
+
+  test('migrate reviewers between states', async () => {
+    flush();
+    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
+    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    const reviewer1 = makeAccount();
+    const reviewer2 = makeAccount();
+    const cc1 = makeAccount();
+    const cc2 = makeAccount();
+    const cc3 = makeAccount();
+    element._reviewers = [reviewer1, reviewer2];
+    element._ccs = [cc1, cc2, cc3];
+
+    element.change!.reviewers = {
+      [ReviewerState.CC]: [],
+      [ReviewerState.REVIEWER]: [{_account_id: 33 as AccountId}],
+    };
+
+    const mutations: ReviewerInput[] = [];
+
+    stubSaveReview((review: ReviewInput) => {
+      mutations.push(...review!.reviewers!);
+    });
+
+    // Remove and add to other field.
+    reviewers.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: reviewer1},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: reviewer1}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: cc1},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: cc3},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    reviewers.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: cc1}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    // Add to other field without removing from former field.
+    // (Currently not possible in UI, but this is a good consistency check).
+    reviewers.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: cc2}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: reviewer2}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    const mapReviewer = function (
+      reviewer: AccountInfo,
+      opt_state?: ReviewerState
+    ) {
+      const result: ReviewerInput = {
+        reviewer: reviewer._account_id as AccountId,
+      };
+      if (opt_state) {
+        result.state = opt_state;
+      }
+      return result;
+    };
+
+    // Send and purge and verify moves, delete cc3.
+    await element.send(false, false);
+    expect(mutations).to.have.lengthOf(5);
+    expect(mutations[0]).to.deep.equal(
+      mapReviewer(cc1, ReviewerState.REVIEWER)
+    );
+    expect(mutations[1]).to.deep.equal(
+      mapReviewer(cc2, ReviewerState.REVIEWER)
+    );
+    expect(mutations[2]).to.deep.equal(
+      mapReviewer(reviewer1, ReviewerState.CC)
+    );
+    expect(mutations[3]).to.deep.equal(
+      mapReviewer(reviewer2, ReviewerState.CC)
+    );
+
+    // Only 1 account was initially part of the change
+    expect(mutations[4]).to.deep.equal({
+      reviewer: 33,
+      state: ReviewerState.REMOVED,
+    });
+  });
+
+  test('Ignore removal requests if being added as reviewer/CC', async () => {
+    flush();
+    const reviewers = queryAndAssert(element, '#reviewers') as GrAccountList;
+    const ccs = queryAndAssert(element, '#ccs') as GrAccountList;
+    const reviewer1 = makeAccount();
+    element._reviewers = [reviewer1];
+    element._ccs = [];
+
+    element.change!.reviewers = {
+      [ReviewerState.CC]: [],
+      [ReviewerState.REVIEWER]: [{_account_id: reviewer1._account_id}],
+    };
+
+    const mutations: ReviewerInput[] = [];
+
+    stubSaveReview((review: ReviewInput) => {
+      mutations.push(...review!.reviewers!);
+    });
+
+    // Remove and add to other field.
+    reviewers.dispatchEvent(
+      new CustomEvent('remove', {
+        detail: {account: reviewer1},
+        composed: true,
+        bubbles: true,
+      })
+    );
+    ccs.$.entry.dispatchEvent(
+      new CustomEvent('add', {
+        detail: {value: {account: reviewer1}},
+        composed: true,
+        bubbles: true,
+      })
+    );
+
+    await element.send(false, false);
+    expect(mutations).to.have.lengthOf(1);
+    // Only 1 account was initially part of the change
+    expect(mutations[0]).to.deep.equal({
+      reviewer: reviewer1._account_id,
+      state: ReviewerState.CC,
+    });
+  });
+
+  test('emits cancel on esc key', () => {
+    const cancelHandler = sinon.spy();
+    element.addEventListener('cancel', cancelHandler);
+    pressAndReleaseKeyOn(element, 27, null, 'Escape');
+    flush();
+
+    assert.isTrue(cancelHandler.called);
+  });
+
+  test('should not send on enter key', () => {
+    stubSaveReview(() => undefined);
+    element.addEventListener('send', () => assert.fail('wrongly called'));
+    pressAndReleaseKeyOn(element, 13, null, 'Enter');
+  });
+
+  test('emit send on ctrl+enter key', async () => {
+    stubSaveReview(() => undefined);
+    const promise = mockPromise();
+    element.addEventListener('send', () => promise.resolve());
+    pressAndReleaseKeyOn(element, 13, 'ctrl', 'Enter');
+    await promise;
+  });
+
+  test('_computeMessagePlaceholder', () => {
+    assert.equal(
+      element._computeMessagePlaceholder(false),
+      'Say something nice...'
+    );
+    assert.equal(
+      element._computeMessagePlaceholder(true),
+      'Add a note for your reviewers...'
+    );
+  });
+
+  test('_computeSendButtonLabel', () => {
+    assert.equal(element._computeSendButtonLabel(false), 'Send');
+    assert.equal(
+      element._computeSendButtonLabel(true),
+      'Send and Start review'
+    );
+  });
+
+  test('_handle400Error reviewers and CCs', async () => {
+    const error1 = 'error 1';
+    const error2 = 'error 2';
+    const error3 = 'error 3';
+    const text =
+      ")]}'" +
+      JSON.stringify({
+        reviewers: {
+          username1: {
+            input: 'username1',
+            error: error1,
+          },
+          username2: {
+            input: 'username2',
+            error: error2,
+          },
+          username3: {
+            input: 'username3',
+            error: error3,
+          },
+        },
+      });
+    const promise = mockPromise();
+    const listener = (e: Event) => {
+      (e as CustomEvent).detail.response.text().then((text: string) => {
+        assert.equal(text, [error1, error2, error3].join(', '));
+        promise.resolve();
+      });
+    };
+    addListenerForTest(document, 'server-error', listener);
+    element._handle400Error(cloneableResponse(400, text) as Response);
+    await promise;
+  });
+
+  test('fires height change when the drafts comments load', async () => {
+    // Flush DOM operations before binding to the autogrow event so we don't
+    // catch the events fired from the initial layout.
+    await flush();
+    const autoGrowHandler = sinon.stub();
+    element.addEventListener('autogrow', autoGrowHandler);
+    element.draftCommentThreads = [];
+    await flush();
+    assert.isTrue(autoGrowHandler.called);
+  });
+
+  suite('start review and save buttons', () => {
+    let sendStub: sinon.SinonStub;
+
+    setup(async () => {
+      sendStub = sinon.stub(element, 'send').callsFake(() => Promise.resolve());
+      element.canBeStarted = true;
+      // Flush to make both Start/Save buttons appear in DOM.
+      await flush();
+    });
+
+    test('start review sets ready', async () => {
+      tap(queryAndAssert(element, '.send'));
+      await flush();
+      assert.isTrue(sendStub.calledWith(true, true));
+    });
+
+    test("save review doesn't set ready", async () => {
+      tap(queryAndAssert(element, '.save'));
+      await flush();
+      assert.isTrue(sendStub.calledWith(true, false));
+    });
+  });
+
+  test('buttons disabled until all API calls are resolved', () => {
+    stubSaveReview(() => {
+      return {ready: true};
+    });
+    return element.send(true, true).then(() => {
+      assert.isFalse(element.disabled);
+    });
+  });
+
+  suite('error handling', () => {
+    const expectedDraft = 'draft';
+    const expectedError = new Error('test');
+
+    setup(() => {
+      element.draft = expectedDraft;
+    });
+
+    function assertDialogOpenAndEnabled() {
+      assert.strictEqual(expectedDraft, element.draft);
+      assert.isFalse(element.disabled);
+    }
+
+    test('error occurs in _saveReview', () => {
+      stubSaveReview(() => {
+        throw expectedError;
+      });
+      return element.send(true, true).catch(err => {
+        assert.strictEqual(expectedError, err);
+        assertDialogOpenAndEnabled();
+      });
+    });
+
+    suite('pending diff drafts?', () => {
+      test('yes', async () => {
+        const promise = mockPromise();
+        const refreshSpy = sinon.spy();
+        element.addEventListener('comment-refresh', refreshSpy);
+        stubRestApi('hasPendingDiffDrafts').returns(1);
+        stubRestApi('awaitPendingDiffDrafts').returns(promise as Promise<void>);
+
+        element.open();
+
+        assert.isFalse(refreshSpy.called);
+        assert.isTrue(element._savingComments);
+
+        promise.resolve();
+        await flush();
+
+        assert.isTrue(refreshSpy.called);
+        assert.isFalse(element._savingComments);
+      });
+
+      test('no', () => {
+        stubRestApi('hasPendingDiffDrafts').returns(0);
+        element.open();
+        assert.isFalse(element._savingComments);
+      });
+    });
+  });
+
+  test('_computeSendButtonDisabled_canBeStarted', () => {
+    // Mock canBeStarted
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ true,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_allFalse', () => {
+    // Mock everything false
+    assert.isTrue(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsSend', () => {
+    // Mock nonempty comment draft array, with sending comments.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [
+          {...createCommentThread([{__draft: true}])},
+        ],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ true,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_draftCommentsDoNotSend', () => {
+    // Mock nonempty comment draft array, without sending comments.
+    assert.isTrue(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [
+          {...createCommentThread([{__draft: true}])},
+        ],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_changeMessage', () => {
+    // Mock nonempty change message.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ 'test',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_reviewersChanged', () => {
+    // Mock reviewers mutated.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ true,
+        /* labelsChanged= */ false,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_labelsChanged', () => {
+    // Mock labels changed.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ false,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_dialogDisabled', () => {
+    // Whole dialog is disabled.
+    assert.isTrue(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* text= */ '',
+        /* reviewersMutated= */ false,
+        /* labelsChanged= */ true,
+        /* includeComments= */ false,
+        /* disabled= */ true,
+        /* commentEditing= */ false,
+        /* change= */ element.change,
+        /* account= */ makeAccount()
+      )
+    );
+  });
+
+  test('_computeSendButtonDisabled_existingVote', async () => {
+    const account = createAccountWithId();
+    (
+      element.change!.labels![StandardLabels.CODE_REVIEW]! as DetailedLabelInfo
+    ).all = [account];
+    await flush();
+
+    // User has already voted.
+    assert.isFalse(
+      element._computeSendButtonDisabled(
+        /* canBeStarted= */ false,
+        /* draftCommentThreads= */ [{...createCommentThread([{}])}],
+        /* 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());
+    element.draftCommentThreads = [];
+    await flush();
+
+    tap(queryAndAssert(element, 'gr-button.send'));
+    assert.isFalse(sendStub.called);
+
+    element.draftCommentThreads = [
+      {
+        ...createCommentThread([
+          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+        ]),
+      },
+    ];
+    await flush();
+
+    tap(queryAndAssert(element, 'gr-button.send'));
+    assert.isTrue(sendStub.called);
+  });
+
+  test('getFocusStops', async () => {
+    // Setting draftCommentThreads to an empty object causes _sendDisabled to be
+    // computed to false.
+    element.draftCommentThreads = [];
+    await flush();
+
+    assert.equal(
+      element.getFocusStops().end,
+      queryAndAssert(element, '#cancelButton')
+    );
+    element.draftCommentThreads = [
+      {
+        ...createCommentThread([
+          {__draft: true, path: 'test', line: 1, patch_set: 1 as PatchSetNum},
+        ]),
+      },
+    ];
+    await flush();
+
+    assert.equal(
+      element.getFocusStops().end,
+      queryAndAssert(element, '#sendButton')
+    );
+  });
+
+  test('setPluginMessage', () => {
+    element.setPluginMessage('foo');
+    assert.equal(queryAndAssert(element, '#pluginMessage').textContent, 'foo');
+  });
+});
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 1c70350..de11b16 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,24 +16,23 @@
  */
 import '../../shared/gr-account-chip/gr-account-chip';
 import '../../shared/gr-button/gr-button';
+import '../../shared/gr-vote-chip/gr-vote-chip';
 import '../../../styles/shared-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-reviewer-list_html';
-import {isSelf, isServiceUser} from '../../../utils/account-util';
-import {hasAttention} from '../../../utils/attention-set-util';
 import {customElement, property, computed, observe} from '@polymer/decorators';
 import {
   ChangeInfo,
-  ServerInfo,
   LabelNameToValueMap,
   AccountInfo,
   ApprovalInfo,
   Reviewers,
   AccountId,
-  DetailedLabelInfo,
   EmailAddress,
   AccountDetailInfo,
+  isDetailedLabelInfo,
+  LabelInfo,
 } from '../../../types/common';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
@@ -41,6 +40,9 @@
 import {isRemovableReviewer} from '../../../utils/change-util';
 import {ReviewerState} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {fireAlert} from '../../../utils/event-util';
+import {getApprovalInfo, getCodeReviewLabel} from '../../../utils/label-util';
+import {sortReviewers} from '../../../utils/attention-set-util';
 
 @customElement('gr-reviewer-list')
 export class GrReviewerList extends PolymerElement {
@@ -60,9 +62,6 @@
   @property({type: Object})
   account?: AccountDetailInfo;
 
-  @property({type: Object})
-  serverConfig?: ServerInfo;
-
   @property({type: Boolean, reflectToAttribute: true})
   disabled = false;
 
@@ -157,24 +156,19 @@
     if (!change.labels) {
       return NaN;
     }
-    const detailedLabel = change.labels[label] as DetailedLabelInfo;
-    if (!detailedLabel.all) {
+    const detailedLabel = change.labels[label];
+    if (!isDetailedLabelInfo(detailedLabel) || !detailedLabel.all) {
       return NaN;
     }
-    const detailed = detailedLabel.all
-      .filter(
-        (approval: ApprovalInfo) =>
-          reviewer._account_id === approval._account_id
-      )
-      .pop();
-    if (!detailed) {
+    const approvalInfo = getApprovalInfo(detailedLabel, reviewer);
+    if (!approvalInfo) {
       return NaN;
     }
-    if (hasOwnProperty(detailed, 'permitted_voting_range')) {
-      if (!detailed.permitted_voting_range) return NaN;
-      return detailed.permitted_voting_range.max;
-    } else if (hasOwnProperty(detailed, 'value')) {
-      // If preset, user can vote on the label.
+    if (hasOwnProperty(approvalInfo, 'permitted_voting_range')) {
+      if (!approvalInfo.permitted_voting_range) return NaN;
+      return approvalInfo.permitted_voting_range.max;
+    } else if (hasOwnProperty(approvalInfo, 'value')) {
+      // If present, user can vote on the label.
       return 0;
     }
     return NaN;
@@ -200,17 +194,29 @@
     return maxScores.join(', ');
   }
 
-  @observe('change.reviewers.*', 'change.owner', 'serverConfig')
+  _computeVote(
+    reviewer: AccountInfo,
+    change?: ChangeInfo
+  ): ApprovalInfo | undefined {
+    const codeReviewLabel = this._computeCodeReviewLabel(change);
+    if (!codeReviewLabel || !isDetailedLabelInfo(codeReviewLabel)) return;
+    return getApprovalInfo(codeReviewLabel, reviewer);
+  }
+
+  _computeCodeReviewLabel(change?: ChangeInfo): LabelInfo | undefined {
+    if (!change || !change.labels) return;
+    return getCodeReviewLabel(change.labels);
+  }
+
+  @observe('change.reviewers.*', 'change.owner')
   _reviewersChanged(
     changeRecord: PolymerDeepPropertyChange<Reviewers, Reviewers>,
-    owner: AccountInfo,
-    serverConfig: ServerInfo
+    owner: AccountInfo
   ) {
     // Polymer 2: check for undefined
     if (
       changeRecord === undefined ||
       owner === undefined ||
-      serverConfig === undefined ||
       this.change === undefined
     ) {
       return;
@@ -230,22 +236,7 @@
     }
     this._reviewers = result
       .filter(reviewer => reviewer._account_id !== owner._account_id)
-      // Sort order:
-      // 1. The user themselves
-      // 2. Human users in the attention set.
-      // 3. Other human users.
-      // 4. Service users.
-      .sort((r1, r2) => {
-        if (this.account) {
-          if (isSelf(r1, this.account)) return -1;
-          if (isSelf(r2, this.account)) return 1;
-        }
-        const a1 = hasAttention(serverConfig, r1, this.change!) ? 1 : 0;
-        const a2 = hasAttention(serverConfig, r2, this.change!) ? 1 : 0;
-        const s1 = isServiceUser(r1) ? -2 : 0;
-        const s2 = isServiceUser(r2) ? -2 : 0;
-        return a2 - a1 + s2 - s1;
-      });
+      .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
 
     if (this._reviewers.length > 8) {
       this._displayedReviewers = this._reviewers.slice(0, 6);
@@ -261,32 +252,37 @@
   _handleRemove(e: Event) {
     e.preventDefault();
     const target = (dom(e) as EventApi).rootTarget as GrAccountChip;
-    if (!target.account || !this.change) {
-      return;
-    }
+    if (!target.account || !this.change?.reviewers) return;
     const accountID = target.account._account_id || target.account.email;
-    this.disabled = true;
     if (!accountID) return;
-    this._xhrPromise = this._removeReviewer(accountID)
-      .then((response: Response | undefined) => {
-        this.disabled = false;
-        if (!response || !response.ok) {
-          return response;
+    const reviewers = this.change.reviewers;
+    let removedAccount: AccountInfo | undefined;
+    let removedType: ReviewerState | undefined;
+    for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
+      const reviewerStateByType = reviewers[type] || [];
+      reviewers[type] = reviewerStateByType;
+      for (let i = 0; i < reviewerStateByType.length; i++) {
+        if (
+          reviewerStateByType[i]._account_id === accountID ||
+          reviewerStateByType[i].email === accountID
+        ) {
+          removedAccount = reviewerStateByType[i];
+          removedType = type;
+          this.splice(`change.reviewers.${type}`, i, 1);
+          break;
         }
-        if (!this.change || !this.change.reviewers) return;
-        const reviewers = this.change.reviewers;
-        for (const type of [ReviewerState.REVIEWER, ReviewerState.CC]) {
-          const reviewerStateByType = reviewers[type] || [];
-          reviewers[type] = reviewerStateByType;
-          for (let i = 0; i < reviewerStateByType.length; i++) {
-            if (
-              reviewerStateByType[i]._account_id === accountID ||
-              reviewerStateByType[i].email === accountID
-            ) {
-              this.splice('change.reviewers.' + type, i, 1);
-              break;
-            }
-          }
+      }
+    }
+    const curChange = this.change;
+    this.disabled = true;
+    this._xhrPromise = this._removeReviewer(accountID)
+      .then(response => {
+        this.disabled = false;
+        if (!this.change?.reviewers || this.change !== curChange) return;
+        if (!response?.ok) {
+          this.push(`change.reviewers.${removedType}`, removedAccount);
+          fireAlert(this, `Cannot remove a ${removedType}`);
+          return response;
         }
         return;
       })
@@ -326,3 +322,9 @@
     return this.restApiService.removeChangeReviewer(this.change._number, id);
   }
 }
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-reviewer-list': GrReviewerList;
+  }
+}
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 25d8517..dec65e2 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
@@ -42,21 +42,24 @@
       display: inline-block;
     }
     gr-button.addReviewer {
-      --padding: 1px 4px;
+      --gr-button-padding: 1px 0px;
       vertical-align: top;
       top: 1px;
     }
     gr-button {
       line-height: var(--line-height-normal);
-      --gr-button: {
-        padding: 0px 0px;
-      }
+      --gr-button-padding: 0px;
     }
     gr-account-chip {
       line-height: var(--line-height-normal);
       vertical-align: top;
       display: inline-block;
     }
+    gr-vote-chip {
+      --gr-vote-chip-width: 14px;
+      --gr-vote-chip-height: 14px;
+      margin-right: var(--spacing-s);
+    }
   </style>
   <div class="container">
     <div>
@@ -66,10 +69,15 @@
           account="[[reviewer]]"
           change="[[change]]"
           on-remove="_handleRemove"
-          highlight-attention
+          highlightAttention
           voteable-text="[[_computeVoteableText(reviewer, change)]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]"
         >
+          <gr-vote-chip
+            slot="vote-chip"
+            vote="[[_computeVote(reviewer, change)]]"
+            label="[[_computeCodeReviewLabel(change)]]"
+          ></gr-vote-chip>
         </gr-account-chip>
       </template>
       <div class="controlsContainer" hidden$="[[!mutable]]">
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
deleted file mode 100644
index ddfe537..0000000
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.js
+++ /dev/null
@@ -1,423 +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-reviewer-list.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-reviewer-list');
-
-suite('gr-reviewer-list tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.serverConfig = {};
-
-    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'));
-    element.mutable = true;
-    assert.isFalse(element.shadowRoot
-        .querySelector('.controlsContainer').hasAttribute('hidden'));
-  });
-
-  test('add reviewer button opens reply dialog', done => {
-    element.addEventListener('show-reply-dialog', () => {
-      done();
-    });
-    flush();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.addReviewer'));
-  });
-
-  test('only show remove for removable reviewers', () => {
-    element.mutable = true;
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        REVIEWER: [
-          {
-            _account_id: 2,
-            name: 'Bojack Horseman',
-            email: 'SecretariatRulez96@hotmail.com',
-          },
-          {
-            _account_id: 3,
-            name: 'Pinky Penguin',
-          },
-        ],
-        CC: [
-          {
-            _account_id: 4,
-            name: 'Diane Nguyen',
-            email: 'macarthurfellow2B@juno.com',
-          },
-          {
-            email: 'test@e.mail',
-          },
-        ],
-      },
-      removable_reviewers: [
-        {
-          _account_id: 3,
-          name: 'Pinky Penguin',
-        },
-        {
-          _account_id: 4,
-          name: 'Diane Nguyen',
-          email: 'macarthurfellow2B@juno.com',
-        },
-        {
-          email: 'test@e.mail',
-        },
-      ],
-    };
-    flush();
-    const chips =
-        element.root.querySelectorAll('gr-account-chip');
-    assert.equal(chips.length, 4);
-
-    for (const el of Array.from(chips)) {
-      const accountID = el.account._account_id || el.account.email;
-      assert.ok(accountID);
-
-      const buttonEl = el.shadowRoot
-          .querySelector('gr-button');
-      assert.isNotNull(buttonEl);
-      if (accountID == 2) {
-        assert.isTrue(buttonEl.hasAttribute('hidden'));
-      } else {
-        assert.isFalse(buttonEl.hasAttribute('hidden'));
-      }
-    }
-  });
-
-  suite('_handleRemove', () => {
-    let removeReviewerStub;
-    let reviewersChangedSpy;
-
-    const reviewerWithId = {
-      _account_id: 2,
-      name: 'Some name',
-    };
-
-    const reviewerWithIdAndEmail = {
-      _account_id: 4,
-      name: 'Some other name',
-      email: 'example@',
-    };
-
-    const reviewerWithEmailOnly = {
-      email: 'example2@example',
-    };
-
-    let chips;
-
-    setup(() => {
-      removeReviewerStub = sinon
-          .stub(element, '_removeReviewer')
-          .returns(Promise.resolve(new Response({status: 200})));
-      element.mutable = true;
-
-      const allReviewers = [
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ];
-
-      element.change = {
-        owner: {
-          _account_id: 1,
-        },
-        reviewers: {
-          REVIEWER: allReviewers,
-        },
-        removable_reviewers: allReviewers,
-      };
-      flush();
-      chips = Array.from(element.root.querySelectorAll('gr-account-chip'));
-      assert.equal(chips.length, allReviewers.length);
-      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
-    });
-
-    test('_handleRemove for account with accountId only', async () => {
-      const accountChip = chips.find(chip =>
-        chip.account._account_id === reviewerWithId._account_id
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithIdAndEmail,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with accountId and email', async () => {
-      const accountChip = chips.find(chip =>
-        chip.account._account_id === reviewerWithIdAndEmail._account_id
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(
-          removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithEmailOnly,
-      ]);
-    });
-
-    test('_handleRemove for account with email only', async () => {
-      const accountChip = chips.find(
-          chip => chip.account.email === reviewerWithEmailOnly.email
-      );
-      accountChip._handleRemoveTap(new MouseEvent('click'));
-      await flush();
-      assert.isTrue(removeReviewerStub.calledOnce);
-      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
-      assert.isTrue(reviewersChangedSpy.called);
-      expect(element.change.reviewers.REVIEWER).to.have.deep.members([
-        reviewerWithId,
-        reviewerWithIdAndEmail,
-      ]);
-    });
-  });
-
-  test('tracking reviewers and ccs', () => {
-    let counter = 0;
-    function makeAccount() {
-      return {_account_id: counter++};
-    }
-
-    const owner = makeAccount();
-    const reviewer = makeAccount();
-    const cc = makeAccount();
-    const reviewers = {
-      REMOVED: [makeAccount()],
-      REVIEWER: [owner, reviewer],
-      CC: [owner, cc],
-    };
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer, cc]);
-
-    element.reviewersOnly = true;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [reviewer]);
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element.change = {
-      owner,
-      reviewers,
-    };
-    assert.deepEqual(element._reviewers, [cc]);
-  });
-
-  test('_handleAddTap passes mode with event', () => {
-    const fireStub = sinon.stub(element, 'dispatchEvent');
-    const e = {preventDefault() {}};
-
-    element.ccsOnly = false;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail, {value: {
-      reviewersOnly: false,
-      ccsOnly: false,
-    }});
-
-    element.reviewersOnly = true;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(
-        fireStub.lastCall.args[0].detail,
-        {value: {reviewersOnly: true, ccsOnly: false}});
-
-    element.ccsOnly = true;
-    element.reviewersOnly = false;
-    element._handleAddTap(e);
-    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
-    assert.deepEqual(fireStub.lastCall.args[0].detail,
-        {value: {ccsOnly: true, reviewersOnly: false}});
-  });
-
-  test('dont show all reviewers button with 4 reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 4);
-    assert.equal(element._reviewers.length, 4);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('account owner comes first in list of reviewers', () => {
-    const reviewers = [];
-    element.maxReviewersDisplayed = 3;
-    for (let i = 0; i < 4; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i,
-            _account_id: i});
-    }
-    element.reviewersOnly = true;
-    element.account = {
-      _account_id: 1,
-    };
-    element.change = {
-      owner: {
-        _account_id: 111,
-      },
-      reviewers: {
-        REVIEWER: reviewers,
-      },
-    };
-    flush();
-    assert.equal(element._displayedReviewers[0]._account_id, 1);
-  });
-
-  test('show all reviewers button with 9 reviewers', () => {
-    const reviewers = [];
-    for (let i = 0; i < 9; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 3);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 9);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('show all reviewers button', () => {
-    const reviewers = [];
-    for (let i = 0; i < 100; i++) {
-      reviewers.push(
-          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
-    }
-    element.ccsOnly = true;
-
-    element.change = {
-      owner: {
-        _account_id: 1,
-      },
-      reviewers: {
-        CC: reviewers,
-      },
-    };
-    assert.equal(element._hiddenReviewerCount, 94);
-    assert.equal(element._displayedReviewers.length, 6);
-    assert.equal(element._reviewers.length, 100);
-    assert.isFalse(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.hiddenReviewers'));
-
-    assert.equal(element._hiddenReviewerCount, 0);
-    assert.equal(element._displayedReviewers.length, 100);
-    assert.equal(element._reviewers.length, 100);
-    assert.isTrue(element.shadowRoot
-        .querySelector('.hiddenReviewers').hidden);
-  });
-
-  test('votable labels', () => {
-    const change = {
-      labels: {
-        Foo: {
-          all: [{_account_id: 7, permitted_voting_range: {max: 2}}],
-        },
-        Bar: {
-          all: [{_account_id: 1, permitted_voting_range: {max: 1}},
-            {_account_id: 7, permitted_voting_range: {max: 1}}],
-        },
-        FooBar: {
-          all: [{_account_id: 7, value: 0}],
-        },
-      },
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-        FooBar: ['-1', ' 0'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change),
-        'Bar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 7}, change),
-        'Foo: +2, Bar, FooBar');
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 2}, change),
-        '');
-  });
-
-  test('fails gracefully when all is not included', () => {
-    const change = {
-      labels: {Foo: {}},
-      permitted_labels: {
-        Foo: ['-1', ' 0', '+1', '+2'],
-      },
-    };
-    assert.strictEqual(
-        element._computeVoteableText({_account_id: 1}, change), '');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
new file mode 100644
index 0000000..bf15bb5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.ts
@@ -0,0 +1,485 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-reviewer-list';
+import {
+  mockPromise,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrReviewerList} from './gr-reviewer-list';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createDetailedLabelInfo,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {AccountId, EmailAddress} from '../../../types/common';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
+
+const basicFixture = fixtureFromElement('gr-reviewer-list');
+
+suite('gr-reviewer-list tests', () => {
+  let element: GrReviewerList;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    stubRestApi('removeChangeReviewer').returns(
+      Promise.resolve({...new Response(), ok: true})
+    );
+  });
+
+  test('controls hidden on immutable element', () => {
+    flush();
+    element.mutable = false;
+    assert.isTrue(
+      queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
+    );
+    element.mutable = true;
+    assert.isFalse(
+      queryAndAssert(element, '.controlsContainer').hasAttribute('hidden')
+    );
+  });
+
+  test('add reviewer button opens reply dialog', async () => {
+    const dialogShown = mockPromise();
+    element.addEventListener('show-reply-dialog', () => {
+      dialogShown.resolve();
+    });
+    await flush();
+    tap(queryAndAssert(element, '.addReviewer'));
+    await dialogShown;
+  });
+
+  test('only show remove for removable reviewers', async () => {
+    element.mutable = true;
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(1),
+      },
+      reviewers: {
+        REVIEWER: [
+          {
+            ...createAccountDetailWithId(2),
+            name: 'Bojack Horseman',
+            email: 'SecretariatRulez96@hotmail.com' as EmailAddress,
+          },
+          {
+            _account_id: 3 as AccountId,
+            name: 'Pinky Penguin',
+          },
+        ],
+        CC: [
+          {
+            ...createAccountDetailWithId(4),
+            name: 'Diane Nguyen',
+            email: 'macarthurfellow2B@juno.com' as EmailAddress,
+          },
+          {
+            email: 'test@e.mail' as EmailAddress,
+          },
+        ],
+      },
+      removable_reviewers: [
+        {
+          _account_id: 3 as AccountId,
+          name: 'Pinky Penguin',
+        },
+        {
+          ...createAccountDetailWithId(4),
+          name: 'Diane Nguyen',
+          email: 'macarthurfellow2B@juno.com' as EmailAddress,
+        },
+        {
+          email: 'test@e.mail' as EmailAddress,
+        },
+      ],
+    };
+    await flush();
+    const chips = element.root!.querySelectorAll('gr-account-chip');
+    assert.equal(chips.length, 4);
+
+    for (const el of Array.from(chips)) {
+      const accountID = el.account!._account_id || el.account!.email;
+      assert.ok(accountID);
+
+      const buttonEl = queryAndAssert(el, 'gr-button');
+      if (accountID === 2) {
+        assert.isTrue(buttonEl.hasAttribute('hidden'));
+      } else {
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+      }
+    }
+  });
+
+  suite('_handleRemove', () => {
+    let removeReviewerStub: sinon.SinonStub;
+    let reviewersChangedSpy: sinon.SinonSpy;
+
+    const reviewerWithId = {
+      ...createAccountDetailWithId(2),
+      name: 'Some name',
+    };
+
+    const reviewerWithIdAndEmail = {
+      ...createAccountDetailWithId(4),
+      name: 'Some other name',
+      email: 'example@' as EmailAddress,
+    };
+
+    const reviewerWithEmailOnly = {
+      email: 'example2@example' as EmailAddress,
+    };
+
+    let chips: GrAccountChip[];
+
+    setup(() => {
+      removeReviewerStub = sinon
+        .stub(element, '_removeReviewer')
+        .returns(Promise.resolve(new Response()));
+      element.mutable = true;
+
+      const allReviewers = [
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ];
+
+      element.change = {
+        ...createChange(),
+        owner: {
+          ...createAccountDetailWithId(1),
+        },
+        reviewers: {
+          REVIEWER: allReviewers,
+        },
+        removable_reviewers: allReviewers,
+      };
+      flush();
+      chips = Array.from(element.root!.querySelectorAll('gr-account-chip'));
+      assert.equal(chips.length, allReviewers.length);
+      reviewersChangedSpy = sinon.spy(element, '_reviewersChanged');
+    });
+
+    test('_handleRemove for account with accountId only', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!._account_id === reviewerWithId._account_id
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithId._account_id));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithIdAndEmail,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with accountId and email', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!._account_id === reviewerWithIdAndEmail._account_id
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(
+        removeReviewerStub.calledWith(reviewerWithIdAndEmail._account_id)
+      );
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithEmailOnly,
+      ]);
+    });
+
+    test('_handleRemove for account with email only', async () => {
+      const accountChip = chips.find(
+        chip => chip.account!.email === reviewerWithEmailOnly.email
+      );
+      accountChip!._handleRemoveTap(new MouseEvent('click'));
+      await flush();
+      assert.isTrue(removeReviewerStub.calledOnce);
+      assert.isTrue(removeReviewerStub.calledWith(reviewerWithEmailOnly.email));
+      assert.isTrue(reviewersChangedSpy.called);
+      expect(element.change!.reviewers.REVIEWER).to.have.deep.members([
+        reviewerWithId,
+        reviewerWithIdAndEmail,
+      ]);
+    });
+  });
+
+  test('tracking reviewers and ccs', () => {
+    let counter = 0;
+    function makeAccount() {
+      return {_account_id: counter++ as AccountId};
+    }
+
+    const owner = makeAccount();
+    const reviewer = makeAccount();
+    const cc = makeAccount();
+    const reviewers = {
+      REMOVED: [makeAccount()],
+      REVIEWER: [owner, reviewer],
+      CC: [owner, cc],
+    };
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer, cc]);
+
+    element.reviewersOnly = true;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [reviewer]);
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element.change = {
+      ...createChange(),
+      owner,
+      reviewers,
+    };
+    assert.deepEqual(element._reviewers, [cc]);
+  });
+
+  test('_handleAddTap passes mode with event', () => {
+    const fireStub = sinon.stub(element, 'dispatchEvent');
+    const e = {...new Event(''), preventDefault() {}};
+
+    element.ccsOnly = false;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {
+        reviewersOnly: false,
+        ccsOnly: false,
+      },
+    });
+
+    element.reviewersOnly = true;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {reviewersOnly: true, ccsOnly: false},
+    });
+
+    element.ccsOnly = true;
+    element.reviewersOnly = false;
+    element._handleAddTap(e);
+    assert.equal(fireStub.lastCall.args[0].type, 'show-reply-dialog');
+    assert.deepEqual((fireStub.lastCall.args[0] as CustomEvent).detail, {
+      value: {ccsOnly: true, reviewersOnly: false},
+    });
+  });
+
+  test('dont show all reviewers button with 4 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 4; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 4);
+    assert.equal(element._reviewers.length, 4);
+    assert.isTrue(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('account owner comes first in list of reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 4; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.reviewersOnly = true;
+    element.account = {
+      ...createAccountDetailWithId(1),
+    };
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(11),
+      },
+      reviewers: {
+        REVIEWER: reviewers,
+      },
+    };
+    flush();
+    assert.equal(element._displayedReviewers[0]._account_id, 1 as AccountId);
+  });
+
+  test('show all reviewers button with 9 reviewers', () => {
+    const reviewers = [];
+    for (let i = 0; i < 9; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 3);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 9);
+    assert.isFalse(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('show all reviewers button', () => {
+    const reviewers = [];
+    for (let i = 0; i < 100; i++) {
+      reviewers.push({
+        ...createAccountDetailWithId(i),
+        email: `${i}reviewer@google.com` as EmailAddress,
+        name: `reviewer${i}`,
+      });
+    }
+    element.ccsOnly = true;
+
+    element.change = {
+      ...createChange(),
+      owner: {
+        ...createAccountDetailWithId(111),
+      },
+      reviewers: {
+        CC: reviewers,
+      },
+    };
+    assert.equal(element._hiddenReviewerCount, 94);
+    assert.equal(element._displayedReviewers.length, 6);
+    assert.equal(element._reviewers.length, 100);
+    assert.isFalse(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+
+    tap(queryAndAssert(element, '.hiddenReviewers'));
+
+    assert.equal(element._hiddenReviewerCount, 0);
+    assert.equal(element._displayedReviewers.length, 100);
+    assert.equal(element._reviewers.length, 100);
+    assert.isTrue(
+      (queryAndAssert(element, '.hiddenReviewers') as GrButton).hidden
+    );
+  });
+
+  test('votable labels', () => {
+    const change = {
+      ...createChange(),
+      labels: {
+        Foo: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 2, min: 0},
+            },
+          ],
+        },
+        Bar: {
+          ...createDetailedLabelInfo(),
+          all: [
+            {
+              ...createAccountDetailWithId(1),
+              permitted_voting_range: {max: 1, min: 0},
+            },
+            {
+              _account_id: 7 as AccountId,
+              permitted_voting_range: {max: 1, min: 0},
+            },
+          ],
+        },
+        FooBar: {
+          ...createDetailedLabelInfo(),
+          all: [{_account_id: 7 as AccountId, value: 0}],
+        },
+      },
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+        FooBar: ['-1', ' 0'],
+      },
+    };
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      'Bar'
+    );
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(7)}, change),
+      'Foo: +2, Bar, FooBar'
+    );
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(2)}, change),
+      ''
+    );
+  });
+
+  test('fails gracefully when all is not included', () => {
+    const change = {
+      ...createChange(),
+      labels: {Foo: {}},
+      permitted_labels: {
+        Foo: ['-1', ' 0', '+1', '+2'],
+      },
+    };
+    assert.strictEqual(
+      element._computeVoteableText({...createAccountDetailWithId(1)}, change),
+      ''
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
new file mode 100644
index 0000000..002ac56
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirement-hovercard/gr-submit-requirement-hovercard.ts
@@ -0,0 +1,260 @@
+/**
+ * @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 '../../shared/gr-button/gr-button';
+import '../../shared/gr-label-info/gr-label-info';
+import {customElement, property} from 'lit/decorators';
+import {
+  AccountInfo,
+  SubmitRequirementExpressionInfo,
+  SubmitRequirementResultInfo,
+} from '../../../api/rest-api';
+import {
+  extractAssociatedLabels,
+  iconForStatus,
+} from '../../../utils/label-util';
+import {ParsedChangeInfo} from '../../../types/types';
+import {Label} from '../gr-change-requirements/gr-change-requirements';
+import {css, html, LitElement} from 'lit';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {fontStyles} from '../../../styles/gr-font-styles';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
+
+@customElement('gr-submit-requirement-hovercard')
+export class GrSubmitRequirementHovercard extends base {
+  @property({type: Object})
+  requirement?: SubmitRequirementResultInfo;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable = false;
+
+  @property({type: Boolean})
+  expanded = false;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      base.styles || [],
+      css`
+        #container {
+          min-width: 356px;
+          max-width: 356px;
+          padding: var(--spacing-xl) 0 var(--spacing-m) 0;
+        }
+        section.label {
+          display: table-row;
+        }
+        .label-title {
+          min-width: 10em;
+          padding-top: var(--spacing-s);
+        }
+        .label-value {
+          padding-top: var(--spacing-s);
+        }
+        .label-title,
+        .label-value {
+          display: table-cell;
+          vertical-align: top;
+        }
+        .row {
+          display: flex;
+        }
+        .title {
+          color: var(--deemphasized-text-color);
+          margin-right: var(--spacing-m);
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: center;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        div.sectionIcon iron-icon {
+          position: relative;
+          width: 20px;
+          height: 20px;
+        }
+        .condition {
+          background-color: var(--gray-background);
+          padding: var(--spacing-m);
+          flex-grow: 1;
+        }
+        .expression {
+          color: var(--gray-foreground);
+        }
+        iron-icon.check {
+          color: var(--success-foreground);
+        }
+        iron-icon.close {
+          color: var(--warning-foreground);
+        }
+        .showConditions iron-icon {
+          color: inherit;
+        }
+        div.showConditions {
+          border-top: 1px solid var(--border-color);
+          margin-top: var(--spacing-m);
+          padding: var(--spacing-m) var(--spacing-xl) 0;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.requirement) return;
+    const icon = iconForStatus(this.requirement.status);
+    return html` <div id="container" role="tooltip" tabindex="-1">
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          <h3 class="name heading-3">
+            <span>${this.requirement.name}</span>
+          </h3>
+        </div>
+      </div>
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          <div class="row">
+            <div class="title">Status</div>
+            <div>${this.requirement.status}</div>
+          </div>
+        </div>
+      </div>
+      ${this.renderLabelSection()} ${this.renderConditionSection()}
+    </div>`;
+  }
+
+  private renderLabelSection() {
+    const labels = this.computeLabels();
+    const showLabelName = labels.length >= 2;
+    return html` <div class="section">
+      <div class="sectionIcon"></div>
+      <div class="row">
+        <div>${labels.map(l => this.renderLabel(l, showLabelName))}</div>
+      </div>
+    </div>`;
+  }
+
+  private renderLabel(label: Label, showLabelName: boolean) {
+    return html`
+      ${showLabelName ? html`<div>${label.labelName} votes</div>` : ''}
+      <gr-label-info
+        .change=${this.change}
+        .account=${this.account}
+        .mutable=${this.mutable}
+        .label="${label.labelName}"
+        .labelInfo="${label.labelInfo}"
+      ></gr-label-info>
+    `;
+  }
+
+  private renderConditionSection() {
+    if (!this.expanded) {
+      return html` <div class="showConditions">
+        <gr-button
+          link=""
+          class="showConditions"
+          @click="${(_: MouseEvent) => this.handleShowConditions()}"
+        >
+          View condition
+          <iron-icon icon="gr-icons:expand-more"></iron-icon
+        ></gr-button>
+      </div>`;
+    } else {
+      return html`
+        <div class="section">
+          <div class="sectionIcon">
+            <iron-icon icon="gr-icons:description"></iron-icon>
+          </div>
+          <div class="sectionContent">${this.requirement?.description}</div>
+        </div>
+        ${this.renderCondition(
+          'Blocking condition',
+          this.requirement?.submittability_expression_result
+        )}
+        ${this.renderCondition(
+          'Application condition',
+          this.requirement?.applicability_expression_result
+        )}
+        ${this.renderCondition(
+          'Override condition',
+          this.requirement?.override_expression_result
+        )}
+      `;
+    }
+  }
+
+  private computeLabels() {
+    if (!this.requirement) return [];
+    const requirementLabels = extractAssociatedLabels(this.requirement);
+    const labels = this.change?.labels ?? {};
+
+    const allLabels: Label[] = [];
+
+    for (const label of Object.keys(labels)) {
+      if (requirementLabels.includes(label)) {
+        allLabels.push({
+          labelName: label,
+          icon: '',
+          style: '',
+          labelInfo: labels[label],
+        });
+      }
+    }
+    return allLabels;
+  }
+
+  private renderCondition(
+    name: string,
+    expression?: SubmitRequirementExpressionInfo
+  ) {
+    if (!expression) return '';
+    return html`
+      <div class="section">
+        <div class="sectionIcon"></div>
+        <div class="sectionContent condition">
+          ${name}:<br />
+          <span class="expression"> ${expression.expression} </span>
+        </div>
+      </div>
+    `;
+  }
+
+  private handleShowConditions() {
+    this.expanded = true;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-submit-requirement-hovercard': GrSubmitRequirementHovercard;
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
new file mode 100644
index 0000000..e8859fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-submit-requirements/gr-submit-requirements.ts
@@ -0,0 +1,393 @@
+/**
+ * @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 '../../shared/gr-label-info/gr-label-info';
+import '../gr-submit-requirement-hovercard/gr-submit-requirement-hovercard';
+import '../gr-trigger-vote-hovercard/gr-trigger-vote-hovercard';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {ParsedChangeInfo} from '../../../types/types';
+import {
+  AccountInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+  LabelNameToInfoMap,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../../../api/rest-api';
+import {unique} from '../../../utils/common-util';
+import {
+  extractAssociatedLabels,
+  getAllUniqueApprovals,
+  hasNeutralStatus,
+  hasVotes,
+  iconForStatus,
+  orderSubmitRequirements,
+} from '../../../utils/label-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {charsOnly, pluralize} from '../../../utils/string-util';
+import {subscribe} from '../../lit/subscription-controller';
+import {
+  allRunsLatestPatchsetLatestAttempt$,
+  CheckRun,
+} from '../../../services/checks/checks-model';
+import {getResultsOf, hasResultsOf} from '../../../services/checks/checks-util';
+import {Category} from '../../../api/checks';
+import '../../shared/gr-vote-chip/gr-vote-chip';
+
+@customElement('gr-submit-requirements')
+export class GrSubmitRequirements extends LitElement {
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable?: boolean;
+
+  @state()
+  runs: CheckRun[] = [];
+
+  static override get styles() {
+    return [
+      fontStyles,
+      css`
+        .metadata-title {
+          color: var(--deemphasized-text-color);
+          padding-left: var(--metadata-horizontal-padding);
+          margin: 0 0 var(--spacing-s);
+          border-top: 1px solid var(--border-color);
+          padding-top: var(--spacing-s);
+        }
+        iron-icon {
+          width: var(--line-height-normal, 20px);
+          height: var(--line-height-normal, 20px);
+        }
+        iron-icon.check,
+        iron-icon.overridden {
+          color: var(--success-foreground);
+        }
+        iron-icon.close {
+          color: var(--error-foreground);
+        }
+        .requirements,
+        section.trigger-votes {
+          margin-left: var(--spacing-l);
+        }
+        .trigger-votes {
+          padding-top: var(--spacing-s);
+          display: flex;
+          flex-wrap: wrap;
+          gap: var(--spacing-s);
+          /* Setting max-width as defined in Submit Requirements design,
+           *  to wrap overflowed items to next row.
+           */
+          max-width: 390px;
+        }
+        gr-limited-text.name {
+          font-weight: var(--font-weight-bold);
+        }
+        table {
+          border-collapse: collapse;
+          border-spacing: 0;
+        }
+        td {
+          padding: var(--spacing-s);
+        }
+        .votes-cell {
+          display: flex;
+        }
+        .check-error {
+          margin-right: var(--spacing-l);
+        }
+        .check-error iron-icon {
+          color: var(--error-foreground);
+          vertical-align: top;
+        }
+        gr-vote-chip {
+          margin-right: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  constructor() {
+    super();
+    subscribe(this, allRunsLatestPatchsetLatestAttempt$, x => (this.runs = x));
+  }
+
+  override render() {
+    let submit_requirements = orderSubmitRequirements(
+      this.change?.submit_requirements ?? []
+    ).filter(req => req.status !== SubmitRequirementStatus.NOT_APPLICABLE);
+
+    const hasNonLegacyRequirements = submit_requirements.some(
+      req => req.is_legacy === false
+    );
+    if (hasNonLegacyRequirements) {
+      submit_requirements = submit_requirements.filter(
+        req => req.is_legacy === false
+      );
+    }
+
+    return html` <h3
+        class="metadata-title heading-3"
+        id="submit-requirements-caption"
+      >
+        Submit Requirements
+      </h3>
+      <table class="requirements" aria-labelledby="submit-requirements-caption">
+        <thead hidden>
+          <tr>
+            <th>Status</th>
+            <th>Name</th>
+            <th>Votes</th>
+          </tr>
+        </thead>
+        <tbody>
+          ${submit_requirements.map(
+            requirement => html`<tr
+              id="requirement-${charsOnly(requirement.name)}"
+            >
+              <td>${this.renderStatus(requirement.status)}</td>
+              <td class="name">
+                <gr-limited-text
+                  class="name"
+                  limit="25"
+                  .text="${requirement.name}"
+                ></gr-limited-text>
+              </td>
+              <td>
+                <div class="votes-cell">
+                  ${this.renderVotes(requirement)}
+                  ${this.renderChecks(requirement)}
+                </div>
+              </td>
+            </tr>`
+          )}
+        </tbody>
+      </table>
+      ${submit_requirements.map(
+        requirement => html`
+          <gr-submit-requirement-hovercard
+            for="requirement-${charsOnly(requirement.name)}"
+            .requirement="${requirement}"
+            .change="${this.change}"
+            .account="${this.account}"
+            .mutable="${this.mutable ?? false}"
+          ></gr-submit-requirement-hovercard>
+        `
+      )}
+      ${this.renderTriggerVotes(submit_requirements)}`;
+  }
+
+  renderStatus(status: SubmitRequirementStatus) {
+    const icon = iconForStatus(status);
+    return html`<iron-icon
+      class="${icon}"
+      icon="gr-icons:${icon}"
+      role="img"
+      aria-label="${status.toLowerCase()}"
+    ></iron-icon>`;
+  }
+
+  renderVotes(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(requirement);
+    const allLabels = this.change?.labels ?? {};
+    const associatedLabels = Object.keys(allLabels).filter(label =>
+      requirementLabels.includes(label)
+    );
+
+    const everyAssociatedLabelsIsWithoutVotes = associatedLabels.every(
+      label => !hasVotes(allLabels[label])
+    );
+    if (everyAssociatedLabelsIsWithoutVotes) return html`No votes`;
+
+    return associatedLabels.map(label =>
+      this.renderLabelVote(label, allLabels)
+    );
+  }
+
+  renderLabelVote(label: string, labels: LabelNameToInfoMap) {
+    const labelInfo = labels[label];
+    if (isDetailedLabelInfo(labelInfo)) {
+      const uniqueApprovals = getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+      return uniqueApprovals.map(
+        approvalInfo =>
+          html`<gr-vote-chip
+            .vote="${approvalInfo}"
+            .label="${labelInfo}"
+            .more="${(labelInfo.all ?? []).filter(
+              other => other.value === approvalInfo.value
+            ).length > 1}"
+          ></gr-vote-chip>`
+      );
+    } else if (isQuickLabelInfo(labelInfo)) {
+      return [html`<gr-vote-chip .label="${labelInfo}"></gr-vote-chip>`];
+    } else {
+      return html``;
+    }
+  }
+
+  renderChecks(requirement: SubmitRequirementResultInfo) {
+    const requirementLabels = extractAssociatedLabels(requirement);
+    const requirementRuns = this.runs
+      .filter(run => hasResultsOf(run, Category.ERROR))
+      .filter(
+        run => run.labelName && requirementLabels.includes(run.labelName)
+      );
+    const runsCount = requirementRuns.reduce(
+      (sum, run) => sum + getResultsOf(run, Category.ERROR).length,
+      0
+    );
+    if (runsCount > 0) {
+      return html`<span class="check-error"
+        ><iron-icon icon="gr-icons:error"></iron-icon>${pluralize(
+          runsCount,
+          'error'
+        )}</span
+      >`;
+    }
+    return;
+  }
+
+  renderTriggerVotes(submitReqs: SubmitRequirementResultInfo[]) {
+    const labels = this.change?.labels ?? {};
+    const allLabels = Object.keys(labels);
+    const labelAssociatedWithSubmitReqs = submitReqs
+      .flatMap(req => extractAssociatedLabels(req))
+      .filter(unique);
+    const triggerVotes = allLabels
+      .filter(label => !labelAssociatedWithSubmitReqs.includes(label))
+      .filter(label => hasVotes(labels[label]));
+    if (!triggerVotes.length) return;
+    return html`<h3 class="metadata-title heading-3">Trigger Votes</h3>
+      <section class="trigger-votes">
+        ${triggerVotes.map(
+          label =>
+            html`<gr-trigger-vote
+              .label="${label}"
+              .labelInfo="${labels[label]}"
+              .change="${this.change}"
+              .account="${this.account}"
+              .mutable="${this.mutable ?? false}"
+            ></gr-trigger-vote>`
+        )}
+      </section>`;
+  }
+}
+
+@customElement('gr-trigger-vote')
+export class GrTriggerVote extends LitElement {
+  @property()
+  label?: string;
+
+  @property({type: Object})
+  labelInfo?: LabelInfo;
+
+  @property({type: Object})
+  change?: ParsedChangeInfo;
+
+  @property({type: Object})
+  account?: AccountInfo;
+
+  @property({type: Boolean})
+  mutable?: boolean;
+
+  static override get styles() {
+    return css`
+      :host {
+        display: block;
+      }
+      .container {
+        box-sizing: border-box;
+        border: 1px solid var(--border-color);
+        border-radius: calc(var(--border-radius) + 2px);
+        background-color: var(--background-color-primary);
+        display: flex;
+        padding: 0;
+        padding-left: var(--spacing-s);
+        padding-right: var(--spacing-xxs);
+        align-items: center;
+      }
+      .label {
+        padding-right: var(--spacing-s);
+        font-weight: var(--font-weight-bold);
+      }
+      gr-vote-chip {
+        --gr-vote-chip-width: 14px;
+        --gr-vote-chip-height: 14px;
+        margin-right: 0px;
+        margin-left: var(--spacing-xs);
+      }
+      gr-vote-chip:first-of-type {
+        margin-left: 0px;
+      }
+    `;
+  }
+
+  override render() {
+    if (!this.labelInfo) return;
+    return html`
+      <div class="container">
+        <gr-trigger-vote-hovercard .labelName=${this.label}>
+          <gr-label-info
+            slot="label-info"
+            .change=${this.change}
+            .account=${this.account}
+            .mutable=${this.mutable}
+            .label=${this.label}
+            .labelInfo=${this.labelInfo}
+            .showAllReviewers=${false}
+          ></gr-label-info>
+        </gr-trigger-vote-hovercard>
+        <span class="label">${this.label}</span>
+        ${this.renderVotes()}
+      </div>
+    `;
+  }
+
+  private renderVotes() {
+    const {labelInfo} = this;
+    if (!labelInfo) return;
+    if (isDetailedLabelInfo(labelInfo)) {
+      const approvals = getAllUniqueApprovals(labelInfo).filter(
+        approval => !hasNeutralStatus(labelInfo, approval)
+      );
+      return approvals.map(
+        approvalInfo => html`<gr-vote-chip
+          .vote="${approvalInfo}"
+          .label="${labelInfo}"
+        ></gr-vote-chip>`
+      );
+    } else if (isQuickLabelInfo(labelInfo)) {
+      return [html`<gr-vote-chip .label="${this.labelInfo}"></gr-vote-chip>`];
+    } else {
+      return html``;
+    }
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-submit-requirements': GrSubmitRequirements;
+    'gr-trigger-vote': GrTriggerVote;
+  }
+}
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 2f4adf5..deea4ab 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
@@ -14,21 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '@polymer/paper-toggle-button/paper-toggle-button';
 import '../../../styles/shared-styles';
 import '../../shared/gr-comment-thread/gr-comment-thread';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import '../../shared/gr-dropdown-list/gr-dropdown-list';
+
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-thread-list_html';
 import {parseDate} from '../../../utils/date-util';
 
 import {CommentSide, SpecialFilePath} from '../../../constants/constants';
-import {customElement, observe, property} from '@polymer/decorators';
+import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
   PolymerSpliceChange,
   PolymerDeepPropertyChange,
 } from '@polymer/polymer/interfaces';
-import {ChangeInfo} from '../../../types/common';
+import {
+  AccountDetailInfo,
+  AccountInfo,
+  ChangeInfo,
+  NumericChangeId,
+  UrlEncodedCommentId,
+} from '../../../types/common';
 import {
   CommentThread,
   isDraft,
@@ -36,11 +42,15 @@
   isDraftThread,
   isRobotThread,
   hasHumanReply,
+  getCommentAuthors,
+  computeId,
+  UIComment,
 } from '../../../utils/comment-util';
 import {pluralize} from '../../../utils/string-util';
-import {fireThreadListModifiedEvent} from '../../../utils/event-util';
-import {assertNever} from '../../../utils/common-util';
+import {assertIsDefined, assertNever} from '../../../utils/common-util';
 import {CommentTabState} from '../../../types/events';
+import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list';
+import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip';
 
 interface CommentThreadWithInfo {
   thread: CommentThread;
@@ -52,6 +62,13 @@
   updated?: Date;
 }
 
+enum SortDropdownState {
+  TIMESTAMP = 'Latest timestamp',
+  FILES = 'Files',
+}
+
+export const __testOnly_SortDropdownState = SortDropdownState;
+
 @customElement('gr-thread-list')
 export class GrThreadList extends PolymerElement {
   static get template() {
@@ -65,7 +82,7 @@
   threads: CommentThread[] = [];
 
   @property({type: String})
-  changeNum?: string;
+  changeNum?: NumericChangeId;
 
   @property({type: Boolean})
   loggedIn?: boolean;
@@ -79,7 +96,7 @@
   @property({
     computed:
       '_computeDisplayedThreads(_sortedThreads.*, unresolvedOnly, ' +
-      '_draftsOnly, onlyShowRobotCommentsWithHumanReply)',
+      '_draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)',
     type: Array,
   })
   _displayedThreads: CommentThread[] = [];
@@ -97,11 +114,32 @@
   onlyShowRobotCommentsWithHumanReply = false;
 
   @property({type: Boolean})
-  hideToggleButtons = false;
+  hideDropdown = false;
 
   @property({type: Object, observer: '_commentTabStateChange'})
   commentTabState?: CommentTabState;
 
+  @property({type: Object})
+  sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP;
+
+  @property({type: Array, notify: true})
+  selectedAuthors: AccountInfo[] = [];
+
+  @property({type: Object})
+  account?: AccountDetailInfo;
+
+  @computed('unresolvedOnly', '_draftsOnly')
+  get commentsDropdownValue() {
+    // set initial value and triggered when comment summary chips are clicked
+    if (this._draftsOnly) return CommentTabState.DRAFTS;
+    return this.unresolvedOnly
+      ? CommentTabState.UNRESOLVED
+      : CommentTabState.SHOW_ALL;
+  }
+
+  @property({type: String})
+  scrollCommentId?: UrlEncodedCommentId;
+
   _showEmptyThreadsMessage(
     threads: CommentThread[],
     displayedThreads: CommentThread[],
@@ -112,7 +150,7 @@
   }
 
   _computeEmptyThreadsMessage(threads: CommentThread[]) {
-    return !threads.length ? 'No comments.' : 'No unresolved comments';
+    return !threads.length ? 'No comments' : 'No unresolved comments';
   }
 
   _showPartyPopper(threads: CommentThread[]) {
@@ -146,7 +184,86 @@
     this.unresolvedOnly = !this.unresolvedOnly;
   }
 
+  getSortDropdownEntires() {
+    return [
+      {text: SortDropdownState.FILES, value: SortDropdownState.FILES},
+      {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP},
+    ];
+  }
+
+  getCommentsDropdownEntires(threads: CommentThread[], loggedIn?: boolean) {
+    const items: DropdownItem[] = [
+      {
+        text: `Unresolved (${this._countUnresolved(threads)})`,
+        value: CommentTabState.UNRESOLVED,
+      },
+      {
+        text: `All (${this._countAllThreads(threads)})`,
+        value: CommentTabState.SHOW_ALL,
+      },
+    ];
+    if (loggedIn)
+      items.splice(1, 0, {
+        text: `Drafts (${this._countDrafts(threads)})`,
+        value: CommentTabState.DRAFTS,
+      });
+    return items;
+  }
+
+  getCommentAuthors(threads?: CommentThread[], account?: AccountDetailInfo) {
+    return getCommentAuthors(threads, account);
+  }
+
+  handleAccountClicked(e: MouseEvent) {
+    const account = (e.target as GrAccountChip).account;
+    assertIsDefined(account, 'account');
+    const index = this.selectedAuthors.findIndex(
+      author => author._account_id === account._account_id
+    );
+    if (index === -1) this.push('selectedAuthors', account);
+    else this.splice('selectedAuthors', index, 1);
+    // re-assign so that isSelected template method is called
+    this.selectedAuthors = [...this.selectedAuthors];
+  }
+
+  isSelected(author: AccountInfo, selectedAuthors: AccountInfo[]) {
+    return selectedAuthors.some(a => a._account_id === author._account_id);
+  }
+
+  computeShouldScrollIntoView(
+    comments: UIComment[],
+    scrollCommentId?: UrlEncodedCommentId
+  ) {
+    const comment = comments?.[0];
+    if (!comment) return false;
+    return computeId(comment) === scrollCommentId;
+  }
+
+  handleSortDropdownValueChange(e: CustomEvent) {
+    this.sortDropdownValue = e.detail.value;
+    /*
+     * Ideally we would have updateSortedThreads observe on sortDropdownValue
+     * but the method triggered re-render only when the length of threads
+     * changes, hence keep the explicit resortThreads method
+     */
+    this.resortThreads(this.threads);
+  }
+
+  handleCommentsDropdownValueChange(e: CustomEvent) {
+    const value = e.detail.value;
+    if (value === CommentTabState.UNRESOLVED) this._handleOnlyUnresolved();
+    else if (value === CommentTabState.DRAFTS) this._handleOnlyDrafts();
+    else this._handleAllComments();
+  }
+
   _compareThreads(c1: CommentThreadWithInfo, c2: CommentThreadWithInfo) {
+    if (
+      this.sortDropdownValue === SortDropdownState.TIMESTAMP &&
+      !this.hideDropdown
+    ) {
+      if (c1.updated && c2.updated) return c1.updated > c2.updated ? -1 : 1;
+    }
+
     if (c1.thread.path !== c2.thread.path) {
       // '/PATCHSET' will not come before '/COMMIT' when sorting
       // alphabetically so move it to the front explicitly
@@ -202,6 +319,15 @@
     return 0;
   }
 
+  resortThreads(threads: CommentThread[]) {
+    const threadsWithInfo = threads.map(thread =>
+      this._getThreadWithStatusInfo(thread)
+    );
+    this._sortedThreads = threadsWithInfo
+      .sort((t1, t2) => this._compareThreads(t1, t2))
+      .map(threadInfo => threadInfo.thread);
+  }
+
   /**
    * Observer on threads and update _sortedThreads when needed.
    * Order as follows:
@@ -241,10 +367,14 @@
     // with addedCount > 0 or removed.length > 0 should also cause re-sorting
     // and re-rendering, but apparently spliceRecord is always undefined for
     // whatever reason.
-    if (this._sortedThreads.length === threads.length) {
+    // If there is an unsaved draftThread which is supposed to be replaced with
+    // a saved draftThread then resort all threads
+    const unsavedThread = this._sortedThreads.some(thread =>
+      thread.rootId?.includes('draft__')
+    );
+    if (this._sortedThreads.length === threads.length && !unsavedThread) {
       // Instead of replacing the _sortedThreads which will trigger a re-render,
       // we override all threads inside of it.
-
       for (const thread of threads) {
         const idxInSortedThreads = this._sortedThreads.findIndex(
           t => t.rootId === thread.rootId
@@ -254,12 +384,7 @@
       return;
     }
 
-    const threadsWithInfo = threads.map(thread =>
-      this._getThreadWithStatusInfo(thread)
-    );
-    this._sortedThreads = threadsWithInfo
-      .sort((t1, t2) => this._compareThreads(t1, t2))
-      .map(threadInfo => threadInfo.thread);
+    this.resortThreads(threads);
   }
 
   _computeDisplayedThreads(
@@ -269,7 +394,8 @@
     >,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     if (!sortedThreadsRecord || !sortedThreadsRecord.base) return [];
     return sortedThreadsRecord.base.filter(t =>
@@ -277,7 +403,8 @@
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
   }
@@ -287,14 +414,16 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
     const index = threads.findIndex(t => t.rootId === thread.rootId);
@@ -309,14 +438,16 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     const threads = displayedThreads.filter(t =>
       this._shouldShowThread(
         t,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
     const index = threads.findIndex(t => t.rootId === thread.rootId);
@@ -330,7 +461,8 @@
         thread,
         unresolvedOnly,
         draftsOnly,
-        onlyShowRobotCommentsWithHumanReply
+        onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors
       )
     );
   }
@@ -339,7 +471,8 @@
     thread: CommentThread,
     unresolvedOnly?: boolean,
     draftsOnly?: boolean,
-    onlyShowRobotCommentsWithHumanReply?: boolean
+    onlyShowRobotCommentsWithHumanReply?: boolean,
+    selectedAuthors?: AccountInfo[]
   ) {
     if (
       [
@@ -347,11 +480,26 @@
         unresolvedOnly,
         draftsOnly,
         onlyShowRobotCommentsWithHumanReply,
+        selectedAuthors,
       ].includes(undefined)
     ) {
       return false;
     }
 
+    if (selectedAuthors!.length) {
+      if (
+        !thread.comments.some(
+          c =>
+            c.author &&
+            selectedAuthors!.some(
+              author => c.author!._account_id === author._account_id
+            )
+        )
+      ) {
+        return false;
+      }
+    }
+
     if (
       !draftsOnly &&
       !unresolvedOnly &&
@@ -411,25 +559,6 @@
     };
   }
 
-  removeThread(rootId: string) {
-    for (let i = 0; i < this.threads.length; i++) {
-      if (this.threads[i].rootId === rootId) {
-        this.splice('threads', i, 1);
-        // Needed to ensure threads get re-rendered in the correct order.
-        flush();
-        return;
-      }
-    }
-  }
-
-  _handleThreadDiscard(e: CustomEvent) {
-    this.removeThread(e.detail.rootId);
-  }
-
-  _handleCommentsChanged(e: CustomEvent) {
-    fireThreadListModifiedEvent(this, e.detail.rootId, e.detail.path);
-  }
-
   _isOnParent(side?: CommentSide) {
     // TODO(TS): That looks like a bug? CommentSide.REVISION will also be
     // classified as parent??
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 794e9c9..3eb28c9 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
@@ -20,7 +20,6 @@
   <style include="shared-styles">
     #threads {
       display: block;
-      padding: var(--spacing-l);
     }
     gr-comment-thread {
       display: block;
@@ -33,7 +32,7 @@
       border-top: 1px solid var(--border-color);
       display: flex;
       justify-content: left;
-      padding: var(--spacing-m) var(--spacing-l);
+      padding: var(--spacing-s) var(--spacing-l);
     }
     .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft],
     .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved],
@@ -44,64 +43,76 @@
       border-top: 1px solid var(--border-color);
       margin-top: var(--spacing-xl);
     }
-    .resolved-comments-message {
-      color: var(--link-color);
-      cursor: pointer;
-    }
     .show-resolved-comments {
       box-shadow: none;
       padding-left: var(--spacing-m);
     }
-    .header .categoryRadio {
-      height: 18px;
-      width: 18px;
-    }
-    .header label {
-      padding-left: 8px;
-      margin-right: 16px;
-    }
     .partypopper{
       margin-right: var(--spacing-s);
     }
+    gr-dropdown-list {
+      --trigger-style-text-color: var(--primary-text-color);
+      --trigger-style-font-family: var(--font-family);
+    }
+    .filter-text, .sort-text, .author-text {
+      margin-right: var(--spacing-s);
+      color: var(--deemphasized-text-color);
+    }
+    .author-text {
+      margin-left: var(--spacing-m);
+    }
+    gr-account-label {
+      --account-max-length: 120px;
+      display: inline-block;
+      user-select: none;
+      --label-border-radius: 8px;
+      margin: 0 var(--spacing-xs);
+      padding: var(--spacing-xs) var(--spacing-m);
+      line-height: var(--line-height-normal);
+      cursor: pointer;
+    }
+    gr-account-label:focus {
+      outline: none;
+    }
+    gr-account-label:hover,
+    gr-account-label:hover {
+      box-shadow: var(--elevation-level-1);
+      cursor: pointer;
+    }
   </style>
-  <template is="dom-if" if="[[!hideToggleButtons]]">
+  <template is="dom-if" if="[[!hideDropdown]]">
     <div class="header">
-        <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="allRadio">
-          All ([[_countAllThreads(threads)]])
-        </label>
+      <span class="sort-text">Sort By:</span>
+      <gr-dropdown-list
+        id="sortDropdown"
+        value="[[sortDropdownValue]]"
+        on-value-change="handleSortDropdownValueChange"
+        items="[[getSortDropdownEntires()]]"
+      >
+      </gr-dropdown-list>
+      <span class="separator"></span>
+      <span class="filter-text">Filter By:</span>
+      <gr-dropdown-list
+        id="filterDropdown"
+        value="[[commentsDropdownValue]]"
+        on-value-change="handleCommentsDropdownValueChange"
+        items="[[getCommentsDropdownEntires(threads, loggedIn)]]"
+      >
+      </gr-dropdown-list>
+      <template is="dom-if" if="[[_displayedThreads.length]]">
+        <span class="author-text">From:</span>
+        <template is="dom-repeat" items="[[getCommentAuthors(_displayedThreads, account)]]">
+          <gr-account-label
+            account="[[item]]"
+            on-click="handleAccountClicked"
+            selectionChipStyle
+            selected="[[isSelected(item, selectedAuthors)]]"
+          > </gr-account-label>
+        </template>
+      </template>
     </div>
   </template>
-  <div id="threads">
+  <div id="threads" part="threads">
     <template
       is="dom-if"
       if="[[_showEmptyThreadsMessage(threads, _displayedThreads, unresolvedOnly)]]"
@@ -134,7 +145,7 @@
     >
       <template
         is="dom-if"
-        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        if="[[_shouldRenderSeparator(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
       >
         <div class="thread-separator"></div>
       </template>
@@ -145,15 +156,14 @@
         change-num="[[changeNum]]"
         comments="[[thread.comments]]"
         diff-side="[[thread.diffSide]]"
-        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply)]]"
+        show-file-name="[[_isFirstThreadWithFileName(_displayedThreads, thread, unresolvedOnly, _draftsOnly, onlyShowRobotCommentsWithHumanReply, selectedAuthors)]]"
         project-name="[[change.project]]"
         is-on-parent="[[_isOnParent(thread.commentSide)]]"
         line-num="[[thread.line]]"
         patch-num="[[thread.patchNum]]"
         path="[[thread.path]]"
         root-id="{{thread.rootId}}"
-        on-thread-changed="_handleCommentsChanged"
-        on-thread-discard="_handleThreadDiscard"
+        should-scroll-into-view="[[computeShouldScrollIntoView(thread.comments, scrollCommentId)]]"
       ></gr-comment-thread>
     </template>
   </div>
diff --git a/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js b/polygerrit-ui/app/elements/change/gr-thread-list/gr-thread-list_test.js
index 7b47bd7..aab5cee 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
@@ -19,21 +19,25 @@
 import './gr-thread-list.js';
 import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
 import {SpecialFilePath} from '../../../constants/constants.js';
+import {CommentTabState} from '../../../types/events.js';
+import {__testOnly_SortDropdownState} from './gr-thread-list.js';
+import {queryAll} from '../../../test/test-utils.js';
+import {accountOrGroupKey} from '../../../utils/account-util.js';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {createAccountDetailWithId} from '../../../test/test-data-generators.js';
 
 const basicFixture = fixtureFromElement('gr-thread-list');
 
 suite('gr-thread-list tests', () => {
   let element;
 
-  let threadElements;
-
   function getVisibleThreads() {
     return [...dom(element.root)
         .querySelectorAll('gr-comment-thread')]
         .filter(e => e.style.display !== 'none');
   }
 
-  setup(done => {
+  setup(async () => {
     element = basicFixture.instantiate();
     element.changeNum = 123;
     element.change = {
@@ -45,14 +49,14 @@
           {
             path: '/COMMIT_MSG',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000001,
               name: 'user',
               username: 'user',
             },
             patch_set: 4,
             id: 'ecf0b9fa_fe1a5f62',
             line: 5,
-            updated: '2018-02-08 18:49:18.000000000',
+            updated: '1',
             message: 'test',
             unresolved: true,
           },
@@ -61,7 +65,7 @@
             path: '/COMMIT_MSG',
             line: 5,
             in_reply_to: 'ecf0b9fa_fe1a5f62',
-            updated: '2018-02-13 22:48:48.018000000',
+            updated: '1',
             message: 'draft',
             unresolved: true,
             __draft: true,
@@ -74,21 +78,21 @@
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'ecf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-08 18:49:18.000000000',
+        updated: '1',
       },
       {
         comments: [
           {
             path: 'test.txt',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000002,
               name: 'user',
               username: 'user',
             },
             patch_set: 3,
             id: '09a9fb0a_1484e6cf',
             side: 'PARENT',
-            updated: '2018-02-13 22:47:19.000000000',
+            updated: '2',
             message: 'Some comment on another patchset.',
             unresolved: false,
           },
@@ -96,7 +100,7 @@
         patchNum: 3,
         path: 'test.txt',
         rootId: '09a9fb0a_1484e6cf',
-        start_datetime: '2018-02-13 22:47:19.000000000',
+        updated: '2',
         commentSide: 'PARENT',
       },
       {
@@ -104,13 +108,13 @@
           {
             path: '/COMMIT_MSG',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000002,
               name: 'user',
               username: 'user',
             },
             patch_set: 2,
             id: '8caddf38_44770ec1',
-            updated: '2018-02-13 22:48:40.000000000',
+            updated: '3',
             message: 'Another unresolved comment',
             unresolved: false,
           },
@@ -118,21 +122,21 @@
         patchNum: 2,
         path: '/COMMIT_MSG',
         rootId: '8caddf38_44770ec1',
-        start_datetime: '2018-02-13 22:48:40.000000000',
+        updated: '3',
       },
       {
         comments: [
           {
             path: '/COMMIT_MSG',
             author: {
-              _account_id: 1000000,
+              _account_id: 1000003,
               name: 'user',
               username: 'user',
             },
             patch_set: 2,
             id: 'scaddf38_44770ec1',
             line: 4,
-            updated: '2018-02-14 22:48:40.000000000',
+            updated: '4',
             message: 'Yet another unresolved comment',
             unresolved: true,
           },
@@ -141,7 +145,7 @@
         path: '/COMMIT_MSG',
         line: 4,
         rootId: 'scaddf38_44770ec1',
-        start_datetime: '2018-02-14 22:48:40.000000000',
+        updated: '4',
       },
       {
         comments: [
@@ -149,7 +153,7 @@
             id: 'zcf0b9fa_fe1a5f62',
             path: '/COMMIT_MSG',
             line: 6,
-            updated: '2018-02-15 22:48:48.018000000',
+            updated: '5',
             message: 'resolved draft',
             unresolved: false,
             __draft: true,
@@ -162,14 +166,14 @@
         path: '/COMMIT_MSG',
         line: 6,
         rootId: 'zcf0b9fa_fe1a5f62',
-        start_datetime: '2018-02-09 18:49:18.000000000',
+        updated: '5',
       },
       {
         comments: [
           {
             id: 'patchset_level_1',
             path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '2018-02-15 22:48:48.018000000',
+            updated: '6',
             message: 'patchset comment 1',
             unresolved: false,
             __editing: false,
@@ -179,14 +183,14 @@
         patchNum: 2,
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
         rootId: 'patchset_level_1',
-        start_datetime: '2018-02-09 18:49:18.000000000',
+        updated: '6',
       },
       {
         comments: [
           {
             id: 'patchset_level_2',
             path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
-            updated: '2018-02-15 22:48:48.018000000',
+            updated: '7',
             message: 'patchset comment 2',
             unresolved: false,
             __editing: false,
@@ -196,7 +200,7 @@
         patchNum: 3,
         path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
         rootId: 'patchset_level_2',
-        start_datetime: '2018-02-09 18:49:18.000000000',
+        updated: '7',
       },
       {
         comments: [
@@ -210,7 +214,7 @@
             patch_set: 4,
             id: 'rc1',
             line: 5,
-            updated: '2019-02-08 18:49:18.000000000',
+            updated: '8',
             message: 'test',
             unresolved: true,
             robot_id: 'rc1',
@@ -220,7 +224,7 @@
         path: '/COMMIT_MSG',
         line: 5,
         rootId: 'rc1',
-        start_datetime: '2019-02-08 18:49:18.000000000',
+        updated: '8',
       },
       {
         comments: [
@@ -234,7 +238,7 @@
             patch_set: 4,
             id: 'rc2',
             line: 7,
-            updated: '2019-03-08 18:49:18.000000000',
+            updated: '9',
             message: 'test',
             unresolved: true,
             robot_id: 'rc2',
@@ -249,7 +253,7 @@
             patch_set: 4,
             id: 'c2_1',
             line: 5,
-            updated: '2019-03-08 18:49:18.000000000',
+            updated: '10',
             message: 'test',
             unresolved: true,
           },
@@ -258,16 +262,23 @@
         path: '/COMMIT_MSG',
         line: 7,
         rootId: 'rc2',
-        start_datetime: '2019-03-08 18:49:18.000000000',
+        updated: '10',
       },
     ];
 
     // use flush to render all (bypass initial-count set on dom-repeat)
-    flush(() => {
-      threadElements = dom(element.root)
-          .querySelectorAll('gr-comment-thread');
-      done();
-    });
+    await flush();
+  });
+
+  test('draft dropdown item only appears when logged in', () => {
+    element.loggedIn = false;
+    flush();
+    assert.equal(element.getCommentsDropdownEntires(element.threads,
+        element.loggedIn).length, 2);
+    element.loggedIn = true;
+    flush();
+    assert.equal(element.getCommentsDropdownEntires(element.threads,
+        element.loggedIn).length, 3);
   });
 
   test('show all threads by default', () => {
@@ -276,24 +287,26 @@
     assert.equal(getVisibleThreads().length, element.threads.length);
   });
 
-  test('show unresolved threads if unresolvedOnly is set', done => {
+  test('show unresolved threads if unresolvedOnly is set', async () => {
     element.unresolvedOnly = true;
-    flush();
+    await flush();
     const unresolvedThreads = element.threads.filter(t => t.comments.some(
         c => c.unresolved
     ));
     assert.equal(getVisibleThreads().length, unresolvedThreads.length);
-    done();
   });
 
   test('showing file name takes visible threads into account', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
         element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply), true);
+        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
+    true);
     element.unresolvedOnly = true;
     assert.equal(element._isFirstThreadWithFileName(element._sortedThreads,
         element._sortedThreads[2], element.unresolvedOnly, element._draftsOnly,
-        element.onlyShowRobotCommentsWithHumanReply), false);
+        element.onlyShowRobotCommentsWithHumanReply, element.selectedAuthors),
+    false);
   });
 
   test('onlyShowRobotCommentsWithHumanReply ', () => {
@@ -306,6 +319,10 @@
   });
 
   suite('_compareThreads', () => {
+    setup(() => {
+      element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+    });
+
     test('patchset comes before any other file', () => {
       const t1 = {thread: {path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS}};
       const t2 = {thread: {path: SpecialFilePath.COMMIT_MESSAGE}};
@@ -437,6 +454,7 @@
   });
 
   test('_computeSortedThreads', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     assert.equal(element._sortedThreads.length, 9);
     const expectedSortedRootIds = [
       'patchset_level_2', // Posted on Patchset 3
@@ -454,12 +472,78 @@
     });
   });
 
+  test('_computeSortedThreads with timestamp', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.TIMESTAMP;
+    element.resortThreads(element.threads);
+    assert.equal(element._sortedThreads.length, 9);
+    const expectedSortedRootIds = [
+      'rc2',
+      'rc1',
+      'patchset_level_2',
+      'patchset_level_1',
+      'zcf0b9fa_fe1a5f62',
+      'scaddf38_44770ec1',
+      '8caddf38_44770ec1',
+      '09a9fb0a_1484e6cf',
+      'ecf0b9fa_fe1a5f62',
+    ];
+    element._sortedThreads.forEach((thread, index) => {
+      assert.equal(thread.rootId, expectedSortedRootIds[index]);
+    });
+  });
+
+  test('tapping single author chips', () => {
+    element.account = createAccountDetailWithId(1);
+    flush();
+    const chips = Array.from(queryAll(element, 'gr-account-label'));
+    const authors = chips.map(
+        chip => accountOrGroupKey(chip.account))
+        .sort();
+    assert.deepEqual(authors, [1, 1000000, 1000001, 1000002, 1000003]);
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 9);
+
+    // accountId 1000001
+    const chip = chips.find(chip => chip.account._account_id === 1000001);
+
+    tap(chip);
+    flush();
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 1);
+    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
+        1000001);
+
+    tap(chip); // tapping again resets
+    flush();
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 9);
+  });
+
+  test('tapping multiple author chips', () => {
+    element.account = createAccountDetailWithId(1);
+    flush();
+    const chips = Array.from(queryAll(element, 'gr-account-label'));
+
+    tap(chips.find(chip => chip.account._account_id === 1000001));
+    tap(chips.find(chip => chip.account._account_id === 1000002));
+    flush();
+
+    assert.equal(element.threads.length, 9);
+    assert.equal(element._displayedThreads.length, 3);
+    assert.equal(element._displayedThreads[0].comments[0].author._account_id,
+        1000002);
+    assert.equal(element._displayedThreads[1].comments[0].author._account_id,
+        1000002);
+    assert.equal(element._displayedThreads[2].comments[0].author._account_id,
+        1000001);
+  });
+
   test('thread removal and sort again', () => {
-    threadElements[1].dispatchEvent(
-        new CustomEvent('thread-discard', {
-          detail: {rootId: 'rc2'},
-          composed: true, bubbles: true,
-        }));
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
+    const index = element.threads.findIndex(t => t.rootId === 'rc2');
+    element.threads.splice(index, 1);
+    element.threads = [...element.threads]; // trigger observers
     flush();
     assert.equal(element._sortedThreads.length, 8);
     const expectedSortedRootIds = [
@@ -478,6 +562,7 @@
   });
 
   test('modification on thread shold not trigger sort again', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     const currentSortedThreads = [...element._sortedThreads];
     for (const thread of currentSortedThreads) {
       thread.comments = [...thread.comments];
@@ -515,6 +600,7 @@
 
   test('non-equal length of sortThreads and threads' +
     ' should trigger sort again', () => {
+    element.sortDropdownValue = __testOnly_SortDropdownState.FILES;
     const modifiedThreads = [...element.threads];
     const currentSortedThreads = [...element._sortedThreads];
     element._sortedThreads = [];
@@ -538,49 +624,31 @@
     });
   });
 
-  test('toggle all shows all all comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#allRadio'));
+  test('show all comments', () => {
+    element.handleCommentsDropdownValueChange({detail: {
+      value: CommentTabState.SHOW_ALL}});
     flush();
     assert.equal(getVisibleThreads().length, 9);
   });
 
-  test('toggle unresolved shows all unresolved comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector(
-        '#unresolvedRadio'));
+  test('unresolved shows all unresolved comments', () => {
+    element.handleCommentsDropdownValueChange({detail: {
+      value: CommentTabState.UNRESOLVED}});
     flush();
     assert.equal(getVisibleThreads().length, 4);
   });
 
   test('toggle drafts only shows threads with draft comments', () => {
-    MockInteractions.tap(element.shadowRoot.querySelector('#draftsRadio'));
+    element.handleCommentsDropdownValueChange({detail: {
+      value: CommentTabState.DRAFTS}});
     flush();
     assert.equal(getVisibleThreads().length, 2);
   });
 
-  test('modification events are consumed and displatched', () => {
-    sinon.spy(element, '_handleCommentsChanged');
-    const dispatchSpy = sinon.stub();
-    element.addEventListener('thread-list-modified', dispatchSpy);
-    threadElements[0].dispatchEvent(
-        new CustomEvent('thread-changed', {
-          detail: {
-            rootId: 'ecf0b9fa_fe1a5f62', path: '/COMMIT_MSG'},
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(element._handleCommentsChanged.called);
-    assert.isTrue(dispatchSpy.called);
-    assert.equal(dispatchSpy.lastCall.args[0].detail.rootId,
-        'ecf0b9fa_fe1a5f62');
-    assert.equal(dispatchSpy.lastCall.args[0].detail.path, '/COMMIT_MSG');
-  });
-
-  suite('hideToggleButtons', () => {
-    setup(done => {
-      element.hideToggleButtons = true;
-      flush(() => {
-        done();
-      });
+  suite('hideDropdown', () => {
+    setup(async () => {
+      element.hideDropdown = true;
+      await flush();
     });
 
     test('toggle buttons are hidden', () => {
@@ -590,17 +658,15 @@
   });
 
   suite('empty thread', () => {
-    setup(done => {
+    setup(async () => {
       element.threads = [];
-      flush(() => {
-        done();
-      });
+      await flush();
     });
 
     test('default empty message should show', () => {
       assert.isTrue(
           element.shadowRoot.querySelector('#threads').textContent.trim()
-              .includes('No comments.'));
+              .includes('No comments'));
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
new file mode 100644
index 0000000..552cc69
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-trigger-vote-hovercard/gr-trigger-vote-hovercard.ts
@@ -0,0 +1,94 @@
+/**
+ * @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 {customElement, property} from 'lit/decorators';
+import {css, html, LitElement} from 'lit';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {fontStyles} from '../../../styles/gr-font-styles';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
+
+@customElement('gr-trigger-vote-hovercard')
+export class GrTriggerVoteHovercard extends base {
+  @property()
+  labelName?: string;
+
+  static override get styles() {
+    return [
+      fontStyles,
+      base.styles || [],
+      css`
+        #container {
+          min-width: 300px;
+          max-width: 300px;
+          padding: var(--spacing-xl) 0 var(--spacing-m) 0;
+        }
+        .row {
+          display: flex;
+        }
+        .title {
+          color: var(--deemphasized-text-color);
+          margin-right: var(--spacing-m);
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+          align-items: flex-start;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        div.sectionIcon iron-icon {
+          position: relative;
+          width: 20px;
+          height: 20px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <div id="container" role="tooltip" tabindex="-1">
+      <div class="section">
+        <div class="sectionContent">
+          <h3 class="name heading-3">
+            <span>${this.labelName}</span>
+          </h3>
+        </div>
+      </div>
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          <div class="row">
+            <div class="title">Status</div>
+            <div>
+              <slot name="label-info"></slot>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-trigger-vote-hovercard': GrTriggerVoteHovercard;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-action.ts b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
new file mode 100644
index 0000000..859fd33
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-action.ts
@@ -0,0 +1,91 @@
+/**
+ * @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 {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {Action} from '../../api/checks';
+import {checkRequiredProperty} from '../../utils/common-util';
+import {appContext} from '../../services/app-context';
+
+@customElement('gr-checks-action')
+export class GrChecksAction extends LitElement {
+  @property({type: Object})
+  action!: Action;
+
+  @property({type: Object})
+  eventTarget: HTMLElement | null = null;
+
+  private checksService = appContext.checksService;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    checkRequiredProperty(this.action, 'action');
+  }
+
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          white-space: nowrap;
+        }
+        gr-button {
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+        }
+        paper-tooltip {
+          text-transform: none;
+          text-align: center;
+          white-space: normal;
+          max-width: 200px;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <gr-button
+        link
+        ?disabled="${this.action.disabled}"
+        class="action"
+        @click="${(e: Event) => this.handleClick(e)}"
+      >
+        ${this.action.name}
+      </gr-button>
+      ${this.renderTooltip()}
+    `;
+  }
+
+  private renderTooltip() {
+    if (!this.action.tooltip) return;
+    return html`
+      <paper-tooltip offset="5" fit-to-visible-bounds>
+        ${this.action.tooltip}
+      </paper-tooltip>
+    `;
+  }
+
+  handleClick(e: Event) {
+    e.stopPropagation();
+    this.checksService.triggerAction(this.action);
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-action': GrChecksAction;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
new file mode 100644
index 0000000..69152b2
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-attempt.ts
@@ -0,0 +1,83 @@
+/**
+ * @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 {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {CheckRun} from '../../services/checks/checks-model';
+import {ordinal} from '../../utils/string-util';
+
+@customElement('gr-checks-attempt')
+class GrChecksAttempt extends LitElement {
+  @property({attribute: false})
+  run?: CheckRun;
+
+  static override get styles() {
+    return [
+      css`
+        .attempt {
+          display: inline-block;
+          height: var(--line-height-normal);
+          vertical-align: top;
+          font-size: var(--font-size-small);
+          position: relative;
+        }
+        .attempt .box,
+        .attempt .angle {
+          box-sizing: border-box;
+          height: calc(var(--line-height-normal) - 2px);
+          line-height: calc(var(--line-height-normal) - 2px);
+          border-radius: 2px;
+        }
+        .attempt .box {
+          margin-left: 2px;
+          margin-bottom: 2px;
+          border: 1px solid var(--deemphasized-text-color);
+          padding: 0 var(--spacing-s);
+        }
+        .attempt .angle {
+          position: absolute;
+          top: 2px;
+          /* The text in the .angle div just ensures the correct width. */
+          color: transparent;
+          border-left: 1px solid var(--deemphasized-text-color);
+          border-bottom: 1px solid var(--deemphasized-text-color);
+          /* 1px for the border of the .box div. */
+          padding: 0 calc(var(--spacing-s) + 1px);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.run) return undefined;
+    if (this.run.isSingleAttempt) return undefined;
+    if (!this.run.attempt) return undefined;
+    const attempt = ordinal(this.run.attempt);
+
+    return html`
+      <span class="attempt">
+        <div class="box">${attempt}</div>
+        <div class="angle">${attempt}</div>
+      </span>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-checks-attempt': GrChecksAttempt;
+  }
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-results.ts b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
index 57bed1c..9c27cdb 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-results.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-results.ts
@@ -14,20 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {html} from 'lit-html';
-import {classMap} from 'lit-html/directives/class-map';
-import {repeat} from 'lit-html/directives/repeat';
-import {
-  css,
-  customElement,
-  internalProperty,
-  property,
-  PropertyValues,
-  query,
-  TemplateResult,
-} from 'lit-element';
-import {GrLitElement} from '../lit/gr-lit-element';
+import {classMap} from 'lit/directives/class-map';
+import {repeat} from 'lit/directives/repeat';
+import {ifDefined} from 'lit/directives/if-defined';
+import {LitElement, css, html, PropertyValues, TemplateResult} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import './gr-checks-action';
+import './gr-hovercard-run';
 import '@polymer/paper-tooltip/paper-tooltip';
+import '@polymer/iron-icon/iron-icon';
 import {
   Action,
   Category,
@@ -38,52 +33,77 @@
 } from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
-  allActions$,
-  checksPatchsetNumber$,
-  someProvidersAreLoading$,
-  RunResult,
   CheckRun,
-  allLinks$,
+  checksSelectedPatchsetNumber$,
+  RunResult,
+  someProvidersAreLoadingSelected$,
+  topLevelActionsSelected$,
+  topLevelLinksSelected$,
 } from '../../services/checks/checks-model';
 import {
   allResults,
-  fireActionTriggered,
+  firstPrimaryLink,
   hasCompletedWithoutResults,
-  hasResultsOf,
-  iconForCategory,
+  iconFor,
   iconForLink,
+  isCategory,
+  otherPrimaryLinks,
+  secondaryLinks,
   tooltipForLink,
 } from '../../services/checks/checks-util';
-import {
-  assertIsDefined,
-  check,
-  checkRequiredProperty,
-} from '../../utils/common-util';
-import {toggleClass, whenVisible} from '../../utils/dom-util';
+import {assertIsDefined, check} from '../../utils/common-util';
+import {modifierPressed, toggleClass, whenVisible} from '../../utils/dom-util';
 import {durationString} from '../../utils/date-util';
-import {charsOnly, pluralize} from '../../utils/string-util';
-import {fireRunSelectionReset, isAttemptSelected} from './gr-checks-util';
+import {charsOnly} from '../../utils/string-util';
+import {isAttemptSelected, matches} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
-import {ConfigInfo, PatchSetNumber} from '../../types/common';
-import {latestPatchNum$} from '../../services/change/change-model';
+import {
+  ConfigInfo,
+  LabelNameToInfoMap,
+  PatchSetNumber,
+} from '../../types/common';
+import {labels$, latestPatchNum$} from '../../services/change/change-model';
 import {appContext} from '../../services/app-context';
 import {repoConfig$} from '../../services/config/config-model';
+import {spinnerStyles} from '../../styles/gr-spinner-styles';
+import {
+  getLabelStatus,
+  getRepresentativeValue,
+  valueString,
+} from '../../utils/label-util';
+import {GerritNav} from '../core/gr-navigation/gr-navigation';
+import {DropdownLink} from '../shared/gr-dropdown/gr-dropdown';
+import {subscribe} from '../lit/subscription-controller';
+import {fontStyles} from '../../styles/gr-font-styles';
 
 @customElement('gr-result-row')
-class GrResultRow extends GrLitElement {
-  @property()
+class GrResultRow extends LitElement {
+  @query('td.nameCol div.name')
+  nameEl?: HTMLElement;
+
+  @property({attribute: false})
   result?: RunResult;
 
-  @property()
+  @state()
   isExpanded = false;
 
   @property({type: Boolean, reflect: true})
   isExpandable = false;
 
-  @property()
+  @state()
   shouldRender = false;
 
-  static get styles() {
+  @state()
+  labels?: LabelNameToInfoMap;
+
+  private checksService = appContext.checksService;
+
+  constructor() {
+    super();
+    subscribe(this, labels$, x => (this.labels = x));
+  }
+
+  static override get styles() {
     return [
       sharedStyles,
       css`
@@ -96,50 +116,54 @@
         gr-result-expanded {
           cursor: default;
         }
-        tr {
+        tr.container {
           border-top: 1px solid var(--border-color);
         }
+        a.link {
+          margin-right: var(--spacing-s);
+        }
         iron-icon.link {
           color: var(--link-color);
-          margin-right: var(--spacing-m);
         }
-        td.iconCol {
-          padding-left: var(--spacing-l);
-          padding-right: var(--spacing-m);
+        td.nameCol div.flex {
+          display: flex;
         }
-        .iconCol div {
-          width: 20px;
-        }
-        .nameCol div {
-          width: 165px;
+        td.nameCol .name {
           overflow: hidden;
           text-overflow: ellipsis;
+          margin-right: var(--spacing-s);
+          outline-offset: var(--spacing-xs);
         }
-        .nameCol .attempt {
+        td.nameCol .space {
+          flex-grow: 1;
+        }
+        td.nameCol gr-checks-action {
+          display: none;
+        }
+        tr:focus-within td.nameCol gr-checks-action,
+        tr:hover td.nameCol gr-checks-action {
           display: inline-block;
-          background-color: var(--tag-gray);
-          border-radius: var(--line-height-normal);
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          text-align: center;
-          vertical-align: top;
-          font-size: var(--font-size-small);
-        }
-        .summaryCol {
-          /* Forces this column to get the remaining space that is left over by
-             the other columns. */
-          width: 99%;
-        }
-        .expanderCol div {
-          width: 20px;
+          /* 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-s)) 0px;
+          margin-left: var(--spacing-s);
         }
         td {
           white-space: nowrap;
           padding: var(--spacing-s);
         }
+        td.expandedCol,
+        td.nameCol {
+          padding-left: var(--spacing-l);
+        }
+        td.expandedCol,
+        td.expanderCol {
+          padding-right: var(--spacing-l);
+        }
         td .summary-cell {
           display: flex;
-          max-width: calc(100vw - 630px);
         }
         td .summary-cell .summary {
           font-weight: var(--font-weight-bold);
@@ -157,24 +181,31 @@
           overflow: hidden;
           text-overflow: ellipsis;
         }
-        tr:hover {
+        tr.container:hover {
           background: var(--hover-background-color);
         }
-        tr td .summary-cell .links,
-        tr td .summary-cell .actions,
-        tr.collapsed:hover td .summary-cell .links,
-        tr.collapsed:hover td .summary-cell .actions,
+        tr.container:focus-within {
+          background: var(--selection-background-color);
+        }
+        tr.container td .summary-cell .links,
+        tr.container td .summary-cell .actions,
+        tr.container.collapsed:focus-within td .summary-cell .links,
+        tr.container.collapsed:focus-within td .summary-cell .actions,
+        tr.container.collapsed:hover td .summary-cell .links,
+        tr.container.collapsed:hover td .summary-cell .actions,
         :host(.dropdown-open) tr td .summary-cell .links,
         :host(.dropdown-open) tr td .summary-cell .actions {
           display: inline-block;
           margin-left: var(--spacing-s);
         }
-        tr.collapsed td .summary-cell .links,
-        tr.collapsed td .summary-cell .actions {
+        tr.container.collapsed td .summary-cell .message {
+          color: var(--deemphasized-text-color);
+        }
+        tr.container.collapsed td .summary-cell .links,
+        tr.container.collapsed td .summary-cell .actions {
           display: none;
         }
-        tr.collapsed:hover .summary-cell .tags,
-        tr.collapsed:hover .summary-cell .label {
+        tr.detailsRow.collapsed {
           display: none;
         }
         td .summary-cell .tags .tag {
@@ -185,30 +216,22 @@
           padding: 0 var(--spacing-m);
           margin-left: var(--spacing-s);
         }
-        td .summary-cell .label {
-          color: var(--primary-text-color);
-          display: inline-block;
-          border-radius: 20px;
-          background-color: var(--label-background);
-          padding: 0 var(--spacing-m);
-          margin-left: var(--spacing-s);
-        }
-        .tag.gray {
+        td .summary-cell .tag.gray {
           background-color: var(--tag-gray);
         }
-        .tag.yellow {
+        td .summary-cell .tag.yellow {
           background-color: var(--tag-yellow);
         }
-        .tag.pink {
+        td .summary-cell .tag.pink {
           background-color: var(--tag-pink);
         }
-        .tag.purple {
+        td .summary-cell .tag.purple {
           background-color: var(--tag-purple);
         }
-        .tag.cyan {
+        td .summary-cell .tag.cyan {
           background-color: var(--tag-cyan);
         }
-        .tag.brown {
+        td .summary-cell .tag.brown {
           background-color: var(--tag-brown);
         }
         .actions gr-checks-action,
@@ -223,29 +246,67 @@
         #moreMessage {
           display: none;
         }
+        td .summary-cell .label {
+          margin-left: var(--spacing-s);
+          border-radius: var(--border-radius);
+          color: var(--vote-text-color);
+          display: inline-block;
+          padding: 0 var(--spacing-s);
+          text-align: center;
+        }
+        td .summary-cell .label.neutral {
+          background-color: var(--vote-color-neutral);
+        }
+        td .summary-cell .label.recommended,
+        td .summary-cell .label.disliked {
+          line-height: calc(var(--line-height-normal) - 2px);
+          color: var(--chip-color);
+        }
+        td .summary-cell .label.recommended {
+          background-color: var(--vote-color-recommended);
+          border: 1px solid var(--vote-outline-recommended);
+        }
+        td .summary-cell .label.disliked {
+          background-color: var(--vote-color-disliked);
+          border: 1px solid var(--vote-outline-disliked);
+        }
+        td .summary-cell .label.approved {
+          background-color: var(--vote-color-approved);
+        }
+        td .summary-cell .label.rejected {
+          background-color: var(--vote-color-rejected);
+        }
       `,
     ];
   }
 
-  update(changedProperties: PropertyValues) {
+  override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('result')) {
       this.isExpandable = !!this.result?.summary && !!this.result?.message;
     }
-    super.update(changedProperties);
   }
 
-  firstUpdated() {
+  override focus() {
+    if (this.nameEl) this.nameEl.focus();
+  }
+
+  override firstUpdated() {
     const loading = this.shadowRoot?.querySelector('.container');
     assertIsDefined(loading, '"Loading" element');
-    whenVisible(loading, () => this.setAttribute('shouldRender', 'true'), 200);
+    whenVisible(
+      loading,
+      () => {
+        this.shouldRender = true;
+      },
+      200
+    );
   }
 
-  render() {
+  override 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>
@@ -256,42 +317,46 @@
     }
     return html`
       <tr class="${classMap({container: true, collapsed: !this.isExpanded})}">
-        <td class="iconCol" @click="${this.toggleExpanded}">
-          <div>${this.renderIcon()}</div>
-        </td>
-        <td class="nameCol" @click="${this.toggleExpanded}">
-          <div>
-            <span>${this.result.checkName}</span>
-            <span class="attempt" ?hidden="${this.result.isSingleAttempt}"
-              >${this.result.attempt}</span
+        <td class="nameCol" @click="${this.toggleExpandedClick}">
+          <div class="flex">
+            <gr-hovercard-run .run="${this.result}"></gr-hovercard-run>
+            <div
+              class="name"
+              role="button"
+              tabindex="0"
+              @click="${this.toggleExpandedClick}"
+              @keydown="${this.toggleExpandedPress}"
             >
+              ${this.result.checkName}
+            </div>
+            <div class="space"></div>
           </div>
         </td>
         <td class="summaryCol">
           <div class="summary-cell">
-            ${(this.result.links?.slice(0, 1) ?? []).map(this.renderLink)}
+            ${this.renderLink(firstPrimaryLink(this.result))}
             ${this.renderSummary(this.result.summary)}
-            <div class="message" @click="${this.toggleExpanded}">
+            <div class="message" @click="${this.toggleExpandedClick}">
               ${this.isExpanded ? '' : this.result.message}
             </div>
+            ${this.renderLinks()} ${this.renderActions()}
             <div class="tags">
               ${(this.result.tags ?? []).map(t => this.renderTag(t))}
             </div>
-            ${this.renderLabel()} ${this.renderLinks()} ${this.renderActions()}
+            ${this.renderLabel()}
           </div>
-          ${this.renderExpanded()}
         </td>
-        <td class="expanderCol" @click="${this.toggleExpanded}">
+        <td class="expanderCol" @click="${this.toggleExpandedClick}">
           <div
             class="show-hide"
             role="switch"
             tabindex="0"
             ?hidden="${!this.isExpandable}"
-            ?aria-checked="${this.isExpanded}"
+            aria-checked="${this.isExpanded ? 'true' : 'false'}"
             aria-label="${this.isExpanded
               ? 'Collapse result row'
               : 'Expand result row'}"
-            @keydown="${this.toggleExpanded}"
+            @keydown="${this.toggleExpandedPress}"
           >
             <iron-icon
               icon="${this.isExpanded
@@ -301,6 +366,9 @@
           </div>
         </td>
       </tr>
+      <tr class="${classMap({detailsRow: true, collapsed: !this.isExpanded})}">
+        <td class="expandedCol" colspan="3">${this.renderExpanded()}</td>
+      </tr>
     `;
   }
 
@@ -311,6 +379,23 @@
     ></gr-result-expanded>`;
   }
 
+  private toggleExpandedClick(e: MouseEvent) {
+    if (!this.isExpandable) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
+  private toggleExpandedPress(e: KeyboardEvent) {
+    if (!this.isExpandable) return;
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.toggleExpanded();
+  }
+
   private toggleExpanded() {
     if (!this.isExpandable) return;
     this.isExpanded = !this.isExpanded;
@@ -325,26 +410,50 @@
     `;
   }
 
-  renderIcon() {
-    if (this.result?.status !== RunStatus.RUNNING) return;
-    return html`<iron-icon icon="gr-icons:timelapse"></iron-icon>`;
-  }
-
   renderLabel() {
+    const category = this.result?.category;
+    if (category !== Category.ERROR && category !== Category.WARNING) return;
     const label = this.result?.labelName;
     if (!label) return;
-    return html`<div class="label">${label}</div>`;
+    if (!this.result?.isLatestAttempt) return;
+    const info = this.labels?.[label];
+    const status = getLabelStatus(info).toLowerCase();
+    const value = getRepresentativeValue(info);
+    // A neutral vote is not interesting for the user to see and is just
+    // cluttering the UI.
+    if (value === 0) return;
+    const valueStr = valueString(value);
+    return html`
+      <div class="label ${status}">
+        <span>${label} ${valueStr}</span>
+        <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+          The check result has (probably) influenced this label vote.
+        </paper-tooltip>
+      </div>
+    `;
   }
 
   renderLinks() {
-    const links = (this.result?.links ?? []).slice(1);
+    const links = otherPrimaryLinks(this.result)
+      // Showing the same icons twice without text is super confusing.
+      .filter(
+        (link: Link, index: number, array: Link[]) =>
+          array.findIndex(other => link.icon === other.icon) === index
+      )
+      // 4 is enough for the summary row. All are shown in expanded state.
+      .slice(0, 4);
     if (links.length === 0) return;
-    return html`<div class="links">${links.map(this.renderLink)}</div>`;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
   }
 
-  renderLink(link: Link) {
+  renderLink(link?: Link) {
+    // The expanded state renders all links in more detail. Hide in summary.
+    if (this.isExpanded) return;
+    if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
-    return html`<a href="${link.url}" target="_blank"
+    return html`<a href="${link.url}" class="link" target="_blank"
       ><iron-icon
         aria-label="external link to details"
         class="link"
@@ -360,6 +469,9 @@
     const overflowItems = actions.slice(2).map(action => {
       return {...action, id: action.name};
     });
+    const disabledItems = overflowItems
+      .filter(action => action.disabled)
+      .map(action => action.id);
     return html`<div class="actions">
       ${this.renderAction(actions[0])} ${this.renderAction(actions[1])}
       <gr-dropdown
@@ -372,6 +484,7 @@
           toggleClass(this, 'dropdown-open', e.detail.value)}"
         ?hidden="${overflowItems.length === 0}"
         .items="${overflowItems}"
+        .disabledIds="${disabledItems}"
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -381,7 +494,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    fireActionTriggered(this, e.detail);
+    this.checksService.triggerAction(e.detail);
   }
 
   private renderAction(action?: Action) {
@@ -408,24 +521,41 @@
   }
 
   renderTag(tag: Tag) {
-    return html`<div class="tag ${tag.color}">${tag.name}</div>`;
+    return html`<div class="tag ${tag.color}">
+      <span>${tag.name}</span>
+      <paper-tooltip offset="5" ?fitToVisibleBounds="${true}">
+        ${tag.tooltip ?? 'A category tag for this check result'}
+      </paper-tooltip>
+    </div>`;
   }
 }
 
 @customElement('gr-result-expanded')
-class GrResultExpanded extends GrLitElement {
-  @property()
+class GrResultExpanded extends LitElement {
+  @property({attribute: false})
   result?: RunResult;
 
-  @property()
+  @state()
   repoConfig?: ConfigInfo;
 
-  static get styles() {
+  private changeService = appContext.changeService;
+
+  static override get styles() {
     return [
       sharedStyles,
       css`
+        .links {
+          white-space: normal;
+        }
+        .links a {
+          display: inline-block;
+          margin-right: var(--spacing-xl);
+        }
+        .links a iron-icon {
+          margin-right: var(--spacing-xs);
+        }
         .message {
-          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m) 0;
+          padding: var(--spacing-m) 0;
         }
       `,
     ];
@@ -433,13 +563,18 @@
 
   constructor() {
     super();
-    this.subscribe('repoConfig', repoConfig$);
+    subscribe(this, repoConfig$, x => (this.repoConfig = x));
   }
 
-  render() {
+  override render() {
     if (!this.result) return '';
     return html`
-      <gr-endpoint-decorator name="check-result-expanded">
+      ${this.renderFirstPrimaryLink()} ${this.renderOtherPrimaryLinks()}
+      ${this.renderSecondaryLinks()} ${this.renderCodePointers()}
+      <gr-endpoint-decorator
+        name="check-result-expanded"
+        .targetPlugin="${this.result.pluginName}"
+      >
         <gr-endpoint-param
           name="run"
           .value="${this.result}"
@@ -449,98 +584,178 @@
           .value="${this.result}"
         ></gr-endpoint-param>
         <gr-formatted-text
-          no-trailing-margin=""
+          noTrailingMargin
           class="message"
-          content="${this.result.message}"
-          config="${this.repoConfig}"
+          .content="${this.result.message}"
+          .config="${this.repoConfig?.commentlinks}"
         ></gr-formatted-text>
       </gr-endpoint-decorator>
     `;
   }
+
+  private renderFirstPrimaryLink() {
+    const link = firstPrimaryLink(this.result);
+    if (!link) return;
+    return html`<div class="links">${this.renderLink(link)}</div>`;
+  }
+
+  private renderOtherPrimaryLinks() {
+    const links = otherPrimaryLinks(this.result);
+    if (links.length === 0) return;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
+  }
+
+  private renderSecondaryLinks() {
+    const links = secondaryLinks(this.result);
+    if (links.length === 0) return;
+    return html`<div class="links">
+      ${links.map(link => this.renderLink(link))}
+    </div>`;
+  }
+
+  private renderCodePointers() {
+    const pointers = this.result?.codePointers ?? [];
+    if (pointers.length === 0) return;
+    const links = pointers.map(pointer => {
+      let rangeText = '';
+      const start = pointer?.range?.start_line;
+      const end = pointer?.range?.end_line;
+      if (start) rangeText += `#${start}`;
+      if (end && start !== end) rangeText += `-${end}`;
+      const change = this.changeService.getChange();
+      assertIsDefined(change);
+      const path = pointer.path;
+      const patchset = this.result?.patchset as PatchSetNumber | undefined;
+      const line = pointer?.range?.start_line;
+      return {
+        icon: LinkIcon.CODE,
+        tooltip: `${path}${rangeText}`,
+        url: GerritNav.getUrlForDiff(change, path, patchset, undefined, line),
+        primary: true,
+      };
+    });
+    return links.map(
+      link => html`<div class="links">${this.renderLink(link, false)}</div>`
+    );
+  }
+
+  private renderLink(link?: Link, targetBlank = true) {
+    if (!link) return;
+    const text = link.tooltip ?? tooltipForLink(link.icon);
+    const target = targetBlank ? '_blank' : undefined;
+    return html`<a href="${link.url}" target="${ifDefined(target)}">
+      <iron-icon
+        class="link"
+        icon="gr-icons:${iconForLink(link.icon)}"
+      ></iron-icon
+      ><span>${text}</span>
+    </a>`;
+  }
 }
 
-const SHOW_ALL_THRESHOLDS: Map<Category | 'SUCCESS', number> = new Map();
-SHOW_ALL_THRESHOLDS.set(Category.ERROR, 20);
-SHOW_ALL_THRESHOLDS.set(Category.WARNING, 20);
-SHOW_ALL_THRESHOLDS.set(Category.INFO, 5);
-SHOW_ALL_THRESHOLDS.set('SUCCESS', 5);
+const CATEGORY_TOOLTIPS: Map<Category, string> = new Map();
+CATEGORY_TOOLTIPS.set(Category.ERROR, 'Must be fixed and is blocking submit');
+CATEGORY_TOOLTIPS.set(
+  Category.WARNING,
+  'Should be checked but is not blocking submit'
+);
+CATEGORY_TOOLTIPS.set(
+  Category.INFO,
+  'Does not have to be checked, for your information only'
+);
+CATEGORY_TOOLTIPS.set(
+  Category.SUCCESS,
+  'Successful runs without results and individual successful results'
+);
 
 @customElement('gr-checks-results')
-export class GrChecksResults extends GrLitElement {
+export class GrChecksResults extends LitElement {
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @internalProperty()
+  @state()
   filterRegExp = new RegExp('');
 
   /** All runs. Shown should only the selected/filtered ones. */
-  @property()
+  @property({attribute: false})
   runs: CheckRun[] = [];
 
   /**
    * Check names of runs that are selected in the runs panel. When this array
    * is empty, then no run is selected and all runs should be shown.
    */
-  @property()
+  @property({attribute: false})
   selectedRuns: string[] = [];
 
-  @property()
+  @state()
   actions: Action[] = [];
 
-  @property()
+  @state()
   links: Link[] = [];
 
-  @property()
+  @property({attribute: false})
   tabState?: ChecksTabState;
 
-  @property()
+  @state()
   someProvidersAreLoading = false;
 
-  @property()
+  @state()
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
 
-  @property()
+  @state()
   latestPatchsetNumber: PatchSetNumber | undefined = undefined;
 
   /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property()
+  @property({attribute: false})
   selectedAttempts: Map<string, number | undefined> = new Map<
     string,
     number | undefined
   >();
 
   /** Maintains the state of which result sections should show all results. */
-  @internalProperty()
-  isShowAll: Map<Category | 'SUCCESS', boolean> = new Map();
+  @state()
+  isShowAll: Map<Category, boolean> = new Map();
 
   /**
    * This is the current state of whether a section is expanded or not. As long
    * as isSectionExpandedByUser is false this will be computed by a default rule
    * on every render.
    */
-  private isSectionExpanded = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpanded = new Map<Category, boolean>();
 
   /**
    * Keeps track of whether the user intentionally changed the expansion state.
    * Once this is true the default rule for showing a section expanded or not
    * is not applied anymore.
    */
-  private isSectionExpandedByUser = new Map<Category | 'SUCCESS', boolean>();
+  private isSectionExpandedByUser = new Map<Category, boolean>();
 
   private readonly checksService = appContext.checksService;
 
   constructor() {
     super();
-    this.subscribe('actions', allActions$);
-    this.subscribe('links', allLinks$);
-    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
-    this.subscribe('latestPatchsetNumber', latestPatchNum$);
-    this.subscribe('someProvidersAreLoading', someProvidersAreLoading$);
+    subscribe(this, topLevelActionsSelected$, x => (this.actions = x));
+    subscribe(this, topLevelLinksSelected$, x => (this.links = x));
+    subscribe(
+      this,
+      checksSelectedPatchsetNumber$,
+      x => (this.checksPatchsetNumber = x)
+    );
+    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
+    subscribe(
+      this,
+      someProvidersAreLoadingSelected$,
+      x => (this.someProvidersAreLoading = x)
+    );
   }
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
+      spinnerStyles,
+      fontStyles,
       css`
         :host {
           display: block;
@@ -553,8 +768,12 @@
             var(--spacing-xl);
           border-bottom: 1px solid var(--border-color);
         }
+        .header.notLatest {
+          background-color: var(--emphasis-color);
+        }
         .headerTopRow,
         .headerBottomRow {
+          max-width: 1600px;
           display: flex;
           justify-content: space-between;
           align-items: flex-end;
@@ -564,15 +783,49 @@
           border-radius: var(--border-radius);
           padding: 0 var(--spacing-m);
         }
+        .headerTopRow h2 {
+          display: inline-block;
+        }
+        .headerTopRow .loading {
+          display: inline-block;
+          margin-left: var(--spacing-m);
+          color: var(--deemphasized-text-color);
+        }
+        /* The basics of .loadingSpin are defined in shared styles. */
+        .headerTopRow .loadingSpin {
+          display: inline-block;
+          margin-left: var(--spacing-s);
+          width: 18px;
+          height: 18px;
+          vertical-align: top;
+        }
         .headerBottomRow {
           margin-top: var(--spacing-s);
         }
+        .headerTopRow .right,
         .headerBottomRow .right {
           display: flex;
           align-items: center;
         }
-        .headerBottomRow .links iron-icon {
+        .headerTopRow .right .goToLatest {
+          display: none;
+        }
+        .notLatest .headerTopRow .right .goToLatest {
+          display: block;
+        }
+        .headerTopRow .right .goToLatest gr-button {
+          margin-right: var(--spacing-m);
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+        }
+        .headerBottomRow iron-icon {
           color: var(--link-color);
+        }
+        .headerBottomRow .space {
+          display: inline-block;
+          width: var(--spacing-xl);
+          height: var(--line-height-normal);
+        }
+        .headerBottomRow a {
           margin-right: var(--spacing-l);
         }
         #moreActions iron-icon {
@@ -598,12 +851,6 @@
         .filterDiv .selection {
           padding: var(--spacing-s) var(--spacing-m);
         }
-        .filterDiv iron-icon.filter {
-          color: var(--selected-foreground);
-        }
-        .filterDiv gr-button.reset {
-          margin: calc(0px - var(--spacing-s)) var(--spacing-l);
-        }
         .categoryHeader {
           margin-top: var(--spacing-l);
           margin-left: var(--spacing-l);
@@ -617,6 +864,9 @@
           height: var(--line-height-h3);
           margin-right: var(--spacing-s);
         }
+        .categoryHeader .statusIconWrapper {
+          display: inline-block;
+        }
         .categoryHeader .statusIcon {
           position: relative;
           top: 2px;
@@ -646,7 +896,7 @@
         }
         .noResultsMessage {
           width: 100%;
-          max-width: 1280px;
+          max-width: 1600px;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
           box-shadow: var(--elevation-level-1);
@@ -655,7 +905,8 @@
         }
         table.resultsTable {
           width: 100%;
-          max-width: 1280px;
+          max-width: 1600px;
+          table-layout: fixed;
           margin-top: var(--spacing-m);
           background-color: var(--background-color-primary);
           box-shadow: var(--elevation-level-1);
@@ -665,6 +916,18 @@
           font-weight: var(--font-weight-bold);
           padding: var(--spacing-s);
         }
+        tr.headerRow th.nameCol {
+          width: 200px;
+          padding-left: var(--spacing-l);
+        }
+        tr.headerRow th.summaryCol {
+          width: 99%;
+        }
+        tr.headerRow th.expanderCol {
+          width: 30px;
+          padding-right: var(--spacing-l);
+        }
+
         gr-button.showAll {
           margin: var(--spacing-m);
         }
@@ -675,20 +938,23 @@
     ];
   }
 
-  protected updated(changedProperties: PropertyValues) {
+  protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
     if (changedProperties.has('tabState') && this.tabState) {
       const {statusOrCategory, checkName} = this.tabState;
-      if (
+      if (isCategory(statusOrCategory)) {
+        const expanded = this.isSectionExpanded.get(statusOrCategory);
+        if (!expanded) this.toggleExpanded(statusOrCategory);
+      }
+      if (checkName) {
+        this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
+      } else if (
         statusOrCategory &&
         statusOrCategory !== RunStatus.RUNNING &&
         statusOrCategory !== RunStatus.RUNNABLE
       ) {
-        let cat = statusOrCategory.toString().toLowerCase();
-        if (statusOrCategory === RunStatus.COMPLETED) cat = 'success';
-        this.scrollElIntoView(`.categoryHeader .${cat}`);
-      } else if (checkName) {
-        this.scrollElIntoView(`gr-result-row.${charsOnly(checkName)}`);
+        const cat = statusOrCategory.toString().toLowerCase();
+        this.scrollElIntoView(`.categoryHeader.${cat} + table gr-result-row`);
       }
     }
   }
@@ -696,26 +962,43 @@
   private scrollElIntoView(selector: string) {
     this.updateComplete.then(() => {
       let el = this.shadowRoot?.querySelector(selector);
-      // <gr-result-row> has display:contents and cannot be scrolled into view
-      // itself. Thus we are preferring to scroll the first child into view.
-      el = el?.shadowRoot?.firstElementChild ?? el;
-      el?.scrollIntoView({block: 'center'});
+      // el might be a <gr-result-row> with an empty shadowRoot. Let's wait a
+      // moment before trying to find a child element in it.
+      setTimeout(() => {
+        if (el) (el as HTMLElement).focus();
+        // <gr-result-row> has display:contents and cannot be scrolled into view
+        // itself. Thus we are preferring to scroll the first child into view.
+        el = el?.shadowRoot?.firstElementChild ?? el;
+        el?.scrollIntoView({block: 'center'});
+      }, 0);
     });
   }
 
-  render() {
+  override render() {
+    const headerClasses = {
+      header: true,
+      notLatest: !!this.checksPatchsetNumber,
+    };
     return html`
-      <div class="header">
+      <div class="${classMap(headerClasses)}">
         <div class="headerTopRow">
           <div class="left">
             <h2 class="heading-2">Results</h2>
-          </div>
-          <div class="middle">
-            <span ?hidden="${!this.someProvidersAreLoading}">Loading...</span>
+            <div class="loading" ?hidden="${!this.someProvidersAreLoading}">
+              <span>Loading results </span>
+              <span class="loadingSpin"></span>
+            </div>
           </div>
           <div class="right">
+            <div class="goToLatest">
+              <gr-button @click="${this.goToLatestPatchset}" link
+                >Go to latest patchset</gr-button
+              >
+            </div>
             <gr-dropdown-list
-              value="${this.checksPatchsetNumber}"
+              value="${this.checksPatchsetNumber ??
+              this.latestPatchsetNumber ??
+              0}"
               .items="${this.createPatchsetDropdownItems()}"
               @value-change="${this.onPatchsetSelected}"
             ></gr-dropdown-list>
@@ -723,24 +1006,64 @@
         </div>
         <div class="headerBottomRow">
           <div class="left">${this.renderFilter()}</div>
-          <div class="right">${this.renderLinks()}${this.renderActions()}</div>
+          <div class="right">${this.renderLinksAndActions()}</div>
         </div>
       </div>
       <div class="body">
         ${this.renderSection(Category.ERROR)}
         ${this.renderSection(Category.WARNING)}
-        ${this.renderSection(Category.INFO)} ${this.renderSection('SUCCESS')}
+        ${this.renderSection(Category.INFO)}
+        ${this.renderSection(Category.SUCCESS)}
       </div>
     `;
   }
 
-  private renderLinks() {
-    const links = (this.links ?? []).slice(0, 4);
-    if (links.length === 0) return;
-    return html`<div class="links">${links.map(this.renderLink)}</div>`;
+  private renderLinksAndActions() {
+    const links = this.links ?? [];
+    const primaryLinks = links
+      .filter(a => a.primary)
+      // Showing the same icons twice without text is super confusing.
+      .filter(
+        (link: Link, index: number, array: Link[]) =>
+          array.findIndex(other => link.icon === other.icon) === index
+      )
+      .slice(0, 4);
+    const overflowLinks = links.filter(a => !primaryLinks.includes(a));
+    const overflowLinkItems = overflowLinks.map(link => {
+      return {
+        ...link,
+        id: link.tooltip,
+        name: link.tooltip,
+        target: '_blank',
+        tooltip: undefined,
+      };
+    });
+
+    const actions = this.actions ?? [];
+    const primaryActions = actions.filter(a => a.primary).slice(0, 2);
+    const overflowActions = actions.filter(a => !primaryActions.includes(a));
+    const overflowActionItems = overflowActions.map(action => {
+      return {...action, id: action.name};
+    });
+    const disabledActions = overflowActionItems
+      .filter(action => action.disabled)
+      .map(action => action.id);
+
+    return html`
+      ${primaryLinks.map(this.renderLink)}
+      ${primaryLinks.length > 0 && primaryActions.length > 0
+        ? html`<div class="space"></div>`
+        : ''}
+      ${primaryActions.map(this.renderAction)}
+      ${this.renderOverflow(
+        [...overflowLinkItems, ...overflowActionItems],
+        disabledActions
+      )}
+    `;
   }
 
-  private renderLink(link: Link) {
+  private renderLink(link?: Link) {
+    if (!link) return;
     const tooltipText = link.tooltip ?? tooltipForLink(link.icon);
     return html`<a href="${link.url}" target="_blank"
       ><iron-icon
@@ -752,21 +1075,17 @@
     >`;
   }
 
-  private renderActions() {
-    const overflowItems = this.actions.slice(2).map(action => {
-      return {...action, id: action.name};
-    });
+  private renderOverflow(items: DropdownLink[], disabledIds: string[] = []) {
+    if (items.length === 0) return;
     return html`
-      ${this.renderAction(this.actions[0])}
-      ${this.renderAction(this.actions[1])}
       <gr-dropdown
         id="moreActions"
         link=""
         vertical-offset="32"
         horizontal-align="right"
         @tap-item="${this.handleAction}"
-        ?hidden="${overflowItems.length === 0}"
-        .items="${overflowItems}"
+        .items="${items}"
+        .disabledIds="${disabledIds}"
       >
         <iron-icon icon="gr-icons:more-vert" aria-labelledby="moreMessage">
         </iron-icon>
@@ -776,7 +1095,7 @@
   }
 
   private handleAction(e: CustomEvent<Action>) {
-    fireActionTriggered(this, e.detail);
+    this.checksService.triggerAction(e.detail);
   }
 
   private renderAction(action?: Action) {
@@ -790,6 +1109,10 @@
     this.checksService.setPatchset(patchset as PatchSetNumber);
   }
 
+  private goToLatestPatchset() {
+    this.checksService.setPatchset(undefined);
+  }
+
   private createPatchsetDropdownItems() {
     if (!this.latestPatchsetNumber) return [];
     return Array.from(Array(this.latestPatchsetNumber), (_, i) => {
@@ -829,43 +1152,22 @@
           placeholder="Filter results by regular expression"
           @input="${this.onInput}"
         />
-        <div class="selection">${this.renderSelectionFilter()}</div>
       </div>
     `;
   }
 
-  renderSelectionFilter() {
-    const count = this.selectedRuns.length;
-    if (count === 0) return;
-    return html`
-      <iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
-      <span>Filtered by ${pluralize(count, 'run')}</span>
-      <gr-button link class="reset" @click="${this.handleClick}"
-        >Reset View</gr-button
-      >
-    `;
-  }
-
-  handleClick() {
-    this.filterRegExp = new RegExp('');
-    fireRunSelectionReset(this);
-  }
-
   onInput() {
     assertIsDefined(this.filterInput, 'filter <input> element');
     this.filterRegExp = new RegExp(this.filterInput.value, 'i');
   }
 
-  renderSection(category: Category | 'SUCCESS') {
+  renderSection(category: Category) {
     const catString = category.toString().toLowerCase();
-    let allRuns = this.runs.filter(run =>
+    const isWarningOrError =
+      category === Category.WARNING || category === Category.ERROR;
+    const allRuns = this.runs.filter(run =>
       isAttemptSelected(this.selectedAttempts, run)
     );
-    if (category === 'SUCCESS') {
-      allRuns = allRuns.filter(hasCompletedWithoutResults);
-    } else {
-      allRuns = allRuns.filter(r => hasResultsOf(r, category));
-    }
     const all = allRuns.reduce(
       (results: RunResult[], run) => [
         ...results,
@@ -873,28 +1175,26 @@
       ],
       []
     );
+    const isSelection = this.selectedRuns.length > 0;
     const selected = all.filter(result => this.isRunSelected(result));
-    const filtered = selected.filter(
-      result =>
-        this.filterRegExp.test(result.checkName) ||
-        this.filterRegExp.test(result.summary)
+    const filtered = selected.filter(result =>
+      matches(result, this.filterRegExp)
     );
     let expanded = this.isSectionExpanded.get(category);
     const expandedByUser = this.isSectionExpandedByUser.get(category) ?? false;
     if (!expandedByUser || expanded === undefined) {
-      expanded = selected.length > 0;
+      expanded = selected.length > 0 && (isWarningOrError || isSelection);
       this.isSectionExpanded.set(category, expanded);
     }
     const expandedClass = expanded ? 'expanded' : 'collapsed';
     const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
     const isShowAll = this.isShowAll.get(category) ?? false;
-    const showAllThreshold = SHOW_ALL_THRESHOLDS.get(category) ?? 5;
     const resultCount = filtered.length;
-    const resultLimit = isShowAll ? 1000 : showAllThreshold;
+    const resultLimit = isShowAll ? 1000 : 20;
     const showAllButton = this.renderShowAllButton(
       category,
       isShowAll,
-      showAllThreshold,
+      resultLimit,
       resultCount
     );
     return html`
@@ -904,14 +1204,17 @@
           @click="${() => this.toggleExpanded(category)}"
         >
           <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
-          <iron-icon
-            icon="gr-icons:${iconForCategory(category)}"
-            class="statusIcon ${catString}"
-          ></iron-icon>
-          <span class="title">${catString}</span>
-          <span class="count"
-            >${this.renderCount(all, selected, filtered)}</span
-          >
+          <div class="statusIconWrapper">
+            <iron-icon
+              icon="gr-icons:${iconFor(category)}"
+              class="statusIcon ${catString}"
+            ></iron-icon>
+            <span class="title">${catString}</span>
+            <span class="count">${this.renderCount(all, filtered)}</span>
+            <paper-tooltip offset="5"
+              >${CATEGORY_TOOLTIPS.get(category)}</paper-tooltip
+            >
+          </div>
         </h3>
         ${this.renderResults(
           all,
@@ -925,7 +1228,7 @@
   }
 
   renderShowAllButton(
-    category: Category | 'SUCCESS',
+    category: Category,
     isShowAll: boolean,
     showAllThreshold: number,
     resultCount: number
@@ -935,7 +1238,7 @@
     const handler = () => this.toggleShowAll(category);
     return html`
       <tr class="showAllRow">
-        <td colspan="4">
+        <td colspan="3">
           <gr-button class="showAll" link @click="${handler}"
             >${message}</gr-button
           >
@@ -944,7 +1247,7 @@
     `;
   }
 
-  toggleShowAll(category: Category | 'SUCCESS') {
+  toggleShowAll(category: Category) {
     const current = this.isShowAll.get(category) ?? false;
     this.isShowAll.set(category, !current);
     this.requestUpdate();
@@ -975,7 +1278,6 @@
       <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>
@@ -985,9 +1287,9 @@
           ${repeat(
             filtered,
             result => result.internalResultId,
-            result => html`
+            (result?: RunResult) => html`
               <gr-result-row
-                class="${charsOnly(result.checkName)}"
+                class="${charsOnly(result!.checkName)}"
                 .result="${result}"
               ></gr-result-row>
             `
@@ -998,17 +1300,14 @@
     `;
   }
 
-  renderCount(all: RunResult[], selected: RunResult[], filtered: RunResult[]) {
+  renderCount(all: RunResult[], filtered: RunResult[]) {
     if (all.length === filtered.length) {
       return html`(${all.length})`;
     }
-    if (all.length !== selected.length) {
-      return html`<span class="filtered"> - filtered</span>`;
-    }
     return html`(${filtered.length} of ${all.length})`;
   }
 
-  toggleExpanded(category: Category | 'SUCCESS') {
+  toggleExpanded(category: Category) {
     const expanded = this.isSectionExpanded.get(category);
     assertIsDefined(expanded, 'expanded must have been set in initial render');
     this.isSectionExpanded.set(category, !expanded);
@@ -1016,8 +1315,10 @@
     this.requestUpdate();
   }
 
-  computeRunResults(category: Category | 'SUCCESS', run: CheckRun) {
-    if (category === 'SUCCESS') return [this.computeSuccessfulRunResult(run)];
+  computeRunResults(category: Category, run: CheckRun) {
+    if (category === Category.SUCCESS && hasCompletedWithoutResults(run)) {
+      return [this.computeSuccessfulRunResult(run)];
+    }
     return (
       run.results
         ?.filter(result => result.category === category)
@@ -1030,7 +1331,7 @@
   computeSuccessfulRunResult(run: CheckRun): RunResult {
     const adaptedRun: RunResult = {
       internalResultId: run.internalRunId + '-0',
-      category: Category.INFO, // will not be used, but is required
+      category: Category.SUCCESS,
       summary: run.statusDescription ?? '',
       ...run,
     };
@@ -1056,53 +1357,10 @@
   }
 }
 
-@customElement('gr-checks-action')
-export class GrChecksAction extends GrLitElement {
-  @property()
-  action!: Action;
-
-  connectedCallback() {
-    super.connectedCallback();
-    checkRequiredProperty(this.action, 'action');
-  }
-
-  static get styles() {
-    return [
-      css`
-        :host {
-          display: inline-block;
-        }
-        gr-button {
-          --padding: var(--spacing-s) var(--spacing-m);
-        }
-        gr-button paper-tooltip {
-          text-transform: none;
-        }
-      `,
-    ];
-  }
-
-  render() {
-    return html`
-      <gr-button link class="action" @click="${this.handleClick}">
-        ${this.action.name}
-        <paper-tooltip ?hidden="${!this.action.tooltip}" offset="5"
-          >${this.action.tooltip}</paper-tooltip
-        >
-      </gr-button>
-    `;
-  }
-
-  handleClick() {
-    fireActionTriggered(this, this.action);
-  }
-}
-
 declare global {
   interface HTMLElementTagNameMap {
     'gr-result-row': GrResultRow;
     'gr-result-expanded': GrResultExpanded;
     'gr-checks-results': GrChecksResults;
-    'gr-checks-action': GrChecksAction;
   }
 }
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
index de9c5fb..a643c18 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-runs.ts
@@ -14,53 +14,57 @@
  * 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 '@polymer/iron-icon/iron-icon';
+import {classMap} from 'lit/directives/class-map';
 import './gr-hovercard-run';
-import {
-  css,
-  customElement,
-  internalProperty,
-  property,
-  PropertyValues,
-  query,
-} from 'lit-element';
-import {GrLitElement} from '../lit/gr-lit-element';
+import {css, html, LitElement, nothing, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import './gr-checks-attempt';
 import {Action, Link, RunStatus} from '../../api/checks';
 import {sharedStyles} from '../../styles/shared-styles';
 import {
   AttemptDetail,
   compareByWorstCategory,
-  fireActionTriggered,
-  iconForCategory,
+  headerForStatus,
+  iconFor,
   iconForRun,
+  PRIMARY_STATUS_ACTIONS,
   primaryRunAction,
   worstCategory,
 } from '../../services/checks/checks-util';
 import {
+  allRunsSelectedPatchset$,
   CheckRun,
-  allRuns$,
+  ChecksPatchset,
+  ErrorMessages,
+  errorMessagesLatest$,
   fakeActions,
+  fakeLinks,
   fakeRun0,
   fakeRun1,
   fakeRun2,
   fakeRun3,
-  fakeRun4_1,
-  fakeRun4_2,
-  fakeRun4_3,
-  fakeRun4_4,
+  fakeRun4Att,
+  loginCallbackLatest$,
   updateStateSetResults,
-  fakeLinks,
 } from '../../services/checks/checks-model';
 import {assertIsDefined} from '../../utils/common-util';
-import {whenVisible} from '../../utils/dom-util';
-import {fireAttemptSelected, fireRunSelected} from './gr-checks-util';
+import {modifierPressed, whenVisible} from '../../utils/dom-util';
+import {
+  fireAttemptSelected,
+  fireRunSelected,
+  fireRunSelectionReset,
+} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
 import {charsOnly} from '../../utils/string-util';
+import {appContext} from '../../services/app-context';
+import {KnownExperimentId} from '../../services/flags/flags';
+import {subscribe} from '../lit/subscription-controller';
+import {fontStyles} from '../../styles/gr-font-styles';
 
 @customElement('gr-checks-run')
-export class GrChecksRun extends GrLitElement {
-  static get styles() {
+export class GrChecksRun extends LitElement {
+  static override get styles() {
     return [
       sharedStyles,
       css`
@@ -81,20 +85,19 @@
           overflow: hidden;
           white-space: nowrap;
           text-overflow: ellipsis;
+          flex-shrink: 1;
+        }
+        .middle {
+          /* extra space must go between middle and right */
+          flex-grow: 1;
+          white-space: nowrap;
+        }
+        .middle gr-checks-attempt {
+          margin-left: var(--spacing-s);
         }
         .name {
           font-weight: var(--font-weight-bold);
         }
-        .attempt {
-          display: inline-block;
-          background-color: var(--tag-gray);
-          border-radius: var(--line-height-normal);
-          height: var(--line-height-normal);
-          width: var(--line-height-normal);
-          text-align: center;
-          vertical-align: top;
-          font-size: var(--font-size-small);
-        }
         .chip.error {
           border-left: var(--thick-border) solid var(--error-foreground);
         }
@@ -128,6 +131,12 @@
         iron-icon.check-circle-outline {
           color: var(--success-foreground);
         }
+        div.chip:hover {
+          background-color: var(--hover-background-color);
+        }
+        div.chip:focus-within {
+          background-color: var(--selection-background-color);
+        }
         /* Additional 'div' for increased specificity. */
         div.chip.selected {
           border: 1px solid var(--selected-background);
@@ -138,17 +147,13 @@
         div.chip.selected iron-icon.filter {
           color: var(--selected-foreground);
         }
-        .chip.selected gr-button.action,
-        .chip.deselected gr-button.action {
-          display: none;
-        }
-        gr-button.action {
-          --padding: var(--spacing-xs) var(--spacing-m);
+        gr-checks-action {
           /* 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));
+          margin: calc(0px - var(--spacing-s));
+          margin-left: var(--spacing-s);
         }
         .attemptDetails {
           padding-bottom: var(--spacing-s);
@@ -167,6 +172,10 @@
           top: 3px;
           margin-right: var(--spacing-s);
         }
+        .statusLinkIcon {
+          color: var(--link-color);
+          margin-left: var(--spacing-s);
+        }
       `,
     ];
   }
@@ -174,31 +183,27 @@
   @query('.chip')
   chipElement?: HTMLElement;
 
-  @property()
+  @property({attribute: false})
   run!: CheckRun;
 
-  @property()
+  @property({attribute: false})
   selected = false;
 
-  @property()
+  @property({attribute: false})
   selectedAttempt?: number;
 
-  @property()
+  @property({attribute: false})
   deselected = false;
 
-  @property()
+  @state()
   shouldRender = false;
 
-  firstUpdated() {
+  override firstUpdated() {
     assertIsDefined(this.chipElement, 'chip element');
-    whenVisible(
-      this.chipElement,
-      () => this.setAttribute('shouldRender', 'true'),
-      200
-    );
+    whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
   }
 
-  protected updated(changedProperties: PropertyValues) {
+  protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
 
     // For some reason the browser does not pick up the correct `checked` state
@@ -213,7 +218,7 @@
     }
   }
 
-  render() {
+  override render() {
     if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
 
     const icon = iconForRun(this.run);
@@ -226,25 +231,26 @@
     const action = primaryRunAction(this.run);
 
     return html`
-      <div @click="${this.handleChipClick}" class="${classMap(classes)}">
-        <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
+      <div
+        @click="${this.handleChipClick}"
+        @keydown="${this.handleChipKey}"
+        class="${classMap(classes)}"
+        tabindex="0"
+      >
         <div class="left">
+          <gr-hovercard-run .run="${this.run}"></gr-hovercard-run>
           ${this.renderFilterIcon()}
           <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
           ${this.renderAdditionalIcon()}
           <span class="name">${this.run.checkName}</span>
-          <span class="attempt" ?hidden="${this.run.isSingleAttempt}"
-            >${this.run.attempt}</span
-          >
+        </div>
+        <div class="middle">
+          <gr-checks-attempt .run="${this.run}"></gr-checks-attempt>
+          ${this.renderStatusLink()}
         </div>
         <div class="right">
           ${action
-            ? html`<gr-button
-                class="action"
-                link
-                @click="${(e: MouseEvent) => this.handleAction(e, action)}"
-                >${action.name}</gr-button
-              >`
+            ? html`<gr-checks-action .action="${action}"></gr-checks-action>`
             : ''}
         </div>
       </div>
@@ -268,16 +274,20 @@
     const checkNameId = charsOnly(this.run.checkName).toLowerCase();
     const id = `attempt-${detail.attempt}`;
     const icon = detail.icon;
+    const wasNotRun = icon === iconFor(RunStatus.RUNNABLE);
     return html`<div class="attemptDetail">
       <input
         type="radio"
         id="${id}"
         name="${`${checkNameId}-attempt-choice`}"
         ?checked="${this.isSelected(detail)}"
+        ?disabled="${!this.isSelected(detail) && wasNotRun}"
         @change="${() => this.handleAttemptChange(detail)}"
       />
       <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
-      <label for="${id}">Attempt ${detail.attempt}</label>
+      <label for="${id}">
+        Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
+      </label>
     </div>`;
   }
 
@@ -287,6 +297,26 @@
     }
   }
 
+  renderStatusLink() {
+    const link = this.run.statusLink;
+    if (!link) return;
+    return html`
+      <a href="${link}" target="_blank" @click="${this.onLinkClick}"
+        ><iron-icon
+          class="statusLinkIcon"
+          icon="gr-icons:launch"
+          aria-label="external link to run status details"
+        ></iron-icon>
+        <paper-tooltip offset="5">Link to run status details</paper-tooltip>
+      </a>
+    `;
+  }
+
+  private onLinkClick(e: MouseEvent) {
+    // Prevents handleChipClick() from reacting to <a> link clicks.
+    e.stopPropagation();
+  }
+
   renderFilterIcon() {
     if (!this.selected) return;
     return html`
@@ -302,7 +332,7 @@
     if (this.run.status !== RunStatus.RUNNING) return nothing;
     const category = worstCategory(this.run);
     if (!category) return nothing;
-    const icon = iconForCategory(category);
+    const icon = iconFor(category);
     return html`
       <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
     `;
@@ -314,53 +344,94 @@
     fireRunSelected(this, this.run.checkName);
   }
 
-  private handleAction(e: MouseEvent, action: Action) {
-    e.stopPropagation();
+  private handleChipKey(e: KeyboardEvent) {
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
     e.preventDefault();
-    fireActionTriggered(this, action, this.run);
+    e.stopPropagation();
+    fireRunSelected(this, this.run.checkName);
   }
 }
 
 @customElement('gr-checks-runs')
-export class GrChecksRuns extends GrLitElement {
+export class GrChecksRuns extends LitElement {
   @query('#filterInput')
   filterInput?: HTMLInputElement;
 
-  @internalProperty()
+  @state()
   filterRegExp = new RegExp('');
 
-  @property()
+  @property({attribute: false})
   runs: CheckRun[] = [];
 
-  @property()
+  @property({type: Boolean, reflect: true})
+  collapsed = false;
+
+  @property({attribute: false})
   selectedRuns: string[] = [];
 
   /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @property()
+  @property({attribute: false})
   selectedAttempts: Map<string, number | undefined> = new Map<
     string,
     number | undefined
   >();
 
-  @property()
+  @property({attribute: false})
   tabState?: ChecksTabState;
 
+  @state()
+  errorMessages: ErrorMessages = {};
+
+  @state()
+  loginCallback?: () => void;
+
   private isSectionExpanded = new Map<RunStatus, boolean>();
 
+  private flagService = appContext.flagsService;
+
+  private checksService = appContext.checksService;
+
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
+    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
+    subscribe(this, errorMessagesLatest$, x => (this.errorMessages = x));
+    subscribe(this, loginCallbackLatest$, x => (this.loginCallback = x));
   }
 
-  static get styles() {
+  static override get styles() {
     return [
       sharedStyles,
+      fontStyles,
       css`
         :host {
           display: block;
+        }
+        :host(:not([collapsed])) {
+          min-width: 320px;
           padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
             var(--spacing-xl);
         }
+        :host([collapsed]) {
+          padding: var(--spacing-l) 0;
+        }
+        .title {
+          display: flex;
+        }
+        .title .flex-space {
+          flex-grow: 1;
+        }
+        .title gr-button {
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+          white-space: nowrap;
+        }
+        .title gr-button.expandButton {
+          --gr-button-padding: var(--spacing-xs) var(--spacing-s);
+        }
+        :host(:not([collapsed])) .expandButton {
+          margin-right: calc(0px - var(--spacing-m));
+        }
         .expandIcon {
           width: var(--line-height-h3);
           height: var(--line-height-h3);
@@ -398,11 +469,44 @@
         .testing:hover * {
           visibility: visible;
         }
+        .zero {
+          padding: var(--spacing-m) 0;
+          color: var(--primary-text-color);
+          margin-top: var(--spacing-m);
+        }
+        .login,
+        .error {
+          padding: var(--spacing-m);
+          color: var(--primary-text-color);
+          margin-top: var(--spacing-m);
+          max-width: 400px;
+        }
+        .error {
+          display: flex;
+          background-color: var(--error-background);
+        }
+        .error iron-icon {
+          color: var(--error-foreground);
+          margin-right: var(--spacing-m);
+        }
+        .login {
+          background: var(--info-background);
+        }
+        .login iron-icon {
+          color: var(--info-foreground);
+        }
+        .login .buttonRow {
+          text-align: right;
+          margin-top: var(--spacing-xl);
+        }
+        .login gr-button {
+          margin: 0 var(--spacing-s);
+        }
       `,
     ];
   }
 
-  protected updated(changedProperties: PropertyValues) {
+  protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
     if (changedProperties.has('tabState') && this.tabState) {
       const {statusOrCategory} = this.tabState;
@@ -419,9 +523,17 @@
     }
   }
 
-  render() {
+  override render() {
+    if (this.collapsed) {
+      return html`${this.renderCollapseButton()}`;
+    }
     return html`
-      <h2 class="heading-2">Runs</h2>
+      <h2 class="title">
+        <div class="heading-2">Runs</div>
+        <div class="flex-space"></div>
+        ${this.renderTitleButtons()} ${this.renderCollapseButton()}
+      </h2>
+      ${this.renderErrors()} ${this.renderSignIn()} ${this.renderZeroState()}
       <input
         id="filterInput"
         type="text"
@@ -429,63 +541,143 @@
         ?hidden="${!this.showFilter()}"
         @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], fakeActions, fakeLinks)}"
-          >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_1, fakeRun4_2, fakeRun4_3, fakeRun4_4]);
-          }}"
-          >4</gr-button
-        >
-        <gr-button link @click="${this.all}">all</gr-button>
+      ${this.renderSection(RunStatus.COMPLETED)}
+      ${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
+    `;
+  }
+
+  private renderZeroState() {
+    if (this.runs.length > 0) return;
+    return html`<div class="zero">No Check Run to show</div>`;
+  }
+
+  private renderErrors() {
+    return Object.entries(this.errorMessages).map(
+      ([plugin, message]) =>
+        html`
+          <div class="error">
+            <div class="left">
+              <iron-icon icon="gr-icons:error"></iron-icon>
+            </div>
+            <div class="right">
+              <div class="message">
+                Error while fetching results for ${plugin}:<br />${message}
+              </div>
+            </div>
+          </div>
+        `
+    );
+  }
+
+  private renderSignIn() {
+    if (!this.loginCallback) return;
+    return html`
+      <div class="login">
+        <div>
+          <iron-icon
+            class="info-outline"
+            icon="gr-icons:info-outline"
+          ></iron-icon>
+          Sign in to Checks Plugin to see runs and results
+        </div>
+        <div class="buttonRow">
+          <gr-button @click="${this.loginCallback}" link>Sign in</gr-button>
+        </div>
       </div>
     `;
   }
 
+  private renderTitleButtons() {
+    if (this.selectedRuns.length < 2) return;
+    const actions = this.selectedRuns.map(selected => {
+      const run = this.runs.find(
+        run => run.isLatestAttempt && run.checkName === selected
+      );
+      return primaryRunAction(run);
+    });
+    const runButtonDisabled = !actions.every(
+      action =>
+        action?.name === PRIMARY_STATUS_ACTIONS.RUN ||
+        action?.name === PRIMARY_STATUS_ACTIONS.RERUN
+    );
+    return html`
+      <gr-button
+        class="font-normal"
+        link
+        @click="${() => fireRunSelectionReset(this)}"
+        >Unselect All</gr-button
+      >
+      <gr-tooltip-content
+        title="${runButtonDisabled
+          ? 'Disabled. Unselect checks without a "Run" action to enable the button.'
+          : ''}"
+        ?has-tooltip=${runButtonDisabled}
+      >
+        <gr-button
+          class="font-normal"
+          link
+          ?disabled=${runButtonDisabled}
+          @click="${() => {
+            actions.forEach(action => this.checksService.triggerAction(action));
+          }}"
+          >Run Selected</gr-button
+        >
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderCollapseButton() {
+    return html`
+      <gr-tooltip-content
+        has-tooltip
+        title="${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}"
+      >
+        <gr-button
+          link
+          class="expandButton"
+          role="switch"
+          aria-checked="${this.collapsed ? 'true' : 'false'}"
+          aria-label="${this.collapsed
+            ? 'Expand runs panel'
+            : 'Collapse runs panel'}"
+          @click="${() => (this.collapsed = !this.collapsed)}"
+          ><iron-icon
+            class="expandIcon"
+            icon="${this.collapsed
+              ? 'gr-icons:chevron-right'
+              : 'gr-icons:chevron-left'}"
+          ></iron-icon>
+        </gr-button>
+      </gr-tooltip-content>
+    `;
+  }
+
   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', []);
+    updateStateSetResults('f0', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f1', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f2', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f3', [], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f4', [], [], [], ChecksPatchset.LATEST);
   }
 
   all() {
-    updateStateSetResults('f0', [fakeRun0], fakeActions, fakeLinks);
-    updateStateSetResults('f1', [fakeRun1]);
-    updateStateSetResults('f2', [fakeRun2]);
-    updateStateSetResults('f3', [fakeRun3]);
-    updateStateSetResults('f4', [
-      fakeRun4_1,
-      fakeRun4_2,
-      fakeRun4_3,
-      fakeRun4_4,
-    ]);
+    updateStateSetResults(
+      'f0',
+      [fakeRun0],
+      fakeActions,
+      fakeLinks,
+      ChecksPatchset.LATEST
+    );
+    updateStateSetResults('f1', [fakeRun1], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f2', [fakeRun2], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f3', [fakeRun3], [], [], ChecksPatchset.LATEST);
+    updateStateSetResults('f4', fakeRun4Att, [], [], ChecksPatchset.LATEST);
   }
 
   toggle(
@@ -495,7 +687,13 @@
     links: Link[] = []
   ) {
     const newRuns = this.runs.includes(runs[0]) ? [] : runs;
-    updateStateSetResults(plugin, newRuns, actions, links);
+    updateStateSetResults(
+      plugin,
+      newRuns,
+      actions,
+      links,
+      ChecksPatchset.LATEST
+    );
   }
 
   renderSection(status: RunStatus) {
@@ -515,7 +713,7 @@
           @click="${() => this.toggleExpanded(status)}"
         >
           <iron-icon class="expandIcon" icon="${icon}"></iron-icon>
-          <h3 class="heading-3">${status.toLowerCase()}</h3>
+          <h3 class="heading-3">${headerForStatus(status)}</h3>
         </div>
         <div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
       </div>
@@ -547,6 +745,35 @@
     }
     return show;
   }
+
+  renderFakeControls() {
+    if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return;
+    return html`
+      <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], fakeActions, fakeLinks)}"
+          >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', fakeRun4Att)}}"
+          >4</gr-button
+        >
+        <gr-button link @click="${this.all}">all</gr-button>
+      </div>
+    `;
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
index 9a1605e..dbc2bec 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-styles.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import {css} from 'lit-element';
+import {css} from 'lit';
 
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
index 418598b..ed6117a 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-tab.ts
@@ -14,61 +14,55 @@
  * 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 {LitElement, css, html, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
 import {Action} from '../../api/checks';
 import {
   CheckResult,
   CheckRun,
-  allResults$,
-  allRuns$,
-  checksPatchsetNumber$,
+  allResultsSelected$,
+  checksSelectedPatchsetNumber$,
+  allRunsSelectedPatchset$,
 } from '../../services/checks/checks-model';
 import './gr-checks-runs';
 import './gr-checks-results';
-import {changeNum$} from '../../services/change/change-model';
+import {changeNum$, latestPatchNum$} from '../../services/change/change-model';
 import {NumericChangeId, PatchSetNumber} from '../../types/common';
 import {ActionTriggeredEvent} from '../../services/checks/checks-util';
 import {AttemptSelectedEvent, RunSelectedEvent} from './gr-checks-util';
 import {ChecksTabState} from '../../types/events';
-import {fireAlert, fireEvent} from '../../utils/event-util';
 import {appContext} from '../../services/app-context';
-import {from, timer} from 'rxjs';
-import {takeUntil} from 'rxjs/operators';
+import {subscribe} from '../lit/subscription-controller';
 
 /**
  * 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()
+export class GrChecksTab extends LitElement {
+  @state()
   runs: CheckRun[] = [];
 
-  @property()
+  @state()
   results: CheckResult[] = [];
 
-  @property()
+  @property({type: Object})
   tabState?: ChecksTabState;
 
-  @property()
+  @state()
   checksPatchsetNumber: PatchSetNumber | undefined = undefined;
 
-  @property()
+  @state()
+  latestPatchsetNumber: PatchSetNumber | undefined = undefined;
+
+  @state()
   changeNum: NumericChangeId | undefined = undefined;
 
-  @internalProperty()
+  @state()
   selectedRuns: string[] = [];
 
   /** Maps checkName to selected attempt number. `undefined` means `latest`. */
-  @internalProperty()
+  @state()
   selectedAttempts: Map<string, number | undefined> = new Map<
     string,
     number | undefined
@@ -78,17 +72,22 @@
 
   constructor() {
     super();
-    this.subscribe('runs', allRuns$);
-    this.subscribe('results', allResults$);
-    this.subscribe('checksPatchsetNumber', checksPatchsetNumber$);
-    this.subscribe('changeNum', changeNum$);
+    subscribe(this, allRunsSelectedPatchset$, x => (this.runs = x));
+    subscribe(this, allResultsSelected$, x => (this.results = x));
+    subscribe(
+      this,
+      checksSelectedPatchsetNumber$,
+      x => (this.checksPatchsetNumber = x)
+    );
+    subscribe(this, latestPatchNum$, x => (this.latestPatchsetNumber = x));
+    subscribe(this, changeNum$, x => (this.changeNum = x));
 
     this.addEventListener('action-triggered', (e: ActionTriggeredEvent) =>
       this.handleActionTriggered(e.detail.action, e.detail.run)
     );
   }
 
-  static get styles() {
+  static override get styles() {
     return css`
       :host {
         display: block;
@@ -97,7 +96,6 @@
         display: flex;
       }
       .runs {
-        min-width: 300px;
         min-height: 400px;
         border-right: 1px solid var(--border-color);
       }
@@ -107,11 +105,12 @@
     `;
   }
 
-  render() {
+  override render() {
     return html`
       <div class="container">
         <gr-checks-runs
           class="runs"
+          ?collapsed="${this.offsetWidth < 1000}"
           .runs="${this.runs}"
           .selectedRuns="${this.selectedRuns}"
           .selectedAttempts="${this.selectedAttempts}"
@@ -131,7 +130,7 @@
     `;
   }
 
-  protected updated(changedProperties: PropertyValues) {
+  protected override updated(changedProperties: PropertyValues) {
     super.updated(changedProperties);
     if (changedProperties.has('tabState')) {
       if (this.tabState) {
@@ -141,34 +140,7 @@
   }
 
   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
-    );
-    // If plugins return undefined or not a promise, then show no toast.
-    if (!promise?.then) return;
-
-    fireAlert(this, `Triggering action '${action.name}' ...`);
-    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 || result.message) {
-          fireAlert(this, `${result.message ?? result.errorMessage}`);
-        } else {
-          fireEvent(this, 'hide-alert');
-        }
-        if (result.shouldReload) {
-          this.checksService.reloadForCheck(run?.checkName);
-        }
-      });
+    this.checksService.triggerAction(action, run);
   }
 
   handleRunSelected(e: RunSelectedEvent) {
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util.ts b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
index 05a87a4..e9bbb22 100644
--- a/polygerrit-ui/app/elements/checks/gr-checks-util.ts
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {CheckRun} from '../../services/checks/checks-model';
+import {CheckRun, RunResult} from '../../services/checks/checks-model';
 
 export interface AttemptSelectedEventDetail {
   checkName: string;
@@ -85,3 +85,12 @@
     (selected === undefined && run.isLatestAttempt) || selected === run.attempt
   );
 }
+
+export function matches(result: RunResult, regExp: RegExp) {
+  return (
+    regExp.test(result.checkName) ||
+    regExp.test(result.summary) ||
+    (result.tags ?? []).some(tag => regExp.test(tag.name)) ||
+    regExp.test(result.message ?? '')
+  );
+}
diff --git a/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
new file mode 100644
index 0000000..698a4a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/checks/gr-checks-util_test.ts
@@ -0,0 +1,34 @@
+/**
+ * @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';
+import {createRunResult} from '../../test/test-data-generators';
+import {matches} from './gr-checks-util';
+import {RunResult} from '../../services/checks/checks-model';
+
+suite('gr-checks-util test', () => {
+  test('regexp filter matching results', () => {
+    const result: RunResult = {
+      ...createRunResult(),
+      tags: [{name: 'tag'}],
+    };
+    assert.isTrue(matches(result, new RegExp('message')));
+    assert.isTrue(matches(result, new RegExp('summary')));
+    assert.isTrue(matches(result, new RegExp('name')));
+    assert.isTrue(matches(result, new RegExp('tag')));
+    assert.isFalse(matches(result, new RegExp('qwertyui')));
+  });
+});
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
index ae6408d..95b7157 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run.ts
@@ -14,101 +14,359 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import './gr-checks-styles';
-import {hovercardBehaviorMixin} from '../shared/gr-hovercard/gr-hovercard-behavior';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-hovercard-run_html';
-import {customElement, property} from '@polymer/decorators';
+import {fontStyles} from '../../styles/gr-font-styles';
+import {customElement, property} from 'lit/decorators';
+import './gr-checks-action';
 import {CheckRun} from '../../services/checks/checks-model';
 import {
-  iconForCategory,
-  iconForStatus,
+  AttemptDetail,
+  iconFor,
   runActions,
   worstCategory,
 } from '../../services/checks/checks-util';
 import {durationString, fromNow} from '../../utils/date-util';
 import {RunStatus} from '../../api/checks';
+import {ordinal} from '../../utils/string-util';
+import {HovercardMixin} from '../../mixins/hovercard-mixin/hovercard-mixin';
+import {css, html, LitElement} from 'lit';
+import {checksStyles} from './gr-checks-styles';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
 
 @customElement('gr-hovercard-run')
-export class GrHovercardRun extends hovercardBehaviorMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrHovercardRun extends base {
   @property({type: Object})
   run?: CheckRun;
 
-  computeIcon(run?: CheckRun) {
-    if (!run) return '';
-    const category = worstCategory(run);
-    if (category) return iconForCategory(category);
-    return run.status === RunStatus.COMPLETED
-      ? iconForStatus(RunStatus.COMPLETED)
-      : '';
+  static override get styles() {
+    return [
+      fontStyles,
+      checksStyles,
+      base.styles || [],
+      css`
+        #container {
+          min-width: 356px;
+          max-width: 356px;
+          padding: var(--spacing-xl) 0 var(--spacing-m) 0;
+        }
+        .row {
+          display: flex;
+          margin-top: var(--spacing-s);
+        }
+        .attempts.row {
+          flex-wrap: wrap;
+        }
+        .chipRow {
+          display: flex;
+          margin-top: var(--spacing-s);
+        }
+        .chip {
+          background: var(--gray-background);
+          color: var(--gray-foreground);
+          border-radius: 20px;
+          padding: var(--spacing-xs) var(--spacing-m) var(--spacing-xs)
+            var(--spacing-s);
+        }
+        .title {
+          color: var(--deemphasized-text-color);
+          margin-right: var(--spacing-m);
+        }
+        div.section {
+          margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
+          display: flex;
+        }
+        div.sectionIcon {
+          flex: 0 0 30px;
+        }
+        div.chip iron-icon {
+          width: 16px;
+          height: 16px;
+          /* Positioning of a 16px icon in the middle of a 20px line. */
+          position: relative;
+          top: 2px;
+        }
+        div.sectionIcon iron-icon {
+          position: relative;
+          top: 2px;
+          width: 20px;
+          height: 20px;
+        }
+        div.sectionIcon iron-icon.small {
+          position: relative;
+          top: 6px;
+          width: 16px;
+          height: 16px;
+        }
+        div.sectionContent iron-icon.link {
+          color: var(--link-color);
+        }
+        div.sectionContent .attemptIcon iron-icon,
+        div.sectionContent iron-icon.small {
+          width: 16px;
+          height: 16px;
+          margin-right: var(--spacing-s);
+          /* Positioning of a 16px icon in the middle of a 20px line. */
+          position: relative;
+          top: 2px;
+        }
+        div.sectionContent .attemptIcon iron-icon {
+          margin-right: 0;
+        }
+        .attemptIcon,
+        .attemptNumber {
+          margin-right: var(--spacing-s);
+          color: var(--deemphasized-text-color);
+          text-align: center;
+          width: 24px;
+          font-size: var(--font-size-small);
+        }
+        div.action {
+          border-top: 1px solid var(--border-color);
+          margin-top: var(--spacing-m);
+          padding: var(--spacing-m) var(--spacing-xl) 0;
+        }
+      `,
+    ];
   }
 
-  computeActions(run?: CheckRun) {
-    return runActions(run);
+  override render() {
+    if (!this.run) return '';
+    const icon = this.computeIcon();
+    return html`
+      <div id="container" role="tooltip" tabindex="-1">
+        <div class="section">
+          <div
+            ?hidden="${!this.run || this.run.status === RunStatus.RUNNABLE}"
+            class="chipRow"
+          >
+            <div class="chip">
+              <iron-icon icon="gr-icons:${this.computeChipIcon()}"></iron-icon>
+              <span>${this.run.status}</span>
+            </div>
+          </div>
+        </div>
+        <div class="section">
+          <div class="sectionIcon" ?hidden="${icon.length === 0}">
+            <iron-icon class="${icon}" icon="gr-icons:${icon}"></iron-icon>
+          </div>
+          <div class="sectionContent">
+            <h3 class="name heading-3">
+              <span>${this.run.checkName}</span>
+            </h3>
+          </div>
+        </div>
+        ${this.renderStatusSection()} ${this.renderAttemptSection()}
+        ${this.renderTimestampSection()} ${this.renderDescriptionSection()}
+        ${this.renderActions()}
+      </div>
+    `;
   }
 
-  computeChipIcon(run?: CheckRun) {
-    if (run?.status === RunStatus.COMPLETED) return 'check';
-    if (run?.status === RunStatus.RUNNING) return 'timelapse';
-    return '';
+  private renderStatusSection() {
+    if (!this.run || (!this.run.statusLink && !this.run.statusDescription))
+      return;
+
+    return html`
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          ${this.run.statusLink
+            ? html` <div class="row">
+                <div class="title">Status</div>
+                <div>
+                  <a href="${this.run.statusLink}" target="_blank"
+                    ><iron-icon
+                      aria-label="external link to check status"
+                      class="small link"
+                      icon="gr-icons:launch"
+                    ></iron-icon
+                    >${this.computeHostName(this.run.statusLink)}
+                  </a>
+                </div>
+              </div>`
+            : ''}
+          ${this.run.statusDescription
+            ? html` <div class="row">
+                <div class="title">Message</div>
+                <div>${this.run.statusDescription}</div>
+              </div>`
+            : ''}
+        </div>
+      </div>
+    `;
   }
 
-  computeCompletionDuration(run?: CheckRun) {
-    if (!run?.finishedTimestamp || !run?.startedTimestamp) return '';
-    return durationString(run.startedTimestamp, run.finishedTimestamp, true);
+  private renderAttemptSection() {
+    if (this.hideAttempts()) return;
+    const attempts = this.computeAttempts();
+    return html`
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="small" icon="gr-icons:arrow-forward"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          <div class="attempts row">
+            <div class="title">Attempt</div>
+            ${attempts.map(a => this.renderAttempt(a))}
+          </div>
+        </div>
+      </div>
+    `;
   }
 
-  computeDuration(date?: Date) {
-    return date ? fromNow(date) : '';
+  private renderAttempt(attempt: AttemptDetail) {
+    return html`
+      <div>
+        <div class="attemptIcon">
+          <iron-icon
+            class="${attempt.icon}"
+            icon="gr-icons:${attempt.icon}"
+          ></iron-icon>
+        </div>
+        <div class="attemptNumber">${ordinal(attempt.attempt)}</div>
+      </div>
+    `;
   }
 
-  computeHostName(link?: string) {
-    return link ? new URL(link).hostname : '';
+  private renderTimestampSection() {
+    if (
+      !this.run ||
+      (!this.run.startedTimestamp &&
+        !this.run.scheduledTimestamp &&
+        !this.run.finishedTimestamp)
+    )
+      return;
+
+    return html`
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          <div ?hidden="${this.hideScheduled()}" class="row">
+            <div class="title">Scheduled</div>
+            <div>${this.computeDuration(this.run.scheduledTimestamp)}</div>
+          </div>
+          <div ?hidden="${!this.run.startedTimestamp}" class="row">
+            <div class="title">Started</div>
+            <div>${this.computeDuration(this.run.startedTimestamp)}</div>
+          </div>
+          <div ?hidden="${!this.run.finishedTimestamp}" class="row">
+            <div class="title">Ended</div>
+            <div>${this.computeDuration(this.run.finishedTimestamp)}</div>
+          </div>
+          <div ?hidden="${this.hideCompletion()}" class="row">
+            <div class="title">Completion</div>
+            <div>${this.computeCompletionDuration()}</div>
+          </div>
+        </div>
+      </div>
+    `;
   }
 
-  hideChip(run?: CheckRun) {
-    return !run || run.status === RunStatus.RUNNABLE;
+  private renderDescriptionSection() {
+    if (!this.run || (!this.run.checkLink && !this.run.checkDescription))
+      return;
+    return html`
+      <div class="section">
+        <div class="sectionIcon">
+          <iron-icon class="small" icon="gr-icons:link"></iron-icon>
+        </div>
+        <div class="sectionContent">
+          ${this.run.checkDescription
+            ? html` <div class="row">
+                <div class="title">Description</div>
+                <div>${this.run.checkDescription}</div>
+              </div>`
+            : ''}
+          ${this.run.checkLink
+            ? html` <div class="row">
+                <div class="title">Documentation</div>
+                <div>
+                  <a href="${this.run.checkLink}" target="_blank"
+                    ><iron-icon
+                      aria-label="external link to check documentation"
+                      class="small link"
+                      icon="gr-icons:launch"
+                    ></iron-icon
+                    >${this.computeHostName(this.run.checkLink)}
+                  </a>
+                </div>
+              </div>`
+            : ''}
+        </div>
+      </div>
+    `;
   }
 
-  hideHeaderSectionIcon(run?: CheckRun) {
-    return this.computeIcon(run).length === 0;
-  }
-
-  hideStatusSection(run?: CheckRun) {
-    if (!run) return true;
-    return !run.statusLink && !run.statusDescription;
-  }
-
-  hideAttemptSection(run?: CheckRun) {
-    if (!run) return true;
-    return (
-      !run.startedTimestamp &&
-      !run.scheduledTimestamp &&
-      !run.finishedTimestamp &&
-      this.hideAttempts(run)
+  private renderActions() {
+    const actions = runActions(this.run);
+    return actions.map(
+      action =>
+        html`
+          <div class="action">
+            <gr-checks-action
+              .eventTarget="${this._target}"
+              .action="${action}"
+            ></gr-checks-action>
+          </div>
+        `
     );
   }
 
-  hideAttempts(run?: CheckRun) {
-    const attemptCount = run?.attemptDetails?.length;
+  computeIcon() {
+    if (!this.run) return '';
+    const category = worstCategory(this.run);
+    if (category) return iconFor(category);
+    return this.run.status === RunStatus.COMPLETED
+      ? iconFor(RunStatus.COMPLETED)
+      : '';
+  }
+
+  computeAttempts(): AttemptDetail[] {
+    const details = this.run?.attemptDetails ?? [];
+    const more =
+      details.length > 7 ? [{icon: 'more-horiz', attempt: undefined}] : [];
+    return [...more, ...details.slice(-7)];
+  }
+
+  private computeChipIcon() {
+    if (this.run?.status === RunStatus.COMPLETED) return 'check';
+    if (this.run?.status === RunStatus.RUNNING) return 'timelapse';
+    return '';
+  }
+
+  private computeCompletionDuration() {
+    if (!this.run?.finishedTimestamp || !this.run?.startedTimestamp) return '';
+    return durationString(
+      this.run.startedTimestamp,
+      this.run.finishedTimestamp,
+      true
+    );
+  }
+
+  private computeDuration(date?: Date) {
+    return date ? fromNow(date) : '';
+  }
+
+  private computeHostName(link?: string) {
+    return link ? new URL(link).hostname : '';
+  }
+
+  private hideAttempts() {
+    const attemptCount = this.run?.attemptDetails?.length;
     return attemptCount === undefined || attemptCount < 2;
   }
 
-  hideScheduled(run?: CheckRun) {
-    return !run?.scheduledTimestamp || !!run?.startedTimestamp;
+  private hideScheduled() {
+    return !this.run?.scheduledTimestamp || !!this.run?.startedTimestamp;
   }
 
-  hideCompletion(run?: CheckRun) {
-    return !run?.startedTimestamp || !run?.finishedTimestamp;
-  }
-
-  hideDescriptionSection(run?: CheckRun) {
-    if (!run) return true;
-    return !run.checkLink && !run.checkDescription;
+  private hideCompletion() {
+    return !this.run?.startedTimestamp || !this.run?.finishedTimestamp;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts b/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
deleted file mode 100644
index 08ceefe..0000000
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_html.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * @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 '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-checks-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-hovercard-shared-style">
-    #container {
-      min-width: 356px;
-      max-width: 356px;
-      padding: var(--spacing-xl) 0 var(--spacing-m) 0;
-    }
-    .row {
-      display: flex;
-      margin-top: var(--spacing-s);
-    }
-    .chipRow {
-      display: flex;
-      margin-top: var(--spacing-s);
-    }
-    .chip {
-      background: var(--gray-background);
-      color: var(--gray-foreground);
-      border-radius: 20px;
-      padding: var(--spacing-xs) var(--spacing-m) var(--spacing-xs)
-        var(--spacing-s);
-    }
-    .title {
-      color: var(--deemphasized-text-color);
-      margin-right: var(--spacing-m);
-    }
-    div.section {
-      margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
-      display: flex;
-    }
-    div.sectionIcon {
-      flex: 0 0 30px;
-    }
-    div.chip iron-icon {
-      width: 16px;
-      height: 16px;
-      /* Positioning of a 16px icon in the middle of a 20px line. */
-      position: relative;
-      top: 2px;
-    }
-    div.sectionIcon iron-icon {
-      position: relative;
-      top: 2px;
-      width: 20px;
-      height: 20px;
-    }
-    div.sectionIcon iron-icon.small {
-      position: relative;
-      top: 6px;
-      width: 16px;
-      height: 16px;
-    }
-    div.sectionContent iron-icon.link {
-      color: var(--link-color);
-    }
-    div.sectionContent .attemptIcon iron-icon,
-    div.sectionContent iron-icon.small {
-      width: 16px;
-      height: 16px;
-      margin-right: var(--spacing-s);
-      /* Positioning of a 16px icon in the middle of a 20px line. */
-      position: relative;
-      top: 2px;
-    }
-    .attemptNumber {
-      margin-right: var(--spacing-s);
-      color: var(--deemphasized-text-color);
-      text-align: center;
-    }
-    div.action {
-      border-top: 1px solid var(--border-color);
-      margin-top: var(--spacing-m);
-      padding: var(--spacing-m) var(--spacing-xl) 0;
-    }
-  </style>
-  <div id="container" role="tooltip" tabindex="-1">
-    <div class="section">
-      <div hidden$="[[hideChip(run)]]" class="chipRow">
-        <div class="chip">
-          <iron-icon icon="gr-icons:[[computeChipIcon(run)]]"></iron-icon>
-          <span>[[run.status]]</span>
-        </div>
-      </div>
-    </div>
-    <div class="section">
-      <div class="sectionIcon" hidden$="[[hideHeaderSectionIcon(run)]]">
-        <iron-icon
-          class$="[[computeIcon(run)]]"
-          icon="gr-icons:[[computeIcon(run)]]"
-        ></iron-icon>
-      </div>
-      <div class="sectionContent">
-        <h3 class="name heading-3">
-          <span>[[run.checkName]]</span>
-        </h3>
-      </div>
-    </div>
-    <div class="section" hidden$="[[hideStatusSection(run)]]">
-      <div class="sectionIcon">
-        <iron-icon class="small" icon="gr-icons:info-outline"></iron-icon>
-      </div>
-      <div class="sectionContent">
-        <div hidden$="[[!run.statusLink]]" class="row">
-          <div class="title">Status</div>
-          <div>
-            <a href="[[run.statusLink]]" target="_blank"
-              ><iron-icon
-                aria-label="external link to check status"
-                class="small link"
-                icon="gr-icons:launch"
-              ></iron-icon
-              >[[computeHostName(run.statusLink)]]
-            </a>
-          </div>
-        </div>
-        <div hidden$="[[!run.statusDescription]]" class="row">
-          <div class="title">Message</div>
-          <div>[[run.statusDescription]]</div>
-        </div>
-      </div>
-    </div>
-    <div class="section" hidden$="[[hideAttemptSection(run)]]">
-      <div class="sectionIcon">
-        <iron-icon class="small" icon="gr-icons:schedule"></iron-icon>
-      </div>
-      <div class="sectionContent">
-        <div hidden$="[[hideAttempts(run)]]" class="row">
-          <div class="title">Attempt</div>
-          <template is="dom-repeat" items="[[run.attemptDetails]]">
-            <div>
-              <div class="attemptIcon">
-                <iron-icon
-                  class$="[[item.icon]]"
-                  icon="gr-icons:[[item.icon]]"
-                ></iron-icon>
-              </div>
-              <div class="attemptNumber">[[item.attempt]]</div>
-            </div>
-          </template>
-        </div>
-        <div hidden$="[[hideScheduled(run)]]" class="row">
-          <div class="title">Scheduled</div>
-          <div>[[computeDuration(run.scheduledTimestamp)]]</div>
-        </div>
-        <div hidden$="[[!run.startedTimestamp]]" class="row">
-          <div class="title">Started</div>
-          <div>[[computeDuration(run.startedTimestamp)]]</div>
-        </div>
-        <div hidden$="[[!run.finishedTimestamp]]" class="row">
-          <div class="title">Ended</div>
-          <div>[[computeDuration(run.finishedTimestamp)]]</div>
-        </div>
-        <div hidden$="[[hideCompletion(run)]]" class="row">
-          <div class="title">Completion</div>
-          <div>[[computeCompletionDuration(run)]]</div>
-        </div>
-      </div>
-    </div>
-    <div class="section" hidden$="[[hideDescriptionSection(run)]]">
-      <div class="sectionIcon">
-        <iron-icon class="small" icon="gr-icons:link"></iron-icon>
-      </div>
-      <div class="sectionContent">
-        <div hidden$="[[!run.checkDescription]]" class="row">
-          <div class="title">Description</div>
-          <div>[[run.checkDescription]]</div>
-        </div>
-        <div hidden$="[[!run.checkLink]]" class="row">
-          <div class="title">Documentation</div>
-          <div>
-            <a href="[[run.checkLink]]" target="_blank"
-              ><iron-icon
-                aria-label="external link to check documentation"
-                class="small link"
-                icon="gr-icons:launch"
-              ></iron-icon
-              >[[computeHostName(run.checkLink)]]
-            </a>
-          </div>
-        </div>
-      </div>
-    </div>
-    <template is="dom-repeat" items="[[computeActions(run)]]">
-      <div class="action"><gr-button link>[[item.name]]</gr-button></div>
-    </template>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
similarity index 66%
rename from polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js
rename to polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
index 9c77654..352219a 100644
--- a/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.js
+++ b/polygerrit-ui/app/elements/checks/gr-hovercard-run_test.ts
@@ -15,27 +15,28 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import './gr-hovercard-run.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../test/common-test-setup-karma';
+import './gr-hovercard-run';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrHovercardRun} from './gr-hovercard-run';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard-run class="hovered"></gr-hovercard-run>
+  <gr-hovercard-run class="hovered"></gr-hovercard-run>
 `);
 
 suite('gr-hovercard-run tests', () => {
-  let element;
+  let element: GrHovercardRun;
 
   setup(async () => {
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrHovercardRun;
     await flush();
   });
 
   teardown(() => {
-    element.hide({});
+    element.hide(new MouseEvent('click'));
   });
 
   test('hovercard is shown', () => {
+    assert.equal(element.computeIcon(), '');
   });
 });
-
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 198f1d9..03fb4d5 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
@@ -14,17 +14,19 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../../styles/shared-styles';
 import '../../shared/gr-avatar/gr-avatar';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-dropdown_html';
 import {getUserName} from '../../../utils/display-name-util';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ServerInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
+import {
+  DropdownContent,
+  DropdownLink,
+} from '../../shared/gr-dropdown/gr-dropdown';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 const INTERPOLATE_URL_PATTERN = /\${([\w]+)}/g;
 
@@ -35,23 +37,13 @@
 }
 
 @customElement('gr-account-dropdown')
-export class GrAccountDropdown extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountDropdown extends LitElement {
   @property({type: Object})
   account?: AccountInfo;
 
   @property({type: Object})
   config?: ServerInfo;
 
-  @property({type: Array, computed: '_getLinks(_switchAccountUrl, _path)'})
-  links?: string[];
-
-  @property({type: Array, computed: '_getTopContent(account)'})
-  topContent?: string[];
-
   @property({type: String})
   _path = '/';
 
@@ -63,8 +55,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.handleLocationChange();
     window.addEventListener('location-change', this.handleLocationChange);
@@ -80,27 +71,72 @@
     });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     window.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
 
-  _getLinks(switchAccountUrl: string, path: string) {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        gr-dropdown {
+          padding: 0 var(--spacing-m);
+          --gr-button-text-color: var(--header-text-color);
+          --gr-dropdown-item-color: var(--primary-text-color);
+        }
+        gr-avatar {
+          height: 2em;
+          width: 2em;
+          vertical-align: middle;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`<gr-dropdown
+      link=""
+      .items="${this.links}"
+      .topContent="${this.topContent}"
+      @tap-item-shortcuts=${this._handleShortcutsTap}
+      .horizontalAlign=${'right'}
+    >
+      <span ?hidden="${this._hasAvatars}"
+        >${this._accountName(this.account)}</span
+      >
+      <gr-avatar
+        .account="${this.account}"
+        ?hidden=${!this._hasAvatars}
+        .imageSize=${56}
+        aria-label="Account avatar"
+      ></gr-avatar>
+    </gr-dropdown>`;
+  }
+
+  get links(): DropdownLink[] | undefined {
+    return this._getLinks(this._switchAccountUrl, this._path);
+  }
+
+  get topContent(): DropdownContent[] | undefined {
+    return this._getTopContent(this.account);
+  }
+
+  _getLinks(switchAccountUrl?: string, path?: string) {
     // Polymer 2: check for undefined
     if (switchAccountUrl === undefined || path === undefined) {
       return undefined;
     }
 
-    const links = [];
-    links.push({name: 'Settings', url: '/settings/'});
+    const links: DropdownLink[] = [];
+    links.push({name: 'Settings', id: 'settings', url: '/settings/'});
     links.push({name: 'Keyboard Shortcuts', id: 'shortcuts'});
     if (switchAccountUrl) {
       const replacements = {path};
       const url = this._interpolateUrl(switchAccountUrl, replacements);
       links.push({name: 'Switch account', url, external: true});
     }
-    links.push({name: 'Sign out', url: '/logout'});
+    links.push({name: 'Sign out', id: 'signout', url: '/logout'});
     return links;
   }
 
@@ -108,7 +144,7 @@
     return [
       {text: this._accountName(account), bold: true},
       {text: account?.email ? account.email : ''},
-    ];
+    ] as DropdownContent[];
   }
 
   _handleShortcutsTap() {
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
deleted file mode 100644
index 0fa2f1e..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_html.ts
+++ /dev/null
@@ -1,52 +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">
-    gr-dropdown {
-      padding: 0 var(--spacing-m);
-      --gr-button: {
-        color: var(--header-text-color);
-      }
-      --gr-dropdown-item: {
-        color: var(--primary-text-color);
-      }
-    }
-    gr-avatar {
-      height: 2em;
-      width: 2em;
-      vertical-align: middle;
-    }
-  </style>
-  <gr-dropdown
-    link=""
-    items="[[links]]"
-    top-content="[[topContent]]"
-    on-tap-item-shortcuts="_handleShortcutsTap"
-    horizontal-align="right"
-  >
-    <span hidden$="[[_hasAvatars]]" hidden="">[[_accountName(account)]]</span>
-    <gr-avatar
-      account="[[account]]"
-      hidden$="[[!_hasAvatars]]"
-      hidden=""
-      image-size="56"
-      aria-label="Account avatar"
-    ></gr-avatar>
-  </gr-dropdown>
-`;
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
deleted file mode 100644
index d2d27b7..0000000
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.js
+++ /dev/null
@@ -1,107 +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-account-dropdown.js';
-
-const basicFixture = fixtureFromElement('gr-account-dropdown');
-
-suite('gr-account-dropdown tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('account information', () => {
-    element.account = {name: 'John Doe', email: 'john@doe.com'};
-    assert.deepEqual(element.topContent,
-        [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
-  });
-
-  test('test for account without a name', () => {
-    element.account = {id: '0001'};
-    assert.deepEqual(element.topContent,
-        [{text: 'Anonymous', bold: true}, {text: ''}]);
-  });
-
-  test('test for account without a name but using config', () => {
-    element.config = {
-      user: {
-        anonymous_coward_name: 'WikiGerrit',
-      },
-    };
-    element.account = {id: '0001'};
-    assert.deepEqual(element.topContent,
-        [{text: 'WikiGerrit', bold: true}, {text: ''}]);
-  });
-
-  test('test for account name as an email', () => {
-    element.config = {
-      user: {
-        anonymous_coward_name: 'WikiGerrit',
-      },
-    };
-    element.account = {email: 'john@doe.com'};
-    assert.deepEqual(element.topContent,
-        [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
-  });
-
-  test('switch account', () => {
-    // Missing params.
-    assert.isUndefined(element._getLinks());
-    assert.isUndefined(element._getLinks(null));
-
-    // No switch account link.
-    assert.equal(element._getLinks(null, '').length, 3);
-
-    // Unparameterized switch account link.
-    let links = element._getLinks('/switch-account', '');
-    assert.equal(links.length, 4);
-    assert.deepEqual(links[2], {
-      name: 'Switch account',
-      url: '/switch-account',
-      external: true,
-    });
-
-    // Parameterized switch account link.
-    links = element._getLinks('/switch-account${path}', '/c/123');
-    assert.equal(links.length, 4);
-    assert.deepEqual(links[2], {
-      name: 'Switch account',
-      url: '/switch-account/c/123',
-      external: true,
-    });
-  });
-
-  test('_interpolateUrl', () => {
-    const replacements = {
-      foo: 'bar',
-      test: 'TEST',
-    };
-    const interpolate = function(url) {
-      return element._interpolateUrl(url, replacements);
-    };
-
-    assert.equal(interpolate('test'), 'test');
-    assert.equal(interpolate('${test}'), 'TEST');
-    assert.equal(
-        interpolate('${}, ${test}, ${TEST}, ${foo}'),
-        '${}, TEST, , bar');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
new file mode 100644
index 0000000..88dccad
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.ts
@@ -0,0 +1,119 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-account-dropdown';
+import {GrAccountDropdown} from './gr-account-dropdown';
+import {AccountInfo} from '../../../types/common';
+import {createServerInfo} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-account-dropdown');
+
+suite('gr-account-dropdown tests', () => {
+  let element: GrAccountDropdown;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('account information', () => {
+    element.account = {name: 'John Doe', email: 'john@doe.com'} as AccountInfo;
+    assert.deepEqual(element.topContent, [
+      {text: 'John Doe', bold: true},
+      {text: 'john@doe.com'},
+    ]);
+  });
+
+  test('test for account without a name', () => {
+    element.account = {id: '0001'} as AccountInfo;
+    assert.deepEqual(element.topContent, [
+      {text: 'Anonymous', bold: true},
+      {text: ''},
+    ]);
+  });
+
+  test('test for account without a name but using config', () => {
+    element.config = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {id: '0001'} as AccountInfo;
+    assert.deepEqual(element.topContent, [
+      {text: 'WikiGerrit', bold: true},
+      {text: ''},
+    ]);
+  });
+
+  test('test for account name as an email', () => {
+    element.config = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'WikiGerrit',
+      },
+    };
+    element.account = {email: 'john@doe.com'} as AccountInfo;
+    assert.deepEqual(element.topContent, [
+      {text: 'john@doe.com', bold: true},
+      {text: 'john@doe.com'},
+    ]);
+  });
+
+  test('switch account', () => {
+    // Missing params.
+    assert.isUndefined(element._getLinks());
+    assert.isUndefined(element._getLinks(undefined));
+
+    // No switch account link.
+    assert.equal(element._getLinks('', '')!.length, 3);
+
+    // Unparameterized switch account link.
+    let links = element._getLinks('/switch-account', '')!;
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
+      name: 'Switch account',
+      url: '/switch-account',
+      external: true,
+    });
+
+    // Parameterized switch account link.
+    links = element._getLinks('/switch-account${path}', '/c/123')!;
+    assert.equal(links.length, 4);
+    assert.deepEqual(links[2], {
+      name: 'Switch account',
+      url: '/switch-account/c/123',
+      external: true,
+    });
+  });
+
+  test('_interpolateUrl', () => {
+    const replacements = {
+      foo: 'bar',
+      test: 'TEST',
+    };
+    const interpolate = (url: string) =>
+      element._interpolateUrl(url, replacements);
+
+    assert.equal(interpolate('test'), 'test');
+    assert.equal(interpolate('${test}'), 'TEST');
+    assert.equal(
+      interpolate('${}, ${test}, ${TEST}, ${foo}'),
+      '${}, TEST, , bar'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
index fa57a23..c51988e 100644
--- a/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
+++ b/polygerrit-ui/app/elements/core/gr-error-dialog/gr-error-dialog_test.ts
@@ -17,7 +17,7 @@
 
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
-import {queryAndAssert} from '../../../test/test-utils';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
 import {GrDialog} from '../../shared/gr-dialog/gr-dialog';
 import {GrErrorDialog} from './gr-error-dialog';
 
@@ -26,14 +26,17 @@
 suite('gr-error-dialog tests', () => {
   let element: GrErrorDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await flush();
   });
 
-  test('dismiss tap fires event', done => {
-    element.addEventListener('dismiss', () => done());
+  test('dismiss tap fires event', async () => {
+    const dismissCalled = mockPromise();
+    element.addEventListener('dismiss', () => dismissCalled.resolve());
     MockInteractions.tap(
-      (queryAndAssert(element, '#dialog') as GrDialog).$.confirm
+      (queryAndAssert(element, '#dialog') as GrDialog).confirmButton!
     );
+    await dismissCalled;
   });
 });
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 3a6a9f6..3b09d8c 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
@@ -41,7 +41,7 @@
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {fireIronAnnounce} from '../../../utils/event-util';
 
-const HIDE_ALERT_TIMEOUT_MS = 5000;
+const HIDE_ALERT_TIMEOUT_MS = 10 * 1000;
 const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
 const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
 const SIGN_IN_WIDTH_PX = 690;
@@ -79,6 +79,37 @@
   };
 }
 
+export function constructServerErrorMsg({
+  errorText,
+  status,
+  statusText,
+  url,
+  trace,
+  tip,
+}: ErrorMsg) {
+  let err = '';
+  if (tip) {
+    err += `${tip}\n\n`;
+  }
+  err += `Error ${status}`;
+  if (statusText) {
+    err += ` (${statusText})`;
+  }
+  if (errorText || url) {
+    err += ': ';
+  }
+  if (errorText) {
+    err += errorText;
+  }
+  if (url) {
+    err += `\nEndpoint: ${url}`;
+  }
+  if (trace) {
+    err += `\nTrace Id: ${trace}`;
+  }
+  return err;
+}
+
 @customElement('gr-error-manager')
 export class GrErrorManager extends PolymerElement {
   static get template() {
@@ -122,8 +153,7 @@
 
   private checkLoggedInTask?: DelayedTask;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     document.addEventListener(EventType.SERVER_ERROR, this.handleServerError);
     document.addEventListener(EventType.NETWORK_ERROR, this.handleNetworkError);
@@ -140,11 +170,12 @@
       }
     );
 
-    ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this._clearHideAlertHandle();
     document.removeEventListener(
       EventType.SERVER_ERROR,
@@ -224,9 +255,11 @@
             url,
             trace,
           });
+        } else if (response.status === 429) {
+          this._showQuotaExceeded({status, statusText});
         } else {
           this._showErrorDialog(
-            this._constructServerErrorMsg({
+            constructServerErrorMsg({
               status,
               statusText,
               errorText,
@@ -236,7 +269,7 @@
           );
         }
       }
-      console.info(`server error: ${errorText}`);
+      this.reporting.error(new Error(`Server error: ${errorText}`));
     });
   };
 
@@ -252,7 +285,7 @@
         ? 'You might have not enough privileges.'
         : 'You might have not enough privileges. Sign in and try again.';
       this._showErrorDialog(
-        this._constructServerErrorMsg({
+        constructServerErrorMsg({
           status,
           statusText,
           errorText,
@@ -267,35 +300,17 @@
     });
   }
 
-  _constructServerErrorMsg({
-    errorText,
-    status,
-    statusText,
-    url,
-    trace,
-    tip,
-  }: ErrorMsg) {
-    let err = '';
-    if (tip) {
-      err += `${tip}\n\n`;
-    }
-    err += `Error ${status}`;
-    if (statusText) {
-      err += ` (${statusText})`;
-    }
-    if (errorText || url) {
-      err += ': ';
-    }
-    if (errorText) {
-      err += errorText;
-    }
-    if (url) {
-      err += `\nEndpoint: ${url}`;
-    }
-    if (trace) {
-      err += `\nTrace Id: ${trace}`;
-    }
-    return err;
+  _showQuotaExceeded({status, statusText}: ErrorMsg) {
+    const tip = 'Try again later';
+    const errorText = 'Too many requests from this client';
+    this._showErrorDialog(
+      constructServerErrorMsg({
+        status,
+        statusText,
+        errorText,
+        tip,
+      })
+    );
   }
 
   private readonly handleShowAlert = (e: CustomEvent<ShowAlertEventDetail>) => {
@@ -311,7 +326,7 @@
 
   private readonly handleNetworkError = (e: NetworkErrorEvent) => {
     this._showAlert('Server unavailable');
-    console.error(e.detail.error.message);
+    this.reporting.error(new Error(`network error: ${e.detail.error.message}`));
   };
 
   // TODO(dhruvsr): allow less priority alerts to override high priority alerts
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
deleted file mode 100644
index d1283a9..0000000
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.js
+++ /dev/null
@@ -1,595 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-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');
-
-_testOnly_initGerritPluginApi();
-
-suite('gr-error-manager tests', () => {
-  let element;
-
-  suite('when authed', () => {
-    let toastSpy;
-    let openOverlaySpy;
-    let fetchStub;
-    let getLoggedInStub;
-
-    setup(() => {
-      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');
-      openOverlaySpy = sinon.spy(element.$.noInteractionOverlay, 'open');
-    });
-
-    teardown(() => {
-      toastSpy.getCalls().forEach(call => {
-        call.returnValue.remove();
-      });
-    });
-
-    test('does not show auth error on 403 by default', done => {
-      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
-      const responseText = Promise.resolve('server says no.');
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isFalse(showAuthErrorStub.calledOnce);
-        done();
-      });
-    });
-
-    test('show auth required for 403 with auth error and not authed before',
-        done => {
-          const showAuthErrorStub = sinon.stub(
-              element, '_showAuthErrorAlert'
-          );
-          const responseText = Promise.resolve('Authentication required\n');
-          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(showAuthErrorStub.calledOnce);
-            done();
-          });
-        });
-
-    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');
-      getLoggedInStub.returns(Promise.resolve(true));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail:
-          {response: {status: 403, text() { return responseText; }}},
-            composed: true, bubbles: true,
-          }));
-      await flush();
-      assert.isTrue(getLoggedInStub.calledOnce);
-    });
-
-    test('show logged in error', () => {
-      const spy = sinon.spy(element, '_showAuthErrorAlert');
-      element.dispatchEvent(
-          new CustomEvent('show-auth-required', {
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(spy.calledWithExactly(
-          'Log in is required to perform that action.', 'Log in.'));
-    });
-
-    test('show normal Error', done => {
-      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
-      const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isTrue(showErrorSpy.calledOnce);
-        assert.isTrue(showErrorSpy.lastCall.calledWithExactly(
-            'Error 500: ZOMG'));
-        done();
-      });
-    });
-
-    test('_constructServerErrorMsg', () => {
-      const errorText = 'change conflicts';
-      const status = 409;
-      const statusText = 'Conflict';
-      const url = '/my/test/url';
-
-      assert.equal(element._constructServerErrorMsg({status}),
-          'Error 409');
-      assert.equal(element._constructServerErrorMsg({status, url}),
-          'Error 409: \nEndpoint: /my/test/url');
-      assert.equal(element.
-          _constructServerErrorMsg({status, statusText, url}),
-      'Error 409 (Conflict): \nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url');
-      assert.equal(element._constructServerErrorMsg({
-        status,
-        statusText,
-        errorText,
-        url,
-        trace: 'xxxxx',
-      }), 'Error 409 (Conflict): change conflicts' +
-      '\nEndpoint: /my/test/url\nTrace Id: xxxxx');
-    });
-
-    test('extract trace id from headers if exists', done => {
-      const textSpy = sinon.spy(
-          () => Promise.resolve('500')
-      );
-      const headers = new Headers();
-      headers.set('X-Gerrit-Trace', 'xxxx');
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {
-              response: {
-                headers,
-                status: 500,
-                text: textSpy,
-              },
-            },
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.equal(
-            element.$.errorDialog.text,
-            'Error 500: 500\nTrace Id: xxxx'
-        );
-        done();
-      });
-    });
-
-    test('suppress TOO_MANY_FILES error', done => {
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      const textSpy = sinon.spy(
-          () => Promise.resolve('too many files to find conflicts')
-      );
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isFalse(showAlertStub.called);
-        done();
-      });
-    });
-
-    test('suppress CONFLICTS_OPERATOR_IS_NOT_SUPPORTED error', done => {
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      const textSpy = sinon.spy(
-          () => Promise.resolve(
-              '\'conflicts:\' operator is not supported by server'
-          )
-      );
-      element.dispatchEvent(
-          new CustomEvent('server-error', {
-            detail: {response: {status: 500, text: textSpy}},
-            composed: true, bubbles: true,
-          }));
-
-      assert.isTrue(textSpy.called);
-      flush(() => {
-        assert.isFalse(showAlertStub.called);
-        done();
-      });
-    });
-
-    test('show network error', done => {
-      const consoleErrorStub = sinon.stub(console, 'error');
-      const showAlertStub = sinon.stub(element, '_showAlert');
-      element.dispatchEvent(
-          new CustomEvent('network-error', {
-            detail: {error: new Error('ZOMG')},
-            composed: true, bubbles: true,
-          }));
-      flush(() => {
-        assert.isTrue(showAlertStub.calledOnce);
-        assert.isTrue(showAlertStub.lastCall.calledWithExactly(
-            'Server unavailable'));
-        assert.isTrue(consoleErrorStub.calledOnce);
-        assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
-        done();
-      });
-    });
-
-    test('_canOverride alerts', () => {
-      assert.isFalse(element._canOverride(undefined,
-          __testOnly_ErrorType.AUTH));
-      assert.isFalse(element._canOverride(undefined,
-          __testOnly_ErrorType.NETWORK));
-      assert.isTrue(element._canOverride(undefined,
-          __testOnly_ErrorType.GENERIC));
-      assert.isTrue(element._canOverride(undefined, undefined));
-
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.NETWORK,
-          undefined));
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
-          undefined));
-      assert.isFalse(element._canOverride(__testOnly_ErrorType.NETWORK,
-          __testOnly_ErrorType.AUTH));
-
-      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH,
-          __testOnly_ErrorType.NETWORK));
-    });
-
-    test('show auth refresh toast', async () => {
-      // 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
-      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(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(fetchStub.callCount, 2);
-      await flush();
-      // Sometime overlay opens with delay, waiting while open is complete
-      await openOverlaySpy.lastCall.returnValue;
-      // auth-error fired
-      assert.isTrue(toastSpy.called);
-
-      // toast
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'Credentials expired.');
-      assert.include(
-          toast.root.textContent, 'Refresh credentials');
-
-      // noInteractionOverlay
-      const noInteractionOverlay = element.$.noInteractionOverlay;
-      assert.isOk(noInteractionOverlay);
-      sinon.spy(noInteractionOverlay, 'close');
-      assert.equal(
-          noInteractionOverlay.backdropElement.getAttribute('opened'),
-          '');
-      assert.isFalse(windowOpen.called);
-      MockInteractions.tap(toast.shadowRoot
-          .querySelector('gr-button.action'));
-      assert.isTrue(windowOpen.called);
-
-      // @see Issue 5822: noopener breaks closeAfterLogin
-      assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-          -1);
-
-      const hideToastSpy = sinon.spy(toast, 'hide');
-
-      // now fake authed
-      fetchStub.returns(Promise.resolve({status: 204}));
-      element.handleWindowFocus();
-      element.checkLoggedInTask.flush();
-      await flush();
-      assert.isTrue(refreshStub.called);
-      assert.isTrue(hideToastSpy.called);
-
-      // toast update
-      assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
-      toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'Credentials refreshed');
-
-      // close overlay
-      assert.isTrue(noInteractionOverlay.close.called);
-    });
-
-    test('auth toast should dismiss existing toast', async () => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-      const responseText = Promise.resolve('Authentication required\n');
-
-      // fake an alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'test reload', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'test reload');
-
-      // fake auth
-      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(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(fetchStub.callCount, 2);
-      await flush();
-      // Sometime overlay opens with delay, waiting while open is complete
-      await openOverlaySpy.lastCall.returnValue;
-      // toast
-      toast = toastSpy.lastCall.returnValue;
-      assert.include(
-          toast.root.textContent, 'Credentials expired.');
-      assert.include(
-          toast.root.textContent, 'Refresh credentials');
-    });
-
-    test('regular toast should dismiss regular toast', () => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-
-      // fake an alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'test reload', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-      let toast = toastSpy.lastCall.returnValue;
-      assert.isOk(toast);
-      assert.include(
-          toast.root.textContent, 'test reload');
-
-      // new alert
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: {message: 'second-test', action: 'reload'},
-            composed: true, bubbles: true,
-          }));
-
-      toast = toastSpy.lastCall.returnValue;
-      assert.include(toast.root.textContent, 'second-test');
-    });
-
-    test('regular toast should not dismiss auth toast', done => {
-      // Set status to AUTHED.
-      appContext.authService.authCheck();
-      const responseText = Promise.resolve('Authentication required\n');
-
-      // fake auth
-      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(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(fetchStub.callCount, 2);
-        flush(() => {
-          let toast = toastSpy.lastCall.returnValue;
-          assert.include(
-              toast.root.textContent, 'Credentials expired.');
-          assert.include(
-              toast.root.textContent, 'Refresh credentials');
-
-          // fake an alert
-          element.dispatchEvent(
-              new CustomEvent('show-alert', {
-                detail: {
-                  message: 'test-alert', action: 'reload',
-                },
-                composed: true, bubbles: true,
-              }));
-          flush(() => {
-            toast = toastSpy.lastCall.returnValue;
-            assert.isOk(toast);
-            assert.include(
-                toast.root.textContent, 'Credentials expired.');
-            done();
-          });
-        });
-      });
-    });
-
-    test('show alert', () => {
-      const alertObj = {message: 'foo'};
-      sinon.stub(element, '_showAlert');
-      element.dispatchEvent(
-          new CustomEvent('show-alert', {
-            detail: alertObj,
-            composed: true, bubbles: true,
-          }));
-      assert.isTrue(element._showAlert.calledOnce);
-      assert.equal(element._showAlert.lastCall.args[0], 'foo');
-      assert.isNotOk(element._showAlert.lastCall.args[1]);
-      assert.isNotOk(element._showAlert.lastCall.args[2]);
-    });
-
-    test('checks stale credentials on visibility change', () => {
-      const refreshStub = sinon.stub(element,
-          '_checkSignedIn');
-      sinon.stub(Date, 'now').returns(999999);
-      element._lastCredentialCheck = 0;
-      element.handleVisibilityChange();
-
-      // Since there is no known account, it should not test credentials.
-      assert.isFalse(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 0);
-
-      element.knownAccountId = 123;
-      element.handleVisibilityChange();
-
-      // Should test credentials, since there is a known account.
-      assert.isTrue(refreshStub.called);
-      assert.equal(element._lastCredentialCheck, 999999);
-    });
-
-    test('refreshes with same credentials', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      stubRestApi('getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
-      const handleRefreshStub = sinon.stub(element,
-          'handleCredentialRefreshed');
-      const reloadStub = sinon.stub(element, '_reloadPage');
-
-      element.knownAccountId = 1234;
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isTrue(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-
-    test('_showAlert hides existing alerts', () => {
-      element._alertElement = element._createToastAlert();
-      const hideStub = sinon.stub(element, 'hideAlert');
-      element._showAlert();
-      assert.isTrue(hideStub.calledOnce);
-    });
-
-    test('show-error', () => {
-      const openStub = sinon.stub(element.$.errorOverlay, 'open');
-      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
-      const reportStub = sinon.stub(
-          element.reporting,
-          'reportErrorDialog'
-      );
-
-      const message = 'test message';
-      element.dispatchEvent(
-          new CustomEvent('show-error', {
-            detail: {message},
-            composed: true, bubbles: true,
-          }));
-      flush();
-
-      assert.isTrue(openStub.called);
-      assert.isTrue(reportStub.called);
-      assert.equal(element.$.errorDialog.text, message);
-
-      element.$.errorDialog.dispatchEvent(
-          new CustomEvent('dismiss', {
-            composed: true, bubbles: true,
-          }));
-      flush();
-
-      assert.isTrue(closeStub.called);
-    });
-
-    test('reloads when refreshed credentials differ', done => {
-      const accountPromise = Promise.resolve({_account_id: 1234});
-      stubRestApi('getAccount')
-          .returns(accountPromise);
-      const requestCheckStub = sinon.stub(
-          element,
-          '_requestCheckLoggedIn');
-      const handleRefreshStub = sinon.stub(element,
-          'handleCredentialRefreshed');
-      const reloadStub = sinon.stub(element, '_reloadPage');
-
-      element.knownAccountId = 4321; // Different from 1234
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isFalse(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isTrue(reloadStub.called);
-        done();
-      });
-    });
-  });
-
-  suite('when not authed', () => {
-    let toastSpy;
-    setup(() => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      toastSpy = sinon.spy(element, '_createToastAlert');
-    });
-
-    teardown(() => {
-      toastSpy.getCalls().forEach(call => {
-        call.returnValue.remove();
-      });
-    });
-
-    test('refresh loop continues on credential fail', done => {
-      const requestCheckStub = sinon.stub(
-          element,
-          '_requestCheckLoggedIn');
-      const handleRefreshStub = sinon.stub(element,
-          'handleCredentialRefreshed');
-      const reloadStub = sinon.stub(element, '_reloadPage');
-
-      element._refreshingCredentials = true;
-      element._checkSignedIn();
-
-      flush(() => {
-        assert.isTrue(requestCheckStub.called);
-        assert.isFalse(handleRefreshStub.called);
-        assert.isFalse(reloadStub.called);
-        done();
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
new file mode 100644
index 0000000..80ebf2d
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.ts
@@ -0,0 +1,677 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-error-manager';
+import {
+  constructServerErrorMsg,
+  GrErrorManager,
+  __testOnly_ErrorType,
+} from './gr-error-manager';
+import {stubAuth, stubReporting, stubRestApi} from '../../../test/test-utils';
+import {appContext} from '../../../services/app-context';
+import {
+  createAccountDetailWithId,
+  createPreferences,
+} from '../../../test/test-data-generators';
+import {tap} from '@polymer/iron-test-helpers/mock-interactions';
+import {AccountId} from '../../../types/common';
+import {waitUntil} from '../../../test/test-utils';
+
+const basicFixture = fixtureFromElement('gr-error-manager');
+
+suite('gr-error-manager tests', () => {
+  let element: GrErrorManager;
+
+  suite('when authed', () => {
+    let toastSpy: sinon.SinonSpy;
+    let fetchStub: sinon.SinonStub;
+    let getLoggedInStub: sinon.SinonStub;
+
+    setup(() => {
+      fetchStub = stubAuth('fetch').returns(
+        Promise.resolve({...new Response(), ok: true, status: 204})
+      );
+      getLoggedInStub = stubRestApi('getLoggedIn').callsFake(() =>
+        appContext.authService.authCheck()
+      );
+      stubRestApi('getPreferences').returns(
+        Promise.resolve(createPreferences())
+      );
+      element = basicFixture.instantiate();
+      appContext.authService.clearCache();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('does not show auth error on 403 by default', async () => {
+      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.isFalse(showAuthErrorStub.calledOnce);
+    });
+
+    test('show auth required for 403 with auth error and not authed before', async () => {
+      const showAuthErrorStub = sinon.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('Authentication required\n');
+      getLoggedInStub.returns(Promise.resolve(true));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.isTrue(showAuthErrorStub.calledOnce);
+    });
+
+    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');
+      getLoggedInStub.returns(Promise.resolve(true));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.isTrue(getLoggedInStub.calledOnce);
+    });
+
+    test('show logged in error', () => {
+      const spy = sinon.spy(element, '_showAuthErrorAlert');
+      element.dispatchEvent(
+        new CustomEvent('show-auth-required', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(
+        spy.calledWithExactly(
+          'Log in is required to perform that action.',
+          'Log in.'
+        )
+      );
+    });
+
+    test('show normal Error', async () => {
+      const showErrorSpy = sinon.spy(element, '_showErrorDialog');
+      const textSpy = sinon.spy(() => Promise.resolve('ZOMG'));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {response: {status: 500, text: textSpy}},
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      assert.isTrue(textSpy.called);
+      await flush();
+      assert.isTrue(showErrorSpy.calledOnce);
+      assert.isTrue(showErrorSpy.lastCall.calledWithExactly('Error 500: ZOMG'));
+    });
+
+    test('constructServerErrorMsg', () => {
+      const errorText = 'change conflicts';
+      const status = 409;
+      const statusText = 'Conflict';
+      const url = '/my/test/url';
+
+      assert.equal(constructServerErrorMsg({status}), 'Error 409');
+      assert.equal(
+        constructServerErrorMsg({status, url}),
+        'Error 409: \nEndpoint: /my/test/url'
+      );
+      assert.equal(
+        constructServerErrorMsg({status, statusText, url}),
+        'Error 409 (Conflict): \nEndpoint: /my/test/url'
+      );
+      assert.equal(
+        constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+        }),
+        'Error 409 (Conflict): change conflicts' + '\nEndpoint: /my/test/url'
+      );
+      assert.equal(
+        constructServerErrorMsg({
+          status,
+          statusText,
+          errorText,
+          url,
+          trace: 'xxxxx',
+        }),
+        'Error 409 (Conflict): change conflicts' +
+          '\nEndpoint: /my/test/url\nTrace Id: xxxxx'
+      );
+    });
+
+    test('extract trace id from headers if exists', async () => {
+      const textSpy = sinon.spy(() => Promise.resolve('500'));
+      const headers = new Headers();
+      headers.set('X-Gerrit-Trace', 'xxxx');
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              headers,
+              status: 500,
+              text: textSpy,
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.equal(
+        element.$.errorDialog.text,
+        'Error 500: 500\nTrace Id: xxxx'
+      );
+    });
+
+    test('suppress TOO_MANY_FILES error', async () => {
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      const textSpy = sinon.spy(() =>
+        Promise.resolve('too many files to find conflicts')
+      );
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {response: {status: 500, text: textSpy}},
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      assert.isTrue(textSpy.called);
+      await flush();
+      assert.isFalse(showAlertStub.called);
+    });
+
+    test('suppress CONFLICTS_OPERATOR_IS_NOT_SUPPORTED error', async () => {
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      const textSpy = sinon.spy(() =>
+        Promise.resolve("'conflicts:' operator is not supported by server")
+      );
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {response: {status: 500, text: textSpy}},
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      assert.isTrue(textSpy.called);
+      await flush();
+      assert.isFalse(showAlertStub.called);
+    });
+
+    test('show network error', async () => {
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      element.dispatchEvent(
+        new CustomEvent('network-error', {
+          detail: {error: new Error('ZOMG')},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      assert.isTrue(showAlertStub.calledOnce);
+      assert.isTrue(
+        showAlertStub.lastCall.calledWithExactly('Server unavailable')
+      );
+    });
+
+    test('_canOverride alerts', () => {
+      assert.isFalse(
+        element._canOverride(undefined, __testOnly_ErrorType.AUTH)
+      );
+      assert.isFalse(
+        element._canOverride(undefined, __testOnly_ErrorType.NETWORK)
+      );
+      assert.isTrue(
+        element._canOverride(undefined, __testOnly_ErrorType.GENERIC)
+      );
+      assert.isTrue(element._canOverride(undefined, undefined));
+
+      assert.isTrue(
+        element._canOverride(__testOnly_ErrorType.NETWORK, undefined)
+      );
+      assert.isTrue(element._canOverride(__testOnly_ErrorType.AUTH, undefined));
+      assert.isFalse(
+        element._canOverride(
+          __testOnly_ErrorType.NETWORK,
+          __testOnly_ErrorType.AUTH
+        )
+      );
+
+      assert.isTrue(
+        element._canOverride(
+          __testOnly_ErrorType.AUTH,
+          __testOnly_ErrorType.NETWORK
+        )
+      );
+    });
+
+    test('show auth refresh toast', async () => {
+      const clock = sinon.useFakeTimers();
+
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const refreshStub = stubRestApi('getAccount').callsFake(() =>
+        Promise.resolve(createAccountDetailWithId())
+      );
+      const windowOpen = sinon.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      // fake failed auth
+      fetchStub.returns(Promise.resolve({...new Response(), status: 403}));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      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(fetchStub.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      clock.tick(1000);
+      await flush();
+      // auth-error fired
+      assert.isTrue(toastSpy.called);
+
+      // toast
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
+      assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
+
+      // noInteractionOverlay
+      const noInteractionOverlay = element.$.noInteractionOverlay;
+      assert.isOk(noInteractionOverlay);
+      const noInteractionOverlayCloseSpy = sinon.spy(
+        noInteractionOverlay,
+        'close'
+      );
+      assert.equal(
+        noInteractionOverlay.backdropElement.getAttribute('opened'),
+        ''
+      );
+      assert.isFalse(windowOpen.called);
+      tap(toast.shadowRoot.querySelector('gr-button.action'));
+      assert.isTrue(windowOpen.called);
+
+      // @see Issue 5822: noopener breaks closeAfterLogin
+      assert.equal(windowOpen.lastCall.args[2]?.indexOf('noopener=yes'), -1);
+
+      const hideToastSpy = sinon.spy(toast, 'hide');
+
+      // now fake authed
+      fetchStub.returns(Promise.resolve({status: 204}));
+
+      clock.tick(1000);
+      element.knownAccountId = 5 as AccountId;
+      element._checkSignedIn();
+      await flush();
+
+      assert.isTrue(refreshStub.called);
+      assert.isTrue(hideToastSpy.called);
+
+      // toast update
+      assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
+      toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.shadowRoot.textContent, 'Credentials refreshed');
+
+      // close overlay
+      assert.isTrue(noInteractionOverlayCloseSpy.called);
+    });
+
+    test('auth toast should dismiss existing toast', async () => {
+      const clock = sinon.useFakeTimers();
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake an alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'test reload', action: 'reload'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.shadowRoot.textContent, 'test reload');
+
+      // fake auth
+      fetchStub.returns(Promise.resolve({status: 403}));
+      element.dispatchEvent(
+        new CustomEvent('server-error', {
+          detail: {
+            response: {
+              status: 403,
+              text() {
+                return responseText;
+              },
+            },
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      await flush();
+      // here needs two flush as there are two chained
+      // promises on server-error handler and flush only flushes one
+      assert.equal(fetchStub.callCount, 2);
+      await flush();
+      // Sometime overlay opens with delay, waiting while open is complete
+      clock.tick(1000);
+      await flush();
+      // toast
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
+      assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
+    });
+
+    test('regular toast should dismiss regular toast', async () => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+
+      // fake an alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'test reload', action: 'reload'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      let toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.shadowRoot.textContent, 'test reload');
+
+      // new alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {message: 'second-test', action: 'reload'},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+      toast = toastSpy.lastCall.returnValue;
+      assert.include(toast.shadowRoot.textContent, 'second-test');
+    });
+
+    test('regular toast should not dismiss auth toast', async () => {
+      // Set status to AUTHED.
+      appContext.authService.authCheck();
+      const responseText = Promise.resolve('Authentication required\n');
+
+      // fake auth
+      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(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(fetchStub.callCount, 2);
+      await flush();
+      await waitUntil(() => toastSpy.calledOnce);
+      let toast = toastSpy.lastCall.returnValue;
+      assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
+      assert.include(toast.shadowRoot.textContent, 'Refresh credentials');
+
+      // fake an alert
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: {
+            message: 'test-alert',
+            action: 'reload',
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
+
+      await flush();
+      assert.isTrue(toastSpy.calledOnce);
+      toast = toastSpy.lastCall.returnValue;
+      assert.isOk(toast);
+      assert.include(toast.shadowRoot.textContent, 'Credentials expired.');
+    });
+
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
+      const showAlertStub = sinon.stub(element, '_showAlert');
+      element.dispatchEvent(
+        new CustomEvent('show-alert', {
+          detail: alertObj,
+          composed: true,
+          bubbles: true,
+        })
+      );
+      assert.isTrue(showAlertStub.calledOnce);
+      assert.equal(showAlertStub.lastCall.args[0], 'foo');
+      assert.isNotOk(showAlertStub.lastCall.args[1]);
+      assert.isNotOk(showAlertStub.lastCall.args[2]);
+    });
+
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sinon.stub(element, '_checkSignedIn');
+      sinon.stub(Date, 'now').returns(999999);
+      element._lastCredentialCheck = 0;
+
+      document.dispatchEvent(new CustomEvent('visibilitychange'));
+
+      // Since there is no known account, it should not test credentials.
+      assert.isFalse(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 0);
+
+      element.knownAccountId = 123 as AccountId;
+
+      document.dispatchEvent(new CustomEvent('visibilitychange'));
+
+      // Should test credentials, since there is a known account.
+      assert.isTrue(refreshStub.called);
+      assert.equal(element._lastCredentialCheck, 999999);
+    });
+
+    test('refreshes with same credentials', async () => {
+      const accountPromise = Promise.resolve({
+        ...createAccountDetailWithId(1234),
+      });
+      stubRestApi('getAccount').returns(accountPromise);
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(
+        element,
+        'handleCredentialRefreshed'
+      );
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 1234 as AccountId;
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      await flush();
+      assert.isFalse(requestCheckStub.called);
+      assert.isTrue(handleRefreshStub.called);
+      assert.isFalse(reloadStub.called);
+    });
+
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      // const hideStub = sinon.stub(element, 'hideAlert');
+      // element._showAlert('');
+      // assert.isTrue(hideStub.calledOnce);
+    });
+
+    test('show-error', async () => {
+      const openStub = sinon.stub(element.$.errorOverlay, 'open');
+      const closeStub = sinon.stub(element.$.errorOverlay, 'close');
+      const reportStub = stubReporting('reportErrorDialog');
+
+      const message = 'test message';
+      element.dispatchEvent(
+        new CustomEvent('show-error', {
+          detail: {message},
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+
+      assert.isTrue(openStub.called);
+      assert.isTrue(reportStub.called);
+      assert.equal(element.$.errorDialog.text, message);
+
+      element.$.errorDialog.dispatchEvent(
+        new CustomEvent('dismiss', {
+          composed: true,
+          bubbles: true,
+        })
+      );
+      await flush();
+
+      assert.isTrue(closeStub.called);
+    });
+
+    test('reloads when refreshed credentials differ', async () => {
+      const accountPromise = Promise.resolve({
+        ...createAccountDetailWithId(1234),
+      });
+      stubRestApi('getAccount').returns(accountPromise);
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(
+        element,
+        'handleCredentialRefreshed'
+      );
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element.knownAccountId = 4321 as AccountId; // Different from 1234
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      await flush();
+
+      assert.isFalse(requestCheckStub.called);
+      assert.isFalse(handleRefreshStub.called);
+      assert.isTrue(reloadStub.called);
+    });
+  });
+
+  suite('when not authed', () => {
+    let toastSpy: sinon.SinonSpy;
+    setup(() => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      toastSpy = sinon.spy(element, '_createToastAlert');
+    });
+
+    teardown(() => {
+      toastSpy.getCalls().forEach(call => {
+        call.returnValue.remove();
+      });
+    });
+
+    test('refresh loop continues on credential fail', async () => {
+      const requestCheckStub = sinon.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sinon.stub(
+        element,
+        'handleCredentialRefreshed'
+      );
+      const reloadStub = sinon.stub(element, '_reloadPage');
+
+      element._refreshingCredentials = true;
+      element._checkSignedIn();
+
+      await flush();
+      assert.isTrue(requestCheckStub.called);
+      assert.isFalse(handleRefreshStub.called);
+      assert.isFalse(reloadStub.called);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts b/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
deleted file mode 100644
index ccba289..0000000
--- a/polygerrit-ui/app/elements/core/gr-key-binding-display/gr-key-binding-display.css.ts
+++ /dev/null
@@ -1,30 +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 {css} from 'lit-element';
-
-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 091684f..1ae0992 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,11 +14,8 @@
  * 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} from 'lit-element';
-import {cssTemplate} from './gr-key-binding-display.css';
-import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,12 +24,25 @@
 }
 
 @customElement('gr-key-binding-display')
-export class GrKeyBindingDisplay extends GrLitElement {
-  static get styles() {
-    return [sharedStyles, cssTemplate];
+export class GrKeyBindingDisplay extends LitElement {
+  static override get styles() {
+    return [
+      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;
+        }
+      `,
+    ];
   }
 
-  render() {
+  override render() {
     const items = this.binding.map((binding, index) => [
       index > 0 ? html` or ` : html``,
       this._computeModifiers(binding).map(
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
index 0ec9b39..8610999 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.ts
@@ -17,15 +17,16 @@
 import '../../shared/gr-button/gr-button';
 import '../gr-key-binding-display/gr-key-binding-display';
 import '../../../styles/shared-styles';
+import '../../../styles/gr-font-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-keyboard-shortcuts-dialog_html';
 import {
-  KeyboardShortcutMixin,
   ShortcutSection,
-  ShortcutListener,
   SectionView,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement} from '@polymer/decorators';
+import {appContext} from '../../../services/app-context';
+import {ShortcutViewListener} from '../../../services/shortcuts/shortcuts-service';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -39,9 +40,7 @@
 }
 
 @customElement('gr-keyboard-shortcuts-dialog')
-export class GrKeyboardShortcutsDialog extends KeyboardShortcutMixin(
-  PolymerElement
-) {
+export class GrKeyboardShortcutsDialog extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -58,34 +57,28 @@
   @property({type: Array})
   _right?: SectionShortcut[];
 
-  private keyboardShortcutDirectoryListener: ShortcutListener;
+  private readonly shortcutListener: ShortcutViewListener;
+
+  private readonly shortcuts = appContext.shortcutsService;
 
   constructor() {
     super();
-    this.keyboardShortcutDirectoryListener = (
-      d?: Map<ShortcutSection, SectionView>
-    ) => this._onDirectoryUpdated(d);
+    this.shortcutListener = (d?: Map<ShortcutSection, SectionView>) =>
+      this._onDirectoryUpdated(d);
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._ensureAttribute('role', 'dialog');
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
-    this.addKeyboardShortcutDirectoryListener(
-      this.keyboardShortcutDirectoryListener
-    );
+    this.shortcuts.addListener(this.shortcutListener);
   }
 
-  /** @override */
-  disconnectedCallback() {
-    this.removeKeyboardShortcutDirectoryListener(
-      this.keyboardShortcutDirectoryListener
-    );
+  override disconnectedCallback() {
+    this.shortcuts.removeListener(this.shortcutListener);
     super.disconnectedCallback();
   }
 
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 3576dfe..4992daa 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
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 816ed87..9d34929 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
@@ -24,7 +24,7 @@
 import {getBaseUrl, getDocsBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {getAdminLinks, NavLink} from '../../../utils/admin-nav-util';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property} from '@polymer/decorators';
 import {
   AccountDetailInfo,
   RequireProperties,
@@ -35,6 +35,11 @@
 import {AuthType} from '../../../constants/constants';
 import {DropdownLink} from '../../shared/gr-dropdown/gr-dropdown';
 import {appContext} from '../../../services/app-context';
+import {Subject} from 'rxjs';
+import {serverConfig$} from '../../../services/config/config-model';
+import {takeUntil} from 'rxjs/operators';
+import {myTopMenuItems$} from '../../../services/user/user-model';
+import {assertIsDefined} from '../../../utils/common-util';
 
 type MainHeaderLink = RequireProperties<DropdownLink, 'url' | 'name'>;
 
@@ -105,7 +110,7 @@
   }
 
   @property({type: String, notify: true})
-  searchQuery?: string;
+  searchQuery = '';
 
   @property({type: Boolean, reflectToAttribute: true})
   loggedIn?: boolean;
@@ -154,21 +159,38 @@
 
   private readonly jsAPI = appContext.jsApiService;
 
-  /** @override */
-  ready() {
+  private readonly disconnected$ = new Subject();
+
+  override ready() {
     super.ready();
     this._ensureAttribute('role', 'banner');
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
+    // TODO(brohlfs): This just ensures that the userService is instantiated at
+    // all. We need the service to manage the model, but we are not making any
+    // direct calls. Will need to find a better solution to this problem ...
+    assertIsDefined(appContext.userService);
+
     super.connectedCallback();
     this._loadAccount();
-    this._loadConfig();
+
+    myTopMenuItems$.pipe(takeUntil(this.disconnected$)).subscribe(items => {
+      this._userLinks = items.map(this._createHeaderLink);
+    });
+
+    serverConfig$.pipe(takeUntil(this.disconnected$)).subscribe(config => {
+      if (!config) return;
+      this._retrieveFeedbackURL(config);
+      this._retrieveRegisterURL(config);
+      getDocsBaseUrl(config, this.restApiService).then(docBaseUrl => {
+        this._docBaseUrl = docBaseUrl;
+      });
+    });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
+    this.disconnected$.next();
     super.disconnectedCallback();
   }
 
@@ -293,34 +315,6 @@
     });
   }
 
-  _loadConfig() {
-    this.restApiService
-      .getConfig()
-      .then(config => {
-        if (!config) {
-          throw new Error('getConfig returned undefined');
-        }
-        this._retrieveFeedbackURL(config);
-        this._retrieveRegisterURL(config);
-        return getDocsBaseUrl(config, this.restApiService);
-      })
-      .then(docBaseUrl => {
-        this._docBaseUrl = docBaseUrl;
-      });
-  }
-
-  @observe('_account')
-  _accountLoaded(account?: AccountDetailInfo) {
-    if (!account) {
-      return;
-    }
-
-    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;
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 7f3970f..b623e8e 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
@@ -86,9 +86,7 @@
       padding: var(--spacing-m);
     }
     gr-dropdown {
-      --gr-dropdown-item: {
-        color: var(--primary-text-color);
-      }
+      --gr-dropdown-item-color: var(--primary-text-color);
     }
     .settingsButton {
       margin-left: var(--spacing-m);
@@ -210,16 +208,19 @@
         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>
+      <gr-endpoint-decorator class="feedbackButton" name="header-feedback">
+        <template is="dom-if" if="[[_feedbackURL]]">
+          <a
+            href$="[[_feedbackURL]]"
+            title="File a bug"
+            aria-label="File a bug"
+            target="_blank"
+            role="button"
+          >
+            <iron-icon icon="gr-icons:bug"></iron-icon>
+          </a>
+        </template>
+      </gr-endpoint-decorator>
       </div>
       <div class="accountContainer" id="accountContainer">
         <iron-icon
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 784b440..0f58ac0 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
@@ -474,7 +474,7 @@
 
   test('shows feedback icon when URL provided', async () => {
     assert.isEmpty(element._feedbackURL);
-    assert.isNotOk(query(element, '.feedbackButton'));
+    assert.isNotOk(query(element, '.feedbackButton > a'));
 
     const url = 'report_bug_url';
     const config: ServerInfo = {
@@ -488,7 +488,7 @@
     await flush();
 
     assert.equal(element._feedbackURL, url);
-    assert.ok(query(element, '.feedbackButton'));
+    assert.ok(query(element, '.feedbackButton > a'));
   });
 
   test('register URL', () => {
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 fa81103..b8f2630 100644
--- a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.ts
@@ -130,7 +130,6 @@
   name: string;
   query: string;
   suffixForDashboard?: string;
-  attentionSetOnly?: boolean;
   selfOnly?: boolean;
   hideIfEmpty?: boolean;
   assigneeOnly?: boolean;
@@ -165,7 +164,6 @@
   query: 'attention:${user}',
   hideIfEmpty: false,
   suffixForDashboard: 'limit:25',
-  attentionSetOnly: true,
 };
 const ASSIGNED: DashboardSection = {
   // Changes that are assigned to the viewed user.
@@ -208,7 +206,7 @@
   // Open changes the viewed user is CCed on. Changes ignored by the viewing
   // user are filtered out.
   name: 'CCed on',
-  query: 'is:open -is:ignored cc:${user}',
+  query: 'is:open -is:ignored -is:wip cc:${user}',
   suffixForDashboard: 'limit:10',
 };
 export const CLOSED: DashboardSection = {
@@ -259,6 +257,7 @@
   host?: string;
   messageHash?: string;
   queryMap?: Map<string, string> | URLSearchParams;
+  commentId?: UrlEncodedCommentId;
 
   // TODO(TS): querystring isn't set anywhere, try to remove
   querystring?: string;
@@ -310,8 +309,8 @@
   changeNum: NumericChangeId;
   project: RepoName;
   path?: string;
-  patchNum?: PatchSetNum | null;
-  basePatchNum?: BasePatchSetNum | null;
+  patchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
   lineNum?: number | string;
   leftSide?: boolean;
   commentId?: UrlEncodedCommentId;
@@ -359,6 +358,19 @@
   commit?: CommitId;
   options?: GenerateWebLinksOptions;
 }
+export interface GenerateWebLinksResolveConflictsParameters {
+  type: WeblinkType.RESOLVE_CONFLICTS;
+  repo: RepoName;
+  commit?: CommitId;
+  options?: GenerateWebLinksOptions;
+}
+export interface GenerateWebLinksEditParameters {
+  type: WeblinkType.EDIT;
+  repo: RepoName;
+  commit: CommitId;
+  file: string;
+  options?: GenerateWebLinksOptions;
+}
 export interface GenerateWebLinksFileParameters {
   type: WeblinkType.FILE;
   repo: RepoName;
@@ -375,11 +387,14 @@
 
 export type GenerateWebLinksParameters =
   | GenerateWebLinksPatchsetParameters
+  | GenerateWebLinksResolveConflictsParameters
+  | GenerateWebLinksEditParameters
   | GenerateWebLinksFileParameters
   | GenerateWebLinksChangeParameters;
 
 export type NavigateCallback = (target: string, redirect?: boolean) => void;
 export type GenerateUrlCallback = (params: GenerateUrlParameters) => string;
+// TODO: Refactor to return only GeneratedWebLink[]
 export type GenerateWebLinksCallback = (
   params: GenerateWebLinksParameters
 ) => GeneratedWebLink[] | GeneratedWebLink;
@@ -404,6 +419,7 @@
 }
 
 export enum RepoDetailView {
+  GENERAL = 'general',
   ACCESS = 'access',
   BRANCHES = 'branches',
   COMMANDS = 'commands',
@@ -413,8 +429,10 @@
 
 export enum WeblinkType {
   CHANGE = 'change',
+  EDIT = 'edit',
   FILE = 'file',
   PATCHSET = 'patchset',
+  RESOLVE_CONFLICTS = 'resolve-conflicts',
 }
 
 // TODO(dmfilippov) Convert to class, extract consts, give better name and
@@ -679,6 +697,19 @@
     });
   },
 
+  getUrlForCommentsTab(
+    changeNum: NumericChangeId,
+    project: RepoName,
+    commentId: UrlEncodedCommentId
+  ) {
+    return this._getUrlFor({
+      view: GerritView.CHANGE,
+      changeNum,
+      project,
+      commentId,
+    });
+  },
+
   /**
    * @param basePatchNum The string 'PARENT' can be used for none.
    */
@@ -811,6 +842,7 @@
   getUrlForRepo(repoName: RepoName) {
     return this._getUrlFor({
       view: GerritView.REPO,
+      detail: RepoDetailView.GENERAL,
       repoName,
     });
   },
@@ -889,6 +921,24 @@
     return this._getUrlFor({view: GerritView.SETTINGS});
   },
 
+  getEditWebLinks(
+    repo: RepoName,
+    commit: CommitId,
+    file: string,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksEditParameters = {
+      type: WeblinkType.EDIT,
+      repo,
+      commit,
+      file,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
   getFileWebLinks(
     repo: RepoName,
     commit: CommitId,
@@ -931,6 +981,22 @@
     }
   },
 
+  getResolveConflictsWeblinks(
+    repo: RepoName,
+    commit?: CommitId,
+    options?: GenerateWebLinksOptions
+  ): GeneratedWebLink[] {
+    const params: GenerateWebLinksResolveConflictsParameters = {
+      type: WeblinkType.RESOLVE_CONFLICTS,
+      repo,
+      commit,
+    };
+    if (options) {
+      params.options = options;
+    }
+    return ([] as GeneratedWebLink[]).concat(this._generateWeblinks(params));
+  },
+
   getChangeWeblinks(
     repo: RepoName,
     commit: CommitId,
@@ -953,11 +1019,8 @@
     title = '',
     config: UserDashboardConfig = {}
   ): UserDashboard {
-    const attentionEnabled =
-      config.change && !!config.change.enable_attention_set;
     const assigneeEnabled = config.change && !!config.change.enable_assignee;
     sections = sections
-      .filter(section => attentionEnabled || !section.attentionSetOnly)
       .filter(section => assigneeEnabled || !section.assigneeOnly)
       .filter(section => user === 'self' || !section.selfOnly)
       .map(section => {
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 4aefc16..6347847 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.ts
@@ -33,9 +33,11 @@
   GenerateUrlRepoViewParameters,
   GenerateUrlSearchViewParameters,
   GenerateWebLinksChangeParameters,
+  GenerateWebLinksEditParameters,
   GenerateWebLinksFileParameters,
   GenerateWebLinksParameters,
   GenerateWebLinksPatchsetParameters,
+  GenerateWebLinksResolveConflictsParameters,
   GerritNav,
   GroupDetailView,
   isGenerateUrlDiffViewParameters,
@@ -55,6 +57,7 @@
   RepoName,
   ServerInfo,
   UrlEncodedCommentId,
+  ParentPatchSetNum,
 } from '../../../types/common';
 import {
   AppElement,
@@ -129,6 +132,8 @@
   // Matches /admin/repos/<repo>,commands.
   REPO_COMMANDS: /^\/admin\/repos\/(.+),commands$/,
 
+  REPO_GENERAL: /^\/admin\/repos\/(.+),general$/,
+
   // Matches /admin/repos/<repos>,access.
   REPO_ACCESS: /^\/admin\/repos\/(.+),access$/,
 
@@ -172,8 +177,8 @@
 
   CHANGE_ID_QUERY: /^\/id\/(I[0-9a-f]{40})$/,
 
-  // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-  CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+  // Matches /c/<changeNum>/[*][/].
+  CHANGE_LEGACY: /^\/c\/(\d+)\/?(.*)$/,
   CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
 
   // Matches
@@ -191,6 +196,10 @@
   // links generated in the emails.
   COMMENT: /^\/c\/(.+)\/\+\/(\d+)\/comment\/(\w+)\/?$/,
 
+  // Matches /c/<project>/+/<changeNum>/comments/<commentId>/
+  // Navigates to the commentId inside the Comments Tab
+  COMMENTS_TAB: /^\/c\/(.+)\/\+\/(\d+)\/comments(?:\/)?(\w+)?\/?$/,
+
   // Matches
   // /c/<project>/+/<changeNum>/[<basePatchNum|edit>..]<patchNum|edit>/<path>.
   // TODO(kaspern): Migrate completely to project based URLs, with backwards
@@ -201,14 +210,11 @@
   // Matches /c/<project>/+/<changeNum>/[<patchNum|edit>]/<path>,edit[#lineNum]
   DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/(\d+|edit)\/(.+),edit(#\d+)?$/,
 
-  // Matches non-project-relative
-  // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-  DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
-
   // Matches diff routes using @\d+ to specify a file name (whether or not
   // the project name is included).
   // eslint-disable-next-line max-len
-  DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
+  DIFF_LEGACY_LINENUM:
+    /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
 
   SETTINGS: /^\/settings\/?/,
   SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
@@ -256,10 +262,10 @@
 const REPO_TOKEN_PATTERN = /\${(project|repo)}/g;
 
 // Polymer makes `app` intrinsically defined on the window by virtue of the
-// custom element having the id "app", but it is made explicit here.
+// custom element having the id "pg-app", but it is made explicit here.
 // If you move this code to other place, please update comment about
 // gr-router and gr-app in the PolyGerritIndexHtml.soy file if needed
-const app = document.querySelector('#app');
+const app = document.querySelector('gr-app');
 if (!app) {
   console.info('No gr-app found (running tests)');
 }
@@ -277,18 +283,9 @@
 
 type QueryStringItem = [string, string]; // [key, value]
 
-type GenerateUrlLegacyChangeViewParameters = Omit<
-  GenerateUrlChangeViewParameters,
-  'project'
->;
-type GenerateUrlLegacyDiffViewParameters = Omit<
-  GenerateUrlDiffViewParameters,
-  'project'
->;
-
 interface PatchRangeParams {
-  patchNum?: PatchSetNum | null;
-  basePatchNum?: BasePatchSetNum | null;
+  patchNum?: PatchSetNum;
+  basePatchNum?: BasePatchSetNum;
 }
 
 @customElement('gr-router')
@@ -335,11 +332,11 @@
     // explicitly in app, or by delegating to it.
 
     // It is expected that application has a GrAppElement(id=='app-element')
-    // at the document level or inside the shadow root of the GrApp (id='app')
+    // at the document level or inside the shadow root of the GrApp ('gr-app')
     // element.
     return (document.getElementById('app-element') ||
       document
-        .getElementById('app')!
+        .querySelector('gr-app')!
         .shadowRoot!.getElementById('app-element')!) as AppElement;
   }
 
@@ -382,12 +379,16 @@
     params: GenerateWebLinksParameters
   ): GeneratedWebLink[] | GeneratedWebLink {
     switch (params.type) {
+      case WeblinkType.EDIT:
+        return this._getEditWebLinks(params);
       case WeblinkType.FILE:
         return this._getFileWebLinks(params);
       case WeblinkType.CHANGE:
         return this._getChangeWeblinks(params);
       case WeblinkType.PATCHSET:
         return this._getPatchSetWeblink(params);
+      case WeblinkType.RESOLVE_CONFLICTS:
+        return this._getResolveConflictsWeblinks(params);
       default:
         // eslint-disable-next-line @typescript-eslint/no-explicit-any
         assertNever(params, `Unsupported weblink ${(params as any).type}!`);
@@ -408,6 +409,12 @@
     }
   }
 
+  _getResolveConflictsWeblinks(
+    params: GenerateWebLinksResolveConflictsParameters
+  ): GeneratedWebLink[] {
+    return params.options?.weblinks ?? [];
+  }
+
   _firstCodeBrowserWeblink(weblinks: GeneratedWebLink[]) {
     // This is an ordered allowed list of web link types that provide direct
     // links to the commit in the url property.
@@ -457,8 +464,12 @@
     );
   }
 
+  _getEditWebLinks(params: GenerateWebLinksEditParameters): GeneratedWebLink[] {
+    return params.options?.weblinks ?? [];
+  }
+
   _getFileWebLinks(params: GenerateWebLinksFileParameters): GeneratedWebLink[] {
-    return params.options?.weblinks || [];
+    return params.options?.weblinks ?? [];
   }
 
   _generateSearchUrl(params: GenerateUrlSearchViewParameters) {
@@ -527,6 +538,9 @@
     if (params.messageHash) {
       suffix += params.messageHash;
     }
+    if (params.commentId) {
+      suffix = suffix + `/comments/${params.commentId}`;
+    }
     if (params.project) {
       const encodedProject = encodeURL(params.project, true);
       return `/c/${encodedProject}/+/${params.changeNum}${suffix}`;
@@ -615,7 +629,9 @@
 
   _generateRepoUrl(params: GenerateUrlRepoViewParameters) {
     let url = `/admin/repos/${encodeURL(`${params.repoName}`, true)}`;
-    if (params.detail === RepoDetailView.ACCESS) {
+    if (params.detail === RepoDetailView.GENERAL) {
+      url += ',general';
+    } else if (params.detail === RepoDetailView.ACCESS) {
       url += ',access';
     } else if (params.detail === RepoDetailView.BRANCHES) {
       url += ',branches';
@@ -643,66 +659,34 @@
     if (params.patchNum) {
       range = `${params.patchNum}`;
     }
-    if (params.basePatchNum) {
+    if (params.basePatchNum && params.basePatchNum !== ParentPatchSetNum) {
       range = `${params.basePatchNum}..${range}`;
     }
     return range;
   }
 
   /**
-   * Given a set of params without a project, gets the project from the rest
-   * API project lookup and then sets the app params.
-   */
-  _normalizeLegacyRouteParams(
-    params: Readonly<
-      | GenerateUrlLegacyChangeViewParameters
-      | GenerateUrlLegacyDiffViewParameters
-    >
-  ) {
-    if (!params.changeNum) {
-      return Promise.resolve();
-    }
-
-    return this.restApiService
-      .getFromProjectLookup(params.changeNum)
-      .then(project => {
-        // Show a 404 and terminate if the lookup request failed. Attempting
-        // to redirect after failing to get the project loops infinitely.
-        if (!project) {
-          this._show404();
-          return;
-        }
-        const updatedParams:
-          | GenerateUrlChangeViewParameters
-          | GenerateUrlDiffViewParameters = {...params, project};
-        this._normalizePatchRangeParams(updatedParams);
-        this._redirect(this._generateUrl(updatedParams));
-      });
-  }
-
-  /**
    * Normalizes the params object, and determines if the URL needs to be
    * modified to fit the proper schema.
    *
    */
   _normalizePatchRangeParams(params: PatchRangeParams) {
-    if (params.basePatchNum === null || params.basePatchNum === undefined) {
+    if (params.basePatchNum === undefined) {
       return false;
     }
-    const hasPatchNum =
-      params.patchNum !== null && params.patchNum !== undefined;
+    const hasPatchNum = params.patchNum !== undefined;
     let needsRedirect = false;
 
     // Diffing a patch against itself is invalid, so if the base and revision
     // patches are equal clear the base.
     if (params.patchNum && params.basePatchNum === params.patchNum) {
       needsRedirect = true;
-      params.basePatchNum = null;
+      params.basePatchNum = ParentPatchSetNum;
     } else if (!hasPatchNum) {
       // Regexes set basePatchNum instead of patchNum when only one is
       // specified. Redirect is not needed in this case.
       params.patchNum = params.basePatchNum;
-      params.basePatchNum = null;
+      params.basePatchNum = ParentPatchSetNum;
     }
     return needsRedirect;
   }
@@ -967,6 +951,8 @@
       true
     );
 
+    this._mapRoute(RoutePattern.REPO_GENERAL, '_handleRepoGeneralRoute');
+
     this._mapRoute(RoutePattern.REPO_ACCESS, '_handleRepoAccessRoute');
 
     this._mapRoute(RoutePattern.REPO_DASHBOARDS, '_handleRepoDashboardsRoute');
@@ -1062,14 +1048,14 @@
 
     this._mapRoute(RoutePattern.COMMENT, '_handleCommentRoute');
 
+    this._mapRoute(RoutePattern.COMMENTS_TAB, '_handleCommentsRoute');
+
     this._mapRoute(RoutePattern.DIFF, '_handleDiffRoute');
 
     this._mapRoute(RoutePattern.CHANGE, '_handleChangeRoute');
 
     this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
 
-    this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
-
     this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
 
     this._mapRoute(
@@ -1378,6 +1364,16 @@
     this.reporting.setRepoName(repo);
   }
 
+  _handleRepoGeneralRoute(data: PageContextWithQueryMap) {
+    const repo = data.params[0] as RepoName;
+    this._setParams({
+      view: GerritView.REPO,
+      detail: RepoDetailView.GENERAL,
+      repo,
+    });
+    this.reporting.setRepoName(repo);
+  }
+
   _handleRepoAccessRoute(data: PageContextWithQueryMap) {
     const repo = data.params[0] as RepoName;
     this._setParams({
@@ -1496,12 +1492,7 @@
   }
 
   _handleRepoRoute(data: PageContextWithQueryMap) {
-    const repo = data.params[0] as RepoName;
-    this._setParams({
-      view: GerritView.REPO,
-      repo,
-    });
-    this.reporting.setRepoName(repo);
+    this._redirect(data.path + ',general');
   }
 
   _handlePluginListOffsetRoute(data: PageContextWithQueryMap) {
@@ -1594,6 +1585,19 @@
     this._redirectOrNavigate(params);
   }
 
+  _handleCommentsRoute(ctx: PageContextWithQueryMap) {
+    const changeNum = Number(ctx.params[1]) as NumericChangeId;
+    const params: GenerateUrlChangeViewParameters = {
+      project: ctx.params[0] as RepoName,
+      changeNum,
+      commentId: ctx.params[2] as UrlEncodedCommentId,
+      view: GerritView.CHANGE,
+    };
+    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.
@@ -1616,41 +1620,26 @@
   }
 
   _handleChangeLegacyRoute(ctx: PageContextWithQueryMap) {
-    // Parameter order is based on the regex group number matched.
-    const params: GenerateUrlLegacyChangeViewParameters = {
-      changeNum: Number(ctx.params[0]) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[3]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[5]),
-      view: GerritView.CHANGE,
-      querystring: ctx.querystring,
-    };
-
-    this._normalizeLegacyRouteParams(params);
+    const changeNum = Number(ctx.params[0]) as NumericChangeId;
+    if (!changeNum) {
+      this._show404();
+      return;
+    }
+    this.restApiService.getFromProjectLookup(changeNum).then(project => {
+      // Show a 404 and terminate if the lookup request failed. Attempting
+      // to redirect after failing to get the project loops infinitely.
+      if (!project) {
+        this._show404();
+        return;
+      }
+      this._redirect(`/c/${project}/+/${changeNum}/${ctx.params[1]}`);
+    });
   }
 
   _handleLegacyLinenum(ctx: PageContextWithQueryMap) {
     this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
   }
 
-  _handleDiffLegacyRoute(ctx: PageContextWithQueryMap) {
-    // Parameter order is based on the regex group number matched.
-    const params: GenerateUrlLegacyDiffViewParameters = {
-      changeNum: Number(ctx.params[0]) as NumericChangeId,
-      basePatchNum: convertToPatchSetNum(ctx.params[2]) as BasePatchSetNum,
-      patchNum: convertToPatchSetNum(ctx.params[4]),
-      path: ctx.params[5],
-      view: GerritView.DIFF,
-    };
-
-    const address = this._parseLineAddress(ctx.hash);
-    if (address) {
-      params.leftSide = address.leftSide;
-      params.lineNum = address.lineNum;
-    }
-
-    this._normalizeLegacyRouteParams(params);
-  }
-
   _handleDiffEditRoute(ctx: PageContextWithQueryMap) {
     // Parameter order is based on the regex group number matched.
     const project = ctx.params[0] as RepoName;
@@ -1703,7 +1692,7 @@
   _handleNewAgreementsRoute(data: PageContextWithQueryMap) {
     data.params['view'] = GerritView.AGREEMENTS;
     // TODO(TS): create valid object
-    this._setParams((data.params as unknown) as AppElementAgreementParam);
+    this._setParams(data.params as unknown as AppElementAgreementParam);
   }
 
   _handleSettingsLegacyRoute(data: PageContextWithQueryMap) {
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 ad4ebcb..b91bf0c 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, stubRestApi, addListenerForTest} from '../../../test/test-utils.js';
 import {_testOnly_RoutePattern} from './gr-router.js';
+import {GerritView} from '../../../services/router/router-model.js';
+import {ParentPatchSetNum} from '../../../types/common.js';
 
 const basicFixture = fixtureFromElement('gr-router');
 
@@ -186,10 +188,10 @@
       '_handleChangeNumberLegacyRoute',
       '_handleChangeRoute',
       '_handleCommentRoute',
+      '_handleCommentsRoute',
       '_handleDiffRoute',
       '_handleDefaultRoute',
       '_handleChangeLegacyRoute',
-      '_handleDiffLegacyRoute',
       '_handleDocumentationRedirectRoute',
       '_handleDocumentationSearchRoute',
       '_handleDocumentationSearchRedirectRoute',
@@ -201,6 +203,7 @@
       '_handleProjectsOldRoute',
       '_handleRepoAccessRoute',
       '_handleRepoDashboardsRoute',
+      '_handleRepoGeneralRoute',
       '_handleRepoListFilterOffsetRoute',
       '_handleRepoListFilterRoute',
       '_handleRepoListOffsetRoute',
@@ -305,12 +308,12 @@
 
     test('change', () => {
       const params = {
-        view: GerritNav.View.CHANGE,
+        view: GerritView.CHANGE,
         changeNum: '1234',
         project: 'test',
       };
       const paramsWithQuery = {
-        view: GerritNav.View.CHANGE,
+        view: GerritView.CHANGE,
         changeNum: '1234',
         project: 'test',
         querystring: 'revert&foo=bar',
@@ -338,7 +341,7 @@
 
     test('change with repo name encoding', () => {
       const params = {
-        view: GerritNav.View.CHANGE,
+        view: GerritView.CHANGE,
         changeNum: '1234',
         project: 'x+/y+/z+/w',
       };
@@ -348,7 +351,7 @@
 
     test('diff', () => {
       const params = {
-        view: GerritNav.View.DIFF,
+        view: GerritView.DIFF,
         changeNum: '42',
         path: 'x+y/path.cpp',
         patchNum: 12,
@@ -382,7 +385,7 @@
 
     test('diff with repo name encoding', () => {
       const params = {
-        view: GerritNav.View.DIFF,
+        view: GerritView.DIFF,
         changeNum: '42',
         path: 'x+y/path.cpp',
         patchNum: 12,
@@ -532,72 +535,12 @@
   });
 
   suite('param normalization', () => {
-    let projectLookupStub;
-    let generateUrlStub;
-
-    setup(() => {
-      projectLookupStub = stubRestApi('getFromProjectLookup');
-      generateUrlStub = sinon.stub(element, '_generateUrl');
-    });
-
-    suite('_normalizeLegacyRouteParams', () => {
-      let rangeStub;
-      let redirectStub;
-      let show404Stub;
-
-      setup(() => {
-        rangeStub = sinon.stub(element, '_normalizePatchRangeParams')
-            .returns(Promise.resolve());
-        redirectStub = sinon.stub(element, '_redirect');
-        show404Stub = sinon.stub(element, '_show404');
-      });
-
-      test('w/o changeNum', () => {
-        projectLookupStub.returns(Promise.resolve('foo/bar'));
-        const params = {};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isFalse(generateUrlStub.calledOnce);
-          assert.isFalse(projectLookupStub.called);
-          assert.isFalse(rangeStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isFalse(show404Stub.called);
-        });
-      });
-
-      test('w/ changeNum', () => {
-        projectLookupStub.returns(Promise.resolve('foo/bar'));
-        const params = {changeNum: 1234};
-
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isTrue(generateUrlStub.calledOnce);
-          const updatedParams = generateUrlStub.lastCall.args[0];
-          assert.isTrue(projectLookupStub.called);
-          assert.isTrue(rangeStub.called);
-          assert.equal(updatedParams.project, 'foo/bar');
-          assert.isTrue(redirectStub.calledOnce);
-          assert.isFalse(show404Stub.called);
-        });
-      });
-
-      test('halts on project lookup failure', () => {
-        projectLookupStub.returns(Promise.resolve(undefined));
-        const params = {changeNum: 1234};
-        return element._normalizeLegacyRouteParams(params).then(() => {
-          assert.isFalse(generateUrlStub.calledOnce);
-          assert.isTrue(projectLookupStub.called);
-          assert.isFalse(rangeStub.called);
-          assert.isFalse(redirectStub.called);
-          assert.isTrue(show404Stub.calledOnce);
-        });
-      });
-    });
-
     suite('_normalizePatchRangeParams', () => {
       test('range n..n normalizes to n', () => {
         const params = {basePatchNum: 4, patchNum: 4};
         const needsRedirect = element._normalizePatchRangeParams(params);
         assert.isTrue(needsRedirect);
-        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.basePatchNum, ParentPatchSetNum);
         assert.equal(params.patchNum, 4);
       });
 
@@ -605,7 +548,7 @@
         const params = {basePatchNum: 4};
         const needsRedirect = element._normalizePatchRangeParams(params);
         assert.isFalse(needsRedirect);
-        assert.isNotOk(params.basePatchNum);
+        assert.equal(params.basePatchNum, ParentPatchSetNum);
         assert.equal(params.patchNum, 4);
       });
     });
@@ -1124,9 +1067,18 @@
       });
 
       test('_handleRepoRoute', () => {
+        const data = {path: '/admin/repos/test'};
+        element._handleRepoRoute(data);
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+            redirectStub.lastCall.args[0], '/admin/repos/test,general');
+      });
+
+      test('_handleRepoGeneralRoute', () => {
         const data = {params: {0: 4321}};
-        assertDataToParams(data, '_handleRepoRoute', {
+        assertDataToParams(data, '_handleRepoGeneralRoute', {
           view: GerritNav.View.REPO,
+          detail: GerritNav.RepoDetailView.GENERAL,
           repo: 4321,
         });
       });
@@ -1354,58 +1306,19 @@
         assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
       });
 
-      test('_handleChangeLegacyRoute', () => {
-        const normalizeRouteStub = sinon.stub(element,
-            '_normalizeLegacyRouteParams');
+      test('_handleChangeLegacyRoute', async () => {
+        stubRestApi('getFromProjectLookup').returns(Promise.resolve('project'));
         const ctx = {
           params: [
             1234, // 0 Change number
-            null, // 1 Unused
-            null, // 2 Unused
-            6, // 3 Base patch number
-            null, // 4 Unused
-            9, // 5 Patch number
+            'comment/6789',
           ],
           querystring: '',
         };
         element._handleChangeLegacyRoute(ctx);
-        assert.isTrue(normalizeRouteStub.calledOnce);
-        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-          changeNum: 1234,
-          basePatchNum: 6,
-          patchNum: 9,
-          view: GerritNav.View.CHANGE,
-          querystring: '',
-        });
-      });
-
-      test('_handleDiffLegacyRoute', () => {
-        const normalizeRouteStub = sinon.stub(element,
-            '_normalizeLegacyRouteParams');
-        const ctx = {
-          params: [
-            1234, // 0 Change number
-            null, // 1 Unused
-            3, // 2 Base patch number
-            null, // 3 Unused
-            8, // 4 Patch number
-            'foo/bar', // 5 Diff path
-          ],
-          path: '/c/1234/3..8/foo/bar',
-          hash: 'b123',
-        };
-        element._handleDiffLegacyRoute(ctx);
-        assert.isFalse(redirectStub.called);
-        assert.isTrue(normalizeRouteStub.calledOnce);
-        assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
-          changeNum: 1234,
-          basePatchNum: 3,
-          patchNum: 8,
-          view: GerritNav.View.DIFF,
-          path: 'foo/bar',
-          lineNum: 123,
-          leftSide: true,
-        });
+        await flush();
+        assert.isTrue(redirectStub.calledWithExactly('/c/project/+/1234' +
+            '/comment/6789'));
       });
 
       test('_handleLegacyLinenum w/ @321', () => {
@@ -1464,7 +1377,7 @@
           sinon.stub(element, '_generateUrl').returns('foo');
           const ctx = makeParams(null, '');
           assertDataToParams(ctx, '_handleChangeRoute', {
-            view: GerritNav.View.CHANGE,
+            view: GerritView.CHANGE,
             project: 'foo/bar',
             changeNum: 1234,
             basePatchNum: 4,
@@ -1518,7 +1431,7 @@
           sinon.stub(element, '_generateUrl').returns('foo');
           const ctx = makeParams('foo/bar/baz', 'b44');
           assertDataToParams(ctx, '_handleDiffRoute', {
-            view: GerritNav.View.DIFF,
+            view: GerritView.DIFF,
             project: 'foo/bar',
             changeNum: 1234,
             basePatchNum: 4,
@@ -1544,9 +1457,26 @@
             changeNum: 264833,
             commentId: '00049681_f34fd6a9',
             commentLink: true,
-            view: GerritNav.View.DIFF,
+            view: GerritView.DIFF,
           });
         });
+
+        test('comments route', () => {
+          const url = '/c/gerrit/+/264833/comments/00049681_f34fd6a9/';
+          const groups = url.match(_testOnly_RoutePattern.COMMENTS_TAB);
+          assert.deepEqual(groups.slice(1), [
+            'gerrit', // project
+            '264833', // changeNum
+            '00049681_f34fd6a9', // commentId
+          ]);
+          assertDataToParams({params: groups.slice(1)},
+              '_handleCommentsRoute', {
+                project: 'gerrit',
+                changeNum: 264833,
+                commentId: '00049681_f34fd6a9',
+                view: GerritView.CHANGE,
+              });
+        });
       });
 
       test('_handleDiffEditRoute', () => {
@@ -1623,7 +1553,7 @@
         const appParams = {
           project: 'foo/bar',
           changeNum: 1234,
-          view: GerritNav.View.CHANGE,
+          view: GerritView.CHANGE,
           patchNum: 3,
           edit: true,
         };
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.ts
index 44afaa1..2901b8a 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
@@ -22,6 +22,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
@@ -31,9 +32,9 @@
   GrAutocomplete,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {CustomKeyboardEvent} from '../../../types/events';
 import {MergeabilityComputationBehavior} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
 
 // Possible static search options for auto complete, without negations.
 const SEARCH_OPERATORS: ReadonlyArray<string> = [
@@ -55,7 +56,6 @@
   'commentby:',
   'commit:',
   'committer:',
-  'conflicts:',
   'deleted:',
   'delta:',
   'dir:',
@@ -66,16 +66,19 @@
   'footer:',
   'from:',
   'has:',
+  'has:attention',
   'has:draft',
   'has:edit',
   'has:star',
-  'has:stars',
   'has:unresolved',
   'hashtag:',
+  'inhashtag:',
   'intopic:',
   'is:',
   'is:abandoned',
   'is:assigned',
+  'is:attention',
+  'is:cherrypick',
   'is:closed',
   'is:ignored',
   'is:merge',
@@ -102,11 +105,13 @@
   'project:',
   'projects:',
   'query:',
+  'repo:',
   'ref:',
   'reviewedby:',
   'reviewer:',
   'reviewer:self',
   'reviewerin:',
+  'rule:',
   'size:',
   'star:',
   'status:',
@@ -144,8 +149,11 @@
   };
 }
 
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = KeyboardShortcutMixin(PolymerElement);
+
 @customElement('gr-search-bar')
-export class GrSearchBar extends KeyboardShortcutMixin(PolymerElement) {
+export class GrSearchBar extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -162,9 +170,6 @@
   value = '';
 
   @property({type: Object})
-  keyEventTarget: unknown = document.body;
-
-  @property({type: Object})
   query: AutocompleteQuery;
 
   @property({type: Object})
@@ -177,7 +182,7 @@
   accountSuggestions: SuggestionProvider = () => Promise.resolve([]);
 
   @property({type: String})
-  _inputVal?: string;
+  _inputVal = '';
 
   @property({type: Number})
   _threshold = 1;
@@ -195,7 +200,7 @@
     this.query = (input: string) => this._getSearchSuggestions(input);
   }
 
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.restApiService.getConfig().then((serverConfig?: ServerInfo) => {
       const mergeability =
@@ -236,10 +241,8 @@
     }
   }
 
-  keyboardShortcuts() {
-    return {
-      [Shortcut.SEARCH]: '_handleSearch',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [listen(Shortcut.SEARCH, _ => this._handleSearch())];
   }
 
   _valueChanged(value: string) {
@@ -308,6 +311,7 @@
 
       case 'parentproject':
       case 'project':
+      case 'repo':
         // Fetch projects.
         return this.projectSuggestions(predicate, expression);
 
@@ -386,16 +390,7 @@
     });
   }
 
-  _handleSearch(e: CustomKeyboardEvent) {
-    const keyboardEvent = this.getKeyboardEvent(e);
-    if (
-      this.shouldSuppressKeyboardShortcut(e) ||
-      (this.modifierPressed(e) && !keyboardEvent.shiftKey)
-    ) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleSearch() {
     this.$.searchInput.focus();
     this.$.searchInput.selectAll();
   }
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
deleted file mode 100644
index ae7e10c..0000000
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.js
+++ /dev/null
@@ -1,249 +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-search-bar.js';
-import '../../../scripts/util.js';
-import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util.js';
-
-const basicFixture = fixtureFromElement('gr-search-bar');
-
-suite('gr-search-bar tests', () => {
-  let element;
-
-  suiteSetup(() => {
-    const kb = TestKeyboardShortcutBinder.push();
-    kb.bindShortcut(Shortcut.SEARCH, '/');
-  });
-
-  suiteTeardown(() => {
-    TestKeyboardShortcutBinder.pop();
-  });
-
-  setup(done => {
-    element = basicFixture.instantiate();
-    flush(done);
-  });
-
-  test('value is propagated to _inputVal', () => {
-    element.value = 'foo';
-    assert.equal(element._inputVal, 'foo');
-  });
-
-  const getActiveElement = () => (document.activeElement.shadowRoot ?
-    document.activeElement.shadowRoot.activeElement :
-    document.activeElement);
-
-  test('enter in search input fires event', done => {
-    element.addEventListener('handle-search', () => {
-      assert.notEqual(getActiveElement(), element.$.searchInput);
-      assert.notEqual(getActiveElement(), element.$.searchButton);
-      done();
-    });
-    element.value = 'test';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-  });
-
-  test('input blurred after commit', () => {
-    const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
-    element.$.searchInput.text = 'fate/stay';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(blurSpy.called);
-  });
-
-  test('empty search query does not trigger nav', () => {
-    const searchSpy = sinon.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = '';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isFalse(searchSpy.called);
-  });
-
-  test('Predefined query op with no predication doesnt trigger nav', () => {
-    const searchSpy = sinon.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'added:';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isFalse(searchSpy.called);
-  });
-
-  test('predefined predicate query triggers nav', () => {
-    const searchSpy = sinon.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'age:1week';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(searchSpy.called);
-  });
-
-  test('undefined predicate query triggers nav', () => {
-    const searchSpy = sinon.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'random:1week';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(searchSpy.called);
-  });
-
-  test('empty undefined predicate query triggers nav', () => {
-    const searchSpy = sinon.spy();
-    element.addEventListener('handle-search', searchSpy);
-    element.value = 'random:';
-    MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
-        null, 'enter');
-    assert.isTrue(searchSpy.called);
-  });
-
-  test('keyboard shortcuts', () => {
-    const focusSpy = sinon.spy(element.$.searchInput, 'focus');
-    const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
-    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
-    assert.isTrue(focusSpy.called);
-    assert.isTrue(selectAllSpy.called);
-  });
-
-  suite('_getSearchSuggestions', () => {
-    setup(() => {
-      // Ensure that config.change.mergeability_computation_behavior is not set.
-      element = basicFixture.instantiate();
-    });
-
-    test('Autocompletes accounts', () => {
-      sinon.stub(element, 'accountSuggestions').callsFake(() =>
-        Promise.resolve([{text: 'owner:fred@goog.co'}])
-      );
-      return element._getSearchSuggestions('owner:fr').then(s => {
-        assert.equal(s[0].value, 'owner:fred@goog.co');
-      });
-    });
-
-    test('Autocompletes groups', done => {
-      sinon.stub(element, 'groupSuggestions').callsFake(() =>
-        Promise.resolve([
-          {text: 'ownerin:Polygerrit'},
-          {text: 'ownerin:gerrit'},
-        ])
-      );
-      element._getSearchSuggestions('ownerin:pol').then(s => {
-        assert.equal(s[0].value, 'ownerin:Polygerrit');
-        done();
-      });
-    });
-
-    test('Autocompletes projects', done => {
-      sinon.stub(element, 'projectSuggestions').callsFake(() =>
-        Promise.resolve([
-          {text: 'project:Polygerrit'},
-          {text: 'project:gerrit'},
-          {text: 'project:gerrittest'},
-        ])
-      );
-      element._getSearchSuggestions('project:pol').then(s => {
-        assert.equal(s[0].value, 'project:Polygerrit');
-        done();
-      });
-    });
-
-    test('Autocompletes simple searches', done => {
-      element._getSearchSuggestions('is:o').then(s => {
-        assert.equal(s[0].name, 'is:open');
-        assert.equal(s[0].value, 'is:open');
-        assert.equal(s[1].name, 'is:owner');
-        assert.equal(s[1].value, 'is:owner');
-        done();
-      });
-    });
-
-    test('Does not autocomplete with no match', done => {
-      element._getSearchSuggestions('asdasdasdasd').then(s => {
-        assert.equal(s.length, 0);
-        done();
-      });
-    });
-
-    test('Autocompletes without is:mergable when disabled', async () => {
-      const s = await element._getSearchSuggestions('is:mergeab');
-      assert.isEmpty(s);
-    });
-  });
-
-  [
-    'API_REF_UPDATED_AND_CHANGE_REINDEX',
-    'REF_UPDATED_AND_CHANGE_REINDEX',
-  ].forEach(mergeability => {
-    suite(`mergeability as ${mergeability}`, () => {
-      setup(done => {
-        stubRestApi('getConfig').returns(Promise.resolve({
-          change: {
-            mergeability_computation_behavior: mergeability,
-          },
-        }));
-
-        element = basicFixture.instantiate();
-        flush(done);
-      });
-
-      test('Autocompltes with is:mergable when enabled', done => {
-        element._getSearchSuggestions('is:mergeab').then(s => {
-          assert.equal(s.length, 2);
-          assert.equal(s[0].name, 'is:mergeable');
-          assert.equal(s[0].value, 'is:mergeable');
-          assert.equal(s[1].name, '-is:mergeable');
-          assert.equal(s[1].value, '-is:mergeable');
-          done();
-        });
-      });
-    });
-  });
-
-  suite('doc url', () => {
-    setup(done => {
-      stubRestApi('getConfig').returns(Promise.resolve({
-        gerrit: {
-          doc_url: 'https://doc.com/',
-        },
-      }));
-
-      _testOnly_clearDocsBaseUrlCache();
-      element = basicFixture.instantiate();
-      flush(done);
-    });
-
-    test('compute help doc url with correct path', () => {
-      assert.equal(element.docBaseUrl, 'https://doc.com/');
-      assert.equal(
-          element._computeHelpDocLink(element.docBaseUrl),
-          'https://doc.com/user-search.html'
-      );
-    });
-
-    test('compute help doc url fallback to gerrit url', () => {
-      assert.equal(
-          element._computeHelpDocLink(),
-          'https://gerrit-review.googlesource.com/documentation/' +
-          'user-search.html'
-      );
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
new file mode 100644
index 0000000..b6d0579
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.ts
@@ -0,0 +1,279 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-search-bar';
+import '../../../scripts/util';
+import {GrSearchBar} from './gr-search-bar';
+import {stubRestApi, mockPromise} from '../../../test/test-utils';
+import {_testOnly_clearDocsBaseUrlCache} from '../../../utils/url-util';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createChangeConfig,
+  createGerritInfo,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {MergeabilityComputationBehavior} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-search-bar');
+
+suite('gr-search-bar tests', () => {
+  let element: GrSearchBar;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
+  });
+
+  test('value is propagated to _inputVal', () => {
+    element.value = 'foo';
+    assert.equal(element._inputVal, 'foo');
+  });
+
+  const getActiveElement = () =>
+    document.activeElement!.shadowRoot
+      ? document.activeElement!.shadowRoot.activeElement
+      : document.activeElement;
+
+  test('enter in search input fires event', async () => {
+    const promise = mockPromise();
+    element.addEventListener('handle-search', () => {
+      assert.notEqual(getActiveElement(), element.$.searchInput);
+      promise.resolve();
+    });
+    element.value = 'test';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    await promise;
+  });
+
+  test('input blurred after commit', () => {
+    const blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
+    element.$.searchInput.text = 'fate/stay';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    assert.isTrue(blurSpy.called);
+  });
+
+  test('empty search query does not trigger nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = '';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('Predefined query op with no predication doesnt trigger nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'added:';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    assert.isFalse(searchSpy.called);
+  });
+
+  test('predefined predicate query triggers nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'age:1week';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('undefined predicate query triggers nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:1week';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('empty undefined predicate query triggers nav', () => {
+    const searchSpy = sinon.spy();
+    element.addEventListener('handle-search', searchSpy);
+    element.value = 'random:';
+    MockInteractions.pressAndReleaseKeyOn(
+      element.$.searchInput.$.input,
+      13,
+      null,
+      'enter'
+    );
+    assert.isTrue(searchSpy.called);
+  });
+
+  test('keyboard shortcuts', () => {
+    const focusSpy = sinon.spy(element.$.searchInput, 'focus');
+    const selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+    MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
+    assert.isTrue(focusSpy.called);
+    assert.isTrue(selectAllSpy.called);
+  });
+
+  suite('_getSearchSuggestions', () => {
+    setup(() => {
+      // Ensure that config.change.mergeability_computation_behavior is not set.
+      element = basicFixture.instantiate();
+    });
+
+    test('Autocompletes accounts', () => {
+      sinon
+        .stub(element, 'accountSuggestions')
+        .callsFake(() => Promise.resolve([{text: 'owner:fred@goog.co'}]));
+      return element._getSearchSuggestions('owner:fr').then(s => {
+        assert.equal(s[0].value, 'owner:fred@goog.co');
+      });
+    });
+
+    test('Autocompletes groups', async () => {
+      sinon
+        .stub(element, 'groupSuggestions')
+        .callsFake(() =>
+          Promise.resolve([
+            {text: 'ownerin:Polygerrit'},
+            {text: 'ownerin:gerrit'},
+          ])
+        );
+      const s = await element._getSearchSuggestions('ownerin:pol');
+      assert.equal(s[0].value, 'ownerin:Polygerrit');
+    });
+
+    test('Autocompletes projects', async () => {
+      sinon
+        .stub(element, 'projectSuggestions')
+        .callsFake(() =>
+          Promise.resolve([
+            {text: 'project:Polygerrit'},
+            {text: 'project:gerrit'},
+            {text: 'project:gerrittest'},
+          ])
+        );
+      const s = await element._getSearchSuggestions('project:pol');
+      assert.equal(s[0].value, 'project:Polygerrit');
+    });
+
+    test('Autocompletes simple searches', async () => {
+      const s = await element._getSearchSuggestions('is:o');
+      assert.equal(s[0].name, 'is:open');
+      assert.equal(s[0].value, 'is:open');
+      assert.equal(s[1].name, 'is:owner');
+      assert.equal(s[1].value, 'is:owner');
+    });
+
+    test('Does not autocomplete with no match', async () => {
+      const s = await element._getSearchSuggestions('asdasdasdasd');
+      assert.equal(s.length, 0);
+    });
+
+    test('Autocompletes without is:mergable when disabled', async () => {
+      const s = await element._getSearchSuggestions('is:mergeab');
+      assert.isEmpty(s);
+    });
+  });
+
+  [
+    'API_REF_UPDATED_AND_CHANGE_REINDEX',
+    'REF_UPDATED_AND_CHANGE_REINDEX',
+  ].forEach(mergeability => {
+    suite(`mergeability as ${mergeability}`, () => {
+      setup(async () => {
+        stubRestApi('getConfig').returns(
+          Promise.resolve({
+            ...createServerInfo(),
+            change: {
+              ...createChangeConfig(),
+              mergeability_computation_behavior:
+                mergeability as MergeabilityComputationBehavior,
+            },
+          })
+        );
+
+        element = basicFixture.instantiate();
+        await flush();
+      });
+
+      test('Autocompltes with is:mergable when enabled', async () => {
+        const s = await element._getSearchSuggestions('is:mergeab');
+        assert.equal(s.length, 2);
+        assert.equal(s[0].name, 'is:mergeable');
+        assert.equal(s[0].value, 'is:mergeable');
+        assert.equal(s[1].name, '-is:mergeable');
+        assert.equal(s[1].value, '-is:mergeable');
+      });
+    });
+  });
+
+  suite('doc url', () => {
+    setup(async () => {
+      stubRestApi('getConfig').returns(
+        Promise.resolve({
+          ...createServerInfo(),
+          gerrit: {
+            ...createGerritInfo(),
+            doc_url: 'https://doc.com/',
+          },
+        })
+      );
+
+      _testOnly_clearDocsBaseUrlCache();
+      element = basicFixture.instantiate();
+      await flush();
+    });
+
+    test('compute help doc url with correct path', () => {
+      assert.equal(element.docBaseUrl, 'https://doc.com/');
+      assert.equal(
+        element._computeHelpDocLink(element.docBaseUrl),
+        'https://doc.com/user-search.html'
+      );
+    });
+
+    test('compute help doc url fallback to gerrit url', () => {
+      assert.equal(
+        element._computeHelpDocLink(null),
+        'https://gerrit-review.googlesource.com/documentation/' +
+          'user-search.html'
+      );
+    });
+  });
+});
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 3dfe1eb..f5a28f8 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
@@ -32,6 +32,12 @@
 const SELF_EXPRESSION = 'self';
 const ME_EXPRESSION = 'me';
 
+declare global {
+  interface HTMLElementEventMap {
+    'handle-search': CustomEvent<SearchBarHandleSearchDetail>;
+  }
+}
+
 @customElement('gr-smart-search')
 export class GrSmartSearch extends PolymerElement {
   static get template() {
@@ -39,7 +45,7 @@
   }
 
   @property({type: String})
-  searchQuery?: string;
+  searchQuery = '';
 
   @property({type: Object})
   _config?: ServerInfo;
@@ -61,8 +67,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.restApiService.getConfig().then(cfg => {
       this._config = cfg;
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
deleted file mode 100644
index f3a9965..0000000
--- a/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.js
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-smart-search.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-smart-search');
-
-suite('gr-smart-search tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('Autocompletes accounts', () => {
-    stubRestApi('getSuggestedAccounts').callsFake(() =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-    });
-  });
-
-  test('Inserts self as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    element._fetchAccounts('owner', 's')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:self'});
-        })
-        .then(() => element._fetchAccounts('owner', 'selfs'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:self'});
-        });
-  });
-
-  test('Inserts me as option when valid', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([
-        {
-          name: 'fred',
-          email: 'fred@goog.co',
-        },
-      ])
-    );
-    return element._fetchAccounts('owner', 'm')
-        .then(s => {
-          assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
-          assert.deepEqual(s[1], {text: 'owner:me'});
-        })
-        .then(() => element._fetchAccounts('owner', 'meme'))
-        .then(s => {
-          assert.notEqual(s[0], {text: 'owner:me'});
-        });
-  });
-
-  test('Autocompletes groups', () => {
-    stubRestApi('getSuggestedGroups').callsFake( () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-    });
-  });
-
-  test('Autocompletes projects', () => {
-    stubRestApi('getSuggestedProjects').callsFake( () =>
-      Promise.resolve({Polygerrit: 0}));
-    return element._fetchProjects('project', 'pol').then(s => {
-      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
-    });
-  });
-
-  test('Autocomplete doesnt override exact matches to input', () => {
-    stubRestApi('getSuggestedGroups').callsFake( () =>
-      Promise.resolve({
-        Polygerrit: 0,
-        gerrit: 0,
-        gerrittest: 0,
-      })
-    );
-    return element._fetchGroups('ownerin', 'gerrit').then(s => {
-      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
-      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
-      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
-    });
-  });
-
-  test('Autocompletes accounts with no email', () => {
-    stubRestApi('getSuggestedAccounts').callsFake( () =>
-      Promise.resolve([{name: 'fred'}]));
-    return element._fetchAccounts('owner', 'fr').then(s => {
-      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
-    });
-  });
-
-  test('Autocompletes accounts with email', () => {
-    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/core/gr-smart-search/gr-smart-search_test.ts b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
new file mode 100644
index 0000000..0218a8f
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-smart-search/gr-smart-search_test.ts
@@ -0,0 +1,143 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-smart-search';
+import {GrSmartSearch} from './gr-smart-search';
+import {stubRestApi} from '../../../test/test-utils';
+import {EmailAddress, GroupId, UrlEncodedRepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-smart-search');
+
+suite('gr-smart-search tests', () => {
+  let element: GrSmartSearch;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('Autocompletes accounts', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+    });
+  });
+
+  test('Inserts self as option when valid', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    element
+      ._fetchAccounts('owner', 's')
+      .then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:self'});
+      })
+      .then(() => element._fetchAccounts('owner', 'selfs'))
+      .then(s => {
+        assert.notEqual(s[0], {text: 'owner:self'});
+      });
+  });
+
+  test('Inserts me as option when valid', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([
+        {
+          name: 'fred',
+          email: 'fred@goog.co' as EmailAddress,
+        },
+      ])
+    );
+    return element
+      ._fetchAccounts('owner', 'm')
+      .then(s => {
+        assert.deepEqual(s[0], {text: 'owner:fred@goog.co', label: 'fred'});
+        assert.deepEqual(s[1], {text: 'owner:me'});
+      })
+      .then(() => element._fetchAccounts('owner', 'meme'))
+      .then(s => {
+        assert.notEqual(s[0], {text: 'owner:me'});
+      });
+  });
+
+  test('Autocompletes groups', () => {
+    stubRestApi('getSuggestedGroups').callsFake(() =>
+      Promise.resolve({
+        Polygerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+      })
+    );
+    return element._fetchGroups('ownerin', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+    });
+  });
+
+  test('Autocompletes projects', () => {
+    stubRestApi('getSuggestedProjects').callsFake(() =>
+      Promise.resolve({Polygerrit: {id: 'test' as UrlEncodedRepoName}})
+    );
+    return element._fetchProjects('project', 'pol').then(s => {
+      assert.deepEqual(s[0], {text: 'project:Polygerrit'});
+    });
+  });
+
+  test('Autocomplete doesnt override exact matches to input', () => {
+    stubRestApi('getSuggestedGroups').callsFake(() =>
+      Promise.resolve({
+        Polygerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrit: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+        gerrittest: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f' as GroupId},
+      })
+    );
+    return element._fetchGroups('ownerin', 'gerrit').then(s => {
+      assert.deepEqual(s[0], {text: 'ownerin:Polygerrit'});
+      assert.deepEqual(s[1], {text: 'ownerin:gerrit'});
+      assert.deepEqual(s[2], {text: 'ownerin:gerrittest'});
+    });
+  });
+
+  test('Autocompletes accounts with no email', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([{name: 'fred'}])
+    );
+    return element._fetchAccounts('owner', 'fr').then(s => {
+      assert.deepEqual(s[0], {text: 'owner:"fred"', label: 'fred'});
+    });
+  });
+
+  test('Autocompletes accounts with email', () => {
+    stubRestApi('getSuggestedAccounts').callsFake(() =>
+      Promise.resolve([{email: 'fred@goog.co' as EmailAddress}])
+    );
+    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.ts
similarity index 68%
rename from polygerrit-ui/app/elements/custom-dark-theme_test.js
rename to polygerrit-ui/app/elements/custom-dark-theme_test.ts
index 97a2750..71e0740 100644
--- a/polygerrit-ui/app/elements/custom-dark-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-dark-theme_test.ts
@@ -14,29 +14,27 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../test/common-test-setup-karma.js';
-import {getComputedStyleValue} from '../utils/dom-util.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';
+import '../test/common-test-setup-karma';
+import {getComputedStyleValue} from '../utils/dom-util';
+import './gr-app';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {GrApp} from './gr-app';
 
 const basicFixture = fixtureFromElement('gr-app');
 
 suite('gr-app custom dark theme tests', () => {
-  let element;
-  setup(done => {
+  let element: GrApp;
+  setup(async () => {
     window.localStorage.setItem('dark-theme', 'true');
 
     element = basicFixture.instantiate();
     getPluginLoader().loadPlugins([]);
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => flush(done));
+    await getPluginLoader().awaitPluginsLoaded();
+    await flush();
   });
 
   teardown(() => {
     window.localStorage.removeItem('dark-theme');
-    removeTheme();
     // The app sends requests to server. This can lead to
     // unexpected gr-alert elements in document.body
     document.body.querySelectorAll('gr-alert').forEach(grAlert => {
@@ -45,19 +43,17 @@
   });
 
   test('should tried to load dark theme', () => {
-    assert.isTrue(
-        !!document.head.querySelector('#dark-theme')
-    );
+    assert.isTrue(!!document.head.querySelector('#dark-theme'));
   });
 
   test('applies the right theme', () => {
     assert.equal(
-        getComputedStyleValue('--header-background-color', element)
-            .toLowerCase(),
-        '#3c4043');
+      getComputedStyleValue('--header-background-color', element).toLowerCase(),
+      '#3c4043'
+    );
     assert.equal(
-        getComputedStyleValue('--footer-background-color', element)
-            .toLowerCase(),
-        '#3c4043');
+      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
+      '#3c4043'
+    );
   });
 });
diff --git a/polygerrit-ui/app/elements/custom-light-theme_test.js b/polygerrit-ui/app/elements/custom-light-theme_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/custom-light-theme_test.js
rename to polygerrit-ui/app/elements/custom-light-theme_test.ts
index 35bc3a6..80a7cab 100644
--- a/polygerrit-ui/app/elements/custom-light-theme_test.js
+++ b/polygerrit-ui/app/elements/custom-light-theme_test.ts
@@ -14,28 +14,35 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../test/common-test-setup-karma.js';
-import {getComputedStyleValue} from '../utils/dom-util.js';
-import './gr-app.js';
-import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader.js';
-import {stubRestApi} from '../test/test-utils.js';
+import '../test/common-test-setup-karma';
+import {getComputedStyleValue} from '../utils/dom-util';
+import './gr-app';
+import '../styles/themes/app-theme';
+import {getPluginLoader} from './shared/gr-js-api-interface/gr-plugin-loader';
+import {stubRestApi} from '../test/test-utils';
+import {GrApp} from './gr-app';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../test/test-data-generators';
 
 const basicFixture = fixtureFromElement('gr-app');
 
 suite('gr-app custom light theme tests', () => {
-  let element;
-  setup(done => {
+  let element: GrApp;
+  setup(async () => {
     window.localStorage.removeItem('dark-theme');
-    stubRestApi('getConfig').returns(Promise.resolve({test: 'config'}));
-    stubRestApi('getAccount').returns(Promise.resolve({}));
+    stubRestApi('getConfig').returns(Promise.resolve(createServerInfo()));
+    stubRestApi('getAccount').returns(
+      Promise.resolve(createAccountDetailWithId())
+    );
     stubRestApi('getDiffComments').returns(Promise.resolve({}));
     stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
     stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
     element = basicFixture.instantiate();
     getPluginLoader().loadPlugins([]);
-    getPluginLoader().awaitPluginsLoaded()
-        .then(() => flush(done));
+    await getPluginLoader().awaitPluginsLoaded();
+    await flush();
   });
   teardown(() => {
     // The app sends requests to server. This can lead to
@@ -52,12 +59,12 @@
 
   test('applies the right theme', () => {
     assert.equal(
-        getComputedStyleValue('--header-background-color', element)
-            .toLowerCase(),
-        '#f1f3f4');
+      getComputedStyleValue('--header-background-color', element).toLowerCase(),
+      '#f1f3f4'
+    );
     assert.equal(
-        getComputedStyleValue('--footer-background-color', element)
-            .toLowerCase(),
-        'transparent');
+      getComputedStyleValue('--footer-background-color', element).toLowerCase(),
+      'transparent'
+    );
   });
 });
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 0bf9f1c..a6176e1 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
@@ -38,8 +38,9 @@
 import {OpenFixPreviewEvent} from '../../../types/events';
 import {appContext} from '../../../services/app-context';
 import {fireCloseFixPreview, fireEvent} from '../../../utils/event-util';
-import {ParsedChangeInfo} from '../../../types/types';
+import {DiffLayer, ParsedChangeInfo} from '../../../types/types';
 import {GrButton} from '../../shared/gr-button/gr-button';
+import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 
 export interface GrApplyFixDialog {
   $: {
@@ -95,12 +96,24 @@
       '_computeDisableApplyFixButton(_isApplyFixLoading, change, ' +
       '_patchNum)',
   })
-  _disableApplyFixButton?: boolean;
+  _disableApplyFixButton = false;
+
+  @property({type: Array})
+  layers: DiffLayer[] = [];
 
   private refitOverlay?: () => void;
 
   private readonly restApiService = appContext.restApiService;
 
+  constructor() {
+    super();
+    this.restApiService.getPreferences().then(prefs => {
+      if (!prefs?.disable_token_highlighting) {
+        this.layers = [new TokenHighlightLayer(this)];
+      }
+    });
+  }
+
   /**
    * Given robot comment CustomEvent object, fetch diffs associated
    * with first robot comment suggested fix and open dialog.
@@ -133,7 +146,7 @@
     });
   }
 
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.refitOverlay = () => {
       // re-center the dialog as content changed
@@ -142,7 +155,7 @@
     this.addEventListener('diff-context-expanded', this.refitOverlay);
   }
 
-  disconnectedCallback() {
+  override disconnectedCallback() {
     if (this.refitOverlay) {
       this.removeEventListener('diff-context-expanded', this.refitOverlay);
     }
@@ -179,12 +192,13 @@
     return (_fixSuggestions || []).length === 1;
   }
 
-  overridePartialPrefs(prefs: DiffPreferencesInfo): DiffPreferencesInfo {
+  overridePartialPrefs(prefs?: DiffPreferencesInfo) {
+    if (!prefs) return undefined;
     // generate a smaller gr-diff than fullscreen for dialog
     return {...prefs, line_length: 50};
   }
 
-  onCancel(e: CustomEvent) {
+  onCancel(e: Event) {
     if (e) {
       e.stopPropagation();
     }
@@ -195,7 +209,7 @@
     return _selectedFixIdx + 1;
   }
 
-  _onPrevFixClick(e: CustomEvent) {
+  _onPrevFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (this._selectedFixIdx >= 1 && this._fixSuggestions) {
       this._selectedFixIdx -= 1;
@@ -205,7 +219,7 @@
     }
   }
 
-  _onNextFixClick(e: CustomEvent) {
+  _onNextFixClick(e: Event) {
     if (e) e.stopPropagation();
     if (
       this._fixSuggestions &&
@@ -249,7 +263,7 @@
   }
 
   _computeDisableApplyFixButton(
-    isApplyFixLoading?: boolean,
+    isApplyFixLoading: boolean,
     change?: ParsedChangeInfo,
     patchNum?: PatchSetNum
   ) {
@@ -263,7 +277,7 @@
     return isApplyFixLoading;
   }
 
-  _handleApplyFix(e: CustomEvent) {
+  _handleApplyFix(e: Event) {
     if (e) {
       e.stopPropagation();
     }
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 52fa9841..b0716dd 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
@@ -31,10 +31,6 @@
       background-color: var(--background-color-secondary);
       border-bottom: 1px solid var(--border-color);
     }
-    .fixActions {
-      display: flex;
-      justify-content: flex-end;
-    }
     gr-button {
       margin-left: var(--spacing-m);
     }
@@ -65,6 +61,7 @@
               change-num="[[changeNum]]"
               path="[[item.filepath]]"
               diff="[[item.preview]]"
+              layers="[[layers]]"
             ></gr-diff>
           </div>
         </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
index d3d7615..94d37f5 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog_test.ts
@@ -186,6 +186,7 @@
         })
       );
       element._isApplyFixLoading = true;
+      await flush();
       const button = getConfirmButton();
       assert.isTrue(button.hasAttribute('disabled'));
       assert.equal(button.getAttribute('title'), '');
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 3836b98..fc22a58 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
@@ -16,19 +16,18 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-api_html';
-import {CURRENT} from '../../../utils/patch-set-util';
 import {customElement, property} from '@polymer/decorators';
 import {
   CommentBasics,
   PatchRange,
   PatchSetNum,
-  PathToRobotCommentsInfoMap,
   RobotCommentInfo,
   UrlEncodedCommentId,
   NumericChangeId,
   PathToCommentsInfoMap,
   FileInfo,
   ParentPatchSetNum,
+  CommentInfo,
 } from '../../../types/common';
 import {
   Comment,
@@ -37,31 +36,30 @@
   DraftInfo,
   isUnresolved,
   UIComment,
-  UIDraft,
-  UIHuman,
-  UIRobot,
   createCommentThreads,
   isInPatchRange,
   isDraftThread,
   isInBaseOfPatchRange,
   isInRevisionOfPatchRange,
   isPatchsetLevel,
+  addPath,
 } from '../../../utils/comment-util';
 import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
 import {appContext} from '../../../services/app-context';
 import {CommentSide, Side} from '../../../constants/constants';
 import {pluralize} from '../../../utils/string-util';
+import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list';
 
 export type CommentIdToCommentThreadMap = {
   [urlEncodedCommentId: string]: CommentThread;
 };
 
 export class ChangeComments {
-  private readonly _comments: {[path: string]: UIHuman[]};
+  private readonly _comments: PathToCommentsInfoMap;
 
-  private readonly _robotComments: {[path: string]: UIRobot[]};
+  private readonly _robotComments: {[path: string]: RobotCommentInfo[]};
 
-  private readonly _drafts: {[path: string]: UIDraft[]};
+  private readonly _drafts: {[path: string]: DraftInfo[]};
 
   private readonly _portedComments: PathToCommentsInfoMap;
 
@@ -72,48 +70,30 @@
    * elements of that which uses the gr-comment-api.
    */
   constructor(
-    comments: {[path: string]: UIHuman[]} | undefined,
-    robotComments: {[path: string]: UIRobot[]} | undefined,
-    drafts: {[path: string]: UIDraft[]} | undefined,
+    comments: PathToCommentsInfoMap | undefined,
+    robotComments: {[path: string]: RobotCommentInfo[]} | undefined,
+    drafts: {[path: string]: DraftInfo[]} | undefined,
     portedComments: PathToCommentsInfoMap | undefined,
     portedDrafts: PathToCommentsInfoMap | undefined
   ) {
-    this._comments = this._addPath(comments);
-    this._robotComments = this._addPath(robotComments);
-    this._drafts = this._addPath(drafts);
+    this._comments = addPath(comments);
+    this._robotComments = addPath(robotComments);
+    this._drafts = addPath(drafts);
     this._portedComments = portedComments || {};
     this._portedDrafts = portedDrafts || {};
   }
 
-  /**
-   * Add path info to every comment as CommentInfo returned
-   * from server does not have that.
-   *
-   * TODO(taoalpha): should consider changing BE to send path
-   * back within CommentInfo
-   */
-  _addPath<T>(
-    comments: {[path: string]: T[]} = {}
-  ): {[path: string]: Array<T & {path: string}>} {
-    const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
-    for (const filePath of Object.keys(comments)) {
-      const allCommentsForPath = comments[filePath] || [];
-      if (allCommentsForPath.length) {
-        updatedComments[filePath] = allCommentsForPath.map(comment => {
-          return {...comment, path: filePath};
-        });
-      }
-    }
-    return updatedComments;
-  }
-
   get drafts() {
     return this._drafts;
   }
 
-  findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
+  findCommentById(
+    commentId?: UrlEncodedCommentId
+  ): CommentInfo | DraftInfo | undefined {
     if (!commentId) return undefined;
-    const findComment = (comments: {[path: string]: UIComment[]}) => {
+    const findComment = (comments: {
+      [path: string]: (CommentInfo | DraftInfo)[];
+    }) => {
       let comment;
       for (const path of Object.keys(comments)) {
         comment = comment || comments[path].find(c => c.id === commentId);
@@ -198,7 +178,7 @@
    */
   getAllDrafts(patchNum?: PatchSetNum) {
     const paths = this.getPaths();
-    const drafts: {[path: string]: UIDraft[]} = {};
+    const drafts: {[path: string]: DraftInfo[]} = {};
     for (const path of Object.keys(paths)) {
       drafts[path] = this.getAllDraftsForPath(path, patchNum);
     }
@@ -253,7 +233,7 @@
     return allComments;
   }
 
-  cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
+  cloneWithUpdatedDrafts(drafts: {[path: string]: DraftInfo[]} | undefined) {
     return new ChangeComments(
       this._comments,
       this._robotComments,
@@ -522,6 +502,33 @@
       .length;
   }
 
+  computeDraftCountForFile(patchRange?: PatchRange, file?: NormalizedFileInfo) {
+    if (patchRange === undefined || file === undefined) {
+      return 0;
+    }
+    const getCommentForPath = (path?: string) => {
+      if (!path) return 0;
+      return (
+        this.computeDraftCount({
+          patchNum: patchRange.basePatchNum,
+          path,
+        }) +
+        this.computeDraftCount({
+          patchNum: patchRange.patchNum,
+          path,
+        }) +
+        this.computePortedDraftCount(
+          {
+            patchNum: patchRange.patchNum,
+            basePatchNum: patchRange.basePatchNum,
+          },
+          path
+        )
+      );
+    };
+    return getCommentForPath(file.__path) + getCommentForPath(file.old_path);
+  }
+
   /**
    * @param includeUnmodified Included unmodified status of the file in the
    * comment string or not. For files we opt of chip instead of a string.
@@ -537,8 +544,17 @@
     if (!patchRange) return '';
 
     const threads = this.getThreadsBySideForFile({path}, patchRange);
-    const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
-      .length;
+    if (changeFileInfo?.old_path) {
+      threads.push(
+        ...this.getThreadsBySideForFile(
+          {path: changeFileInfo.old_path},
+          patchRange
+        )
+      );
+    }
+    const commentThreadCount = threads.filter(
+      thread => !isDraftThread(thread)
+    ).length;
     const unresolvedCount = threads.reduce((cnt, thread) => {
       if (isUnresolved(thread)) cnt += 1;
       return cnt;
@@ -596,12 +612,6 @@
   }
 }
 
-// TODO(TS): move findCommentById out of class
-export const _testOnly_findCommentById =
-  ChangeComments.prototype.findCommentById;
-
-export const _testOnly_getCommentsForPath =
-  ChangeComments.prototype.getCommentsForPath;
 @customElement('gr-comment-api')
 export class GrCommentApi extends PolymerElement {
   static get template() {
@@ -613,56 +623,11 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /**
-   * Load all comments (with drafts and robot comments) for the given change
-   * number. The returned promise resolves when the comments have loaded, but
-   * does not yield the comment data.
-   */
-  loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
-    const revision = patchNum || CURRENT;
-    const commentsPromise = [
-      this.restApiService.getDiffComments(changeNum),
-      this.restApiService.getDiffRobotComments(changeNum),
-      this.restApiService.getDiffDrafts(changeNum),
-      this.restApiService.getPortedComments(changeNum, revision),
-      this.restApiService.getPortedDrafts(changeNum, revision),
-    ];
-
-    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;
-      }
-    );
-  }
-
-  /**
-   * Re-initialize _changeComments with a new ChangeComments object, that
-   * uses the previous values for comments and robot comments, but fetches
-   * updated draft comments.
-   */
-  reloadDrafts(changeNum: NumericChangeId) {
-    if (!this._changeComments) {
-      return this.loadAll(changeNum);
-    }
-    return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
-      this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
-        drafts
-      );
-      return this._changeComments;
-    });
-  }
+  private readonly commentsService = appContext.commentsService;
 
   reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
     if (!this._changeComments) {
-      this.loadAll(changeNum);
+      this.commentsService.loadAll(changeNum);
       return Promise.resolve();
     }
     return Promise.all([
@@ -670,10 +635,8 @@
       this.restApiService.getPortedDrafts(changeNum, patchNum),
     ]).then(res => {
       if (!this._changeComments) return;
-      this._changeComments = this._changeComments.cloneWithUpdatedPortedComments(
-        res[0],
-        res[1]
-      );
+      this._changeComments =
+        this._changeComments.cloneWithUpdatedPortedComments(res[0], res[1]);
     });
   }
 }
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 fbbe1fb..7e01371 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
@@ -34,107 +34,11 @@
     element = basicFixture.instantiate();
   });
 
-  test('loads logged-out', () => {
-    const changeNum = 1234;
-
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        }));
-    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(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, {});
-    });
-  });
-
-  test('loads logged-in', () => {
-    const changeNum = 1234;
-
-    const getCommentsStub = stubRestApi('getDiffComments').returns(
-        Promise.resolve({
-          'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
-        })
-    );
-    const getRobotCommentsStub = stubRestApi('getDiffRobotComments')
-        .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
-    const getDraftsStub = stubRestApi('getDiffDrafts')
-        .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
-
-    return element.loadAll(changeNum).then(() => {
-      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, {});
-    });
-  });
-
-  suite('reloadDrafts', () => {
-    let commentStub;
-    let robotCommentStub;
-    let draftStub;
-    setup(() => {
-      commentStub = stubRestApi('getDiffComments')
-          .returns(Promise.resolve({}));
-      robotCommentStub = stubRestApi(
-          'getDiffRobotComments').returns(Promise.resolve({}));
-      draftStub = stubRestApi('getDiffDrafts')
-          .returns(Promise.resolve({}));
-    });
-
-    test('without loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      sinon.spy(element, 'loadAll');
-      element.reloadDrafts().then(() => {
-        assert.isTrue(element.loadAll.called);
-        assert.isOk(element._changeComments);
-        assert.equal(commentStub.callCount, 1);
-        assert.equal(robotCommentStub.callCount, 1);
-        assert.equal(draftStub.callCount, 1);
-        done();
-      });
-    });
-
-    test('with loadAll first', done => {
-      assert.isNotOk(element._changeComments);
-      element.loadAll()
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 1);
-            return element.reloadDrafts();
-          })
-          .then(() => {
-            assert.isOk(element._changeComments);
-            assert.equal(commentStub.callCount, 1);
-            assert.equal(robotCommentStub.callCount, 1);
-            assert.equal(draftStub.callCount, 2);
-            done();
-          });
-    });
-  });
-
   suite('_changeComment methods', () => {
-    setup(done => {
-      const changeNum = 1234;
+    setup(() => {
       stubRestApi('getDiffComments').returns(Promise.resolve({}));
       stubRestApi('getDiffRobotComments').returns(Promise.resolve({}));
       stubRestApi('getDiffDrafts').returns(Promise.resolve({}));
-      element.loadAll(changeNum).then(() => {
-        done();
-      });
     });
 
     suite('ported comments', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
new file mode 100644
index 0000000..af9c9d4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls.ts
@@ -0,0 +1,545 @@
+/**
+ * @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 '@polymer/paper-button/paper-button';
+import '@polymer/paper-card/paper-card';
+import '@polymer/paper-checkbox/paper-checkbox';
+import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
+import '@polymer/paper-item/paper-item';
+import '@polymer/paper-listbox/paper-listbox';
+import '@polymer/paper-tooltip/paper-tooltip';
+import {of, EMPTY, Subject} from 'rxjs';
+import {switchMap, delay, takeUntil} from 'rxjs/operators';
+
+import '../../shared/gr-button/gr-button';
+import {pluralize} from '../../../utils/string-util';
+import {fire} from '../../../utils/event-util';
+import {DiffInfo} from '../../../types/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {css, html, LitElement, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators';
+
+import {
+  ContextButtonType,
+  DiffContextButtonHoveredDetail,
+  RenderPreferences,
+  SyntaxBlock,
+} from '../../../api/diff';
+
+import {GrDiffGroup, hideInContextControl} from '../gr-diff/gr-diff-group';
+
+declare global {
+  interface HTMLElementEventMap {
+    'diff-context-button-hovered': CustomEvent<DiffContextButtonHoveredDetail>;
+  }
+}
+
+const PARTIAL_CONTEXT_AMOUNT = 10;
+
+/**
+ * Traverses a hierarchical structure of syntax blocks and
+ * finds the most local/nested block that can be associated line.
+ * It finds the closest block that contains the whole line and
+ * returns the whole path from the syntax layer (blocks) sent as parameter
+ * to the most nested block - the complete path from the top to bottom layer of
+ * a syntax tree. Example: [myNamepace, MyClass, myMethod1, aLocalFunctionInsideMethod1]
+ *
+ * @param lineNum line number for the targeted line.
+ * @param blocks Blocks for a specific syntax level in the file (to allow recursive calls)
+ */
+function findBlockTreePathForLine(
+  lineNum: number,
+  blocks?: SyntaxBlock[]
+): SyntaxBlock[] {
+  const containingBlock = blocks?.find(
+    ({range}) => range.start_line < lineNum && range.end_line > lineNum
+  );
+  if (!containingBlock) return [];
+  const innerPathInChild = findBlockTreePathForLine(
+    lineNum,
+    containingBlock?.children
+  );
+  return [containingBlock].concat(innerPathInChild);
+}
+
+export type GrContextControlsShowConfig = 'above' | 'below' | 'both';
+
+@customElement('gr-context-controls')
+export class GrContextControls extends LitElement {
+  @property({type: Object}) renderPreferences?: RenderPreferences;
+
+  @property({type: Object}) diff?: DiffInfo;
+
+  @property({type: Object}) section?: HTMLElement;
+
+  @property({type: Object}) contextGroups: GrDiffGroup[] = [];
+
+  @property({type: String, reflect: true})
+  showConfig: GrContextControlsShowConfig = 'both';
+
+  private expandButtonsHover = new Subject<{
+    eventType: 'enter' | 'leave';
+    buttonType: ContextButtonType;
+    linesToExpand: number;
+  }>();
+
+  private disconnected$ = new Subject();
+
+  static override styles = css`
+    :host {
+      display: flex;
+      justify-content: center;
+      flex-direction: column;
+      position: relative;
+    }
+
+    :host([showConfig='above']) {
+      justify-content: flex-end;
+      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: var(--gr-context-controls-margin-bottom);
+      height: calc(var(--line-height-normal) + var(--spacing-s));
+      .horizontalFlex {
+        align-items: end;
+      }
+    }
+
+    :host([showConfig='below']) {
+      justify-content: flex-start;
+      margin-top: 1px;
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      .horizontalFlex {
+        align-items: start;
+      }
+    }
+
+    :host([showConfig='both']) {
+      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
+      height: calc(
+        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
+          var(--divider-height)
+      );
+      .horizontalFlex {
+        align-items: center;
+      }
+    }
+
+    .contextControlButton {
+      background-color: var(--default-button-background-color);
+      font: var(--context-control-button-font, inherit);
+    }
+
+    paper-button {
+      text-transform: none;
+      align-items: center;
+      background-color: var(--background-color);
+      font-family: inherit;
+      margin: var(--margin, 0);
+      min-width: var(--border, 0);
+      color: var(--diff-context-control-color);
+      border: solid var(--border-color);
+      border-width: 1px;
+      border-radius: var(--border-radius);
+      padding: var(--spacing-s) var(--spacing-l);
+    }
+
+    paper-button:hover {
+      /* same as defined in gr-button */
+      background: rgba(0, 0, 0, 0.12);
+    }
+
+    .aboveBelowButtons {
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      margin-left: var(--spacing-m);
+      position: relative;
+    }
+    .aboveBelowButtons:first-child {
+      margin-left: 0;
+      /* Places a default background layer behind the "all button" that can have opacity */
+      background-color: var(--default-button-background-color);
+    }
+
+    .horizontalFlex {
+      display: flex;
+      justify-content: center;
+      align-items: var(--gr-context-controls-horizontal-align-items, center);
+    }
+
+    .aboveButton {
+      border-bottom-width: 0;
+      border-bottom-right-radius: 0;
+      border-bottom-left-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+    }
+    .belowButton {
+      border-top-width: 0;
+      border-top-left-radius: 0;
+      border-top-right-radius: 0;
+      padding: var(--spacing-xxs) var(--spacing-l);
+      margin-top: calc(var(--divider-height) + 2 * var(--spacing-xxs));
+    }
+    .belowButton:first-child {
+      margin-top: 0;
+    }
+    .breadcrumbTooltip {
+      white-space: nowrap;
+    }
+  `;
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.setupButtonHoverHandler();
+  }
+
+  override disconnectedCallback() {
+    this.disconnected$.next();
+  }
+
+  private showBoth() {
+    return this.showConfig === 'both';
+  }
+
+  private showAbove() {
+    return this.showBoth() || this.showConfig === 'above';
+  }
+
+  private showBelow() {
+    return this.showBoth() || this.showConfig === 'below';
+  }
+
+  setupButtonHoverHandler() {
+    this.expandButtonsHover
+      .pipe(
+        switchMap(e => {
+          if (e.eventType === 'leave') {
+            // cancel any previous delay
+            // for mouse enter
+            return EMPTY;
+          }
+          return of(e).pipe(delay(500));
+        }),
+        takeUntil(this.disconnected$)
+      )
+      .subscribe(({buttonType, linesToExpand}) => {
+        fire(this, 'diff-context-button-hovered', {
+          buttonType,
+          linesToExpand,
+        });
+      });
+  }
+
+  private numLines() {
+    const {leftStart, leftEnd} = this.contextRange();
+    return leftEnd - leftStart + 1;
+  }
+
+  private createExpandAllButtonContainer() {
+    return html` <div
+      class="style-scope gr-diff aboveBelowButtons fullExpansion"
+    >
+      ${this.createContextButton(ContextButtonType.ALL, this.numLines())}
+    </div>`;
+  }
+
+  /**
+   * Creates a specific expansion button (e.g. +X common lines, +10, +Block).
+   */
+  private createContextButton(
+    type: ContextButtonType,
+    linesToExpand: number,
+    tooltip?: TemplateResult
+  ) {
+    let text = '';
+    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
+    let ariaLabel = '';
+    let classes = 'contextControlButton showContext ';
+
+    if (type === ContextButtonType.ALL) {
+      text = `+${pluralize(linesToExpand, 'common line')}`;
+      ariaLabel = `Show ${pluralize(linesToExpand, 'common line')}`;
+      classes += this.showBoth()
+        ? 'centeredButton'
+        : this.showAbove()
+        ? 'aboveButton'
+        : 'belowButton';
+      if (this.partialContent) {
+        // Expanding content would require load of more data
+        text += ' (too large)';
+      }
+      groups.push(...this.contextGroups);
+    } else if (type === ContextButtonType.ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = `+${linesToExpand}`;
+      classes += 'aboveButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} above`;
+    } else if (type === ContextButtonType.BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = `+${linesToExpand}`;
+      classes += 'belowButton';
+      ariaLabel = `Show ${pluralize(linesToExpand, 'line')} below`;
+    } else if (type === ContextButtonType.BLOCK_ABOVE) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        linesToExpand,
+        this.numLines()
+      );
+      text = '+Block';
+      classes += 'aboveButton';
+      ariaLabel = 'Show block above';
+    } else if (type === ContextButtonType.BLOCK_BELOW) {
+      groups = hideInContextControl(
+        this.contextGroups,
+        0,
+        this.numLines() - linesToExpand
+      );
+      text = '+Block';
+      classes += 'belowButton';
+      ariaLabel = 'Show block below';
+    }
+    const expandHandler = this.createExpansionHandler(
+      linesToExpand,
+      type,
+      groups
+    );
+
+    const mouseHandler = (eventType: 'enter' | 'leave') => {
+      this.expandButtonsHover.next({
+        eventType,
+        buttonType: type,
+        linesToExpand,
+      });
+    };
+
+    const button = html` <paper-button
+      class="${classes}"
+      aria-label="${ariaLabel}"
+      @click="${expandHandler}"
+      @mouseenter="${() => mouseHandler('enter')}"
+      @mouseleave="${() => mouseHandler('leave')}"
+    >
+      <span class="showContext">${text}</span>
+      ${tooltip}
+    </paper-button>`;
+    return button;
+  }
+
+  private createExpansionHandler(
+    linesToExpand: number,
+    type: ContextButtonType,
+    groups: GrDiffGroup[]
+  ) {
+    return (e: Event) => {
+      e.stopPropagation();
+      if (type === ContextButtonType.ALL && this.partialContent) {
+        const {leftStart, leftEnd, rightStart, rightEnd} = this.contextRange();
+        const lineRange = {
+          left: {
+            start_line: leftStart,
+            end_line: leftEnd,
+          },
+          right: {
+            start_line: rightStart,
+            end_line: rightEnd,
+          },
+        };
+        fire(this, 'content-load-needed', {
+          lineRange,
+        });
+      } else {
+        assertIsDefined(this.section, 'section');
+        fire(this, 'diff-context-expanded', {
+          groups,
+          section: this.section!,
+          numLines: this.numLines(),
+          buttonType: type,
+          expandedLines: linesToExpand,
+        });
+      }
+    };
+  }
+
+  private showPartialLinks() {
+    return this.numLines() > PARTIAL_CONTEXT_AMOUNT;
+  }
+
+  /**
+   * Creates a container div with partial (+10) expansion buttons (above and/or below).
+   */
+  private createPartialExpansionButtons() {
+    if (!this.showPartialLinks()) {
+      return undefined;
+    }
+    let aboveButton;
+    let belowButton;
+    if (this.showAbove()) {
+      aboveButton = this.createContextButton(
+        ContextButtonType.ABOVE,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    if (this.showBelow()) {
+      belowButton = this.createContextButton(
+        ContextButtonType.BELOW,
+        PARTIAL_CONTEXT_AMOUNT
+      );
+    }
+    return aboveButton || belowButton
+      ? html` <div class="aboveBelowButtons partialExpansion">
+          ${aboveButton} ${belowButton}
+        </div>`
+      : undefined;
+  }
+
+  /**
+   * Checks if the collapsed section contains unavailable content (skip chunks).
+   */
+  private get partialContent() {
+    return this.contextGroups.some(c => !!c.skip);
+  }
+
+  /**
+   * Creates a container div with block expansion buttons (above and/or below).
+   */
+  private createBlockExpansionButtons() {
+    if (
+      !this.showPartialLinks() ||
+      !this.renderPreferences?.use_block_expansion ||
+      this.partialContent
+    ) {
+      return undefined;
+    }
+    let aboveBlockButton;
+    let belowBlockButton;
+    if (this.showAbove()) {
+      aboveBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_ABOVE,
+        this.numLines(),
+        this.contextRange().rightStart - 1
+      );
+    }
+    if (this.showBelow()) {
+      belowBlockButton = this.createBlockButton(
+        ContextButtonType.BLOCK_BELOW,
+        this.numLines(),
+        this.contextRange().rightEnd + 1
+      );
+    }
+    if (aboveBlockButton || belowBlockButton) {
+      return html` <div class="aboveBelowButtons blockExpansion">
+        ${aboveBlockButton} ${belowBlockButton}
+      </div>`;
+    }
+    return undefined;
+  }
+
+  private createBlockButtonTooltip(
+    buttonType: ContextButtonType,
+    syntaxPath: SyntaxBlock[],
+    linesToExpand: number
+  ) {
+    // Create breadcrumb string:
+    // myNamepace > MyClass > myMethod1 > aLocalFunctionInsideMethod1 > (anonymous)
+    const tooltipText = syntaxPath.length
+      ? syntaxPath.map(b => b.name || '(anonymous)').join(' > ')
+      : `${linesToExpand} common lines`;
+
+    const position =
+      buttonType === ContextButtonType.BLOCK_ABOVE ? 'top' : 'bottom';
+    return html`<paper-tooltip offset="10" position="${position}"
+      ><div class="breadcrumbTooltip">${tooltipText}</div></paper-tooltip
+    >`;
+  }
+
+  private createBlockButton(
+    buttonType: ContextButtonType,
+    numLines: number,
+    referenceLine: number
+  ) {
+    assertIsDefined(this.diff, 'diff');
+    const syntaxTree = this.diff!.meta_b.syntax_tree;
+    const outlineSyntaxPath = findBlockTreePathForLine(
+      referenceLine,
+      syntaxTree
+    );
+    let linesToExpand = numLines;
+    if (outlineSyntaxPath.length) {
+      const {range} = outlineSyntaxPath[outlineSyntaxPath.length - 1];
+      const targetLine =
+        buttonType === ContextButtonType.BLOCK_ABOVE
+          ? range.end_line
+          : range.start_line;
+      const distanceToTargetLine = Math.abs(targetLine - referenceLine);
+      if (distanceToTargetLine < numLines) {
+        linesToExpand = distanceToTargetLine;
+      }
+    }
+    const tooltip = this.createBlockButtonTooltip(
+      buttonType,
+      outlineSyntaxPath,
+      linesToExpand
+    );
+    return this.createContextButton(buttonType, linesToExpand, tooltip);
+  }
+
+  private contextRange() {
+    return {
+      leftStart: this.contextGroups[0].lineRange.left.start_line,
+      leftEnd:
+        this.contextGroups[this.contextGroups.length - 1].lineRange.left
+          .end_line,
+      rightStart: this.contextGroups[0].lineRange.right.start_line,
+      rightEnd:
+        this.contextGroups[this.contextGroups.length - 1].lineRange.right
+          .end_line,
+    };
+  }
+
+  private hasValidProperties() {
+    return !!(this.diff && this.section && this.contextGroups?.length);
+  }
+
+  override render() {
+    if (!this.hasValidProperties()) {
+      console.error('Invalid properties for gr-context-controls!');
+      return html`<p>invalid properties</p>`;
+    }
+    return html`
+      <div class="horizontalFlex">
+        ${this.createExpandAllButtonContainer()}
+        ${this.createPartialExpansionButtons()}
+        ${this.createBlockExpansionButtons()}
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-context-controls': GrContextControls;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
new file mode 100644
index 0000000..cacea42
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-context-controls/gr-context-controls_test.ts
@@ -0,0 +1,378 @@
+/**
+ * @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';
+import '../gr-diff/gr-diff-group';
+import './gr-context-controls';
+import {GrContextControls} from './gr-context-controls';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+import {DiffFileMetaInfo, DiffInfo, SyntaxBlock} from '../../../api/diff';
+
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-context-control tests', () => {
+  let element: GrContextControls;
+
+  setup(async () => {
+    element = document.createElement('gr-context-controls');
+    element.diff = {content: []} as any as DiffInfo;
+    element.renderPreferences = {};
+    element.section = document.createElement('div');
+    blankFixture.instantiate().appendChild(element);
+    await flush();
+  });
+
+  function createContextGroups(options: {offset?: number; count?: number}) {
+    const offset = options.offset || 0;
+    const numLines = options.count || 10;
+    const lines = [];
+    for (let i = 0; i < numLines; i++) {
+      const line = new GrDiffLine(GrDiffLineType.BOTH);
+      line.beforeNumber = offset + i + 1;
+      line.afterNumber = offset + i + 1;
+      line.text = 'lorem upsum';
+      lines.push(line);
+    }
+
+    return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
+  }
+
+  test('no +10 buttons for 10 or less lines', async () => {
+    element.contextGroups = createContextGroups({count: 10});
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+    assert.equal(buttons.length, 1);
+    assert.equal(buttons[0].textContent!.trim(), '+10 common lines');
+  });
+
+  test('context control at the top', async () => {
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'belowButton');
+    assert.include([...buttons[1].classList.values()], 'belowButton');
+  });
+
+  test('context control in the middle', async () => {
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 3);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+    assert.equal(buttons[2].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'centeredButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+    assert.include([...buttons[2].classList.values()], 'belowButton');
+  });
+
+  test('context control at the bottom', async () => {
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await flush();
+
+    const buttons = element.shadowRoot!.querySelectorAll(
+      'paper-button.showContext'
+    );
+
+    assert.equal(buttons.length, 2);
+    assert.equal(buttons[0].textContent!.trim(), '+20 common lines');
+    assert.equal(buttons[1].textContent!.trim(), '+10');
+
+    assert.include([...buttons[0].classList.values()], 'aboveButton');
+    assert.include([...buttons[1].classList.values()], 'aboveButton');
+  });
+
+  function prepareForBlockExpansion(syntaxTree: SyntaxBlock[]) {
+    element.renderPreferences!.use_block_expansion = true;
+    element.diff!.meta_b = {
+      syntax_tree: syntaxTree,
+    } as any as DiffFileMetaInfo;
+  }
+
+  test('context control with block expansion at the top', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 0, count: 20});
+    element.showConfig = 'below';
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion in the middle', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 2);
+    assert.equal(blockExpansionButtons.length, 2);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.equal(
+      blockExpansionButtons[1].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+    assert.include(
+      [...blockExpansionButtons[1].classList.values()],
+      'belowButton'
+    );
+  });
+
+  test('context control with block expansion at the bottom', async () => {
+    prepareForBlockExpansion([]);
+    element.contextGroups = createContextGroups({offset: 30, count: 20});
+    element.showConfig = 'above';
+
+    await flush();
+
+    const fullExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.fullExpansion paper-button'
+    );
+    const partialExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.partialExpansion paper-button'
+    );
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(fullExpansionButtons.length, 1);
+    assert.equal(partialExpansionButtons.length, 1);
+    assert.equal(blockExpansionButtons.length, 1);
+    assert.equal(
+      blockExpansionButtons[0].querySelector('span')!.textContent!.trim(),
+      '+Block'
+    );
+    assert.include(
+      [...blockExpansionButtons[0].classList.values()],
+      'aboveButton'
+    );
+  });
+
+  test('+ Block tooltip tooltip shows syntax block containing the target lines above and below', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificFunction',
+        range: {start_line: 1, start_column: 0, end_line: 25, end_column: 0},
+        children: [],
+      },
+      {
+        name: 'anotherFunction',
+        range: {start_line: 26, start_column: 0, end_line: 50, end_column: 0},
+        children: [],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificFunction'
+    );
+    assert.equal(
+      blockExpansionButtons[1]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'anotherFunction'
+    );
+  });
+
+  test('+Block tooltip shows nested syntax blocks as breadcrumbs', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: 'MyClass',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > MyClass > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows (anonymous) for empty blocks', async () => {
+    prepareForBlockExpansion([
+      {
+        name: 'aSpecificNamespace',
+        range: {start_line: 1, start_column: 0, end_line: 200, end_column: 0},
+        children: [
+          {
+            name: '',
+            range: {
+              start_line: 2,
+              start_column: 0,
+              end_line: 100,
+              end_column: 0,
+            },
+            children: [
+              {
+                name: 'aMethod',
+                range: {
+                  start_line: 5,
+                  start_column: 0,
+                  end_line: 80,
+                  end_column: 0,
+                },
+                children: [],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    assert.equal(
+      blockExpansionButtons[0]
+        .querySelector('.breadcrumbTooltip')!
+        .textContent?.trim(),
+      'aSpecificNamespace > (anonymous) > aMethod'
+    );
+  });
+
+  test('+Block tooltip shows "all common lines" for empty syntax tree', async () => {
+    prepareForBlockExpansion([]);
+
+    element.contextGroups = createContextGroups({offset: 10, count: 20});
+    element.showConfig = 'both';
+    await flush();
+
+    const blockExpansionButtons = element.shadowRoot!.querySelectorAll(
+      '.blockExpansion paper-button'
+    );
+    const tooltipAbove =
+      blockExpansionButtons[0].querySelector('paper-tooltip')!;
+    const tooltipBelow =
+      blockExpansionButtons[1].querySelector('paper-tooltip')!;
+    assert.equal(
+      tooltipAbove.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(
+      tooltipBelow.querySelector('.breadcrumbTooltip')!.textContent?.trim(),
+      '20 common lines'
+    );
+    assert.equal(tooltipAbove!.getAttribute('position'), 'top');
+    assert.equal(tooltipBelow!.getAttribute('position'), 'bottom');
+  });
+});
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 763a524..4186a10 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
@@ -18,6 +18,8 @@
 import {GrDiffBuilderUnified} from './gr-diff-builder-unified';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
 import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line';
+import {queryAndAssert} from '../../../utils/common-util';
+import {RenderPreferences} from '../../../api/diff';
 
 export class GrDiffBuilderBinary extends GrDiffBuilderUnified {
   constructor(
@@ -28,13 +30,15 @@
     super(diff, prefs, outputEl);
   }
 
-  buildSectionElement(): HTMLElement {
+  override buildSectionElement(): HTMLElement {
     const section = this._createElement('tbody', 'binary-diff');
     const line = new GrDiffLine(GrDiffLineType.BOTH, 'FILE', 'FILE');
     const fileRow = this._createRow(line);
-    const contentTd = fileRow.querySelector('td.both.file')!;
+    const contentTd = queryAndAssert(fileRow, 'td.both.file')!;
     contentTd.textContent = ' Difference in binary files';
     section.appendChild(fileRow);
     return section;
   }
+
+  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
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 2777639..720cc27 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
@@ -46,7 +46,7 @@
 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';
+import {getLineNumber, getSideByLineEl} from '../gr-diff/gr-diff-utils';
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 
 const TRAILING_WHITESPACE_PATTERN = /\s+$/;
@@ -191,8 +191,7 @@
   @property({type: Object})
   _cancelableRenderPromise: CancelablePromise<unknown> | null = null;
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     if (this._builder) {
       this._builder.clear();
     }
@@ -241,7 +240,7 @@
     this.$.processor.keyLocations = keyLocations;
 
     this._clearDiffContent();
-    this._builder.addColumns(this.diffElement, prefs.font_size);
+    this._builder.addColumns(this.diffElement, getLineNumberCellWidth(prefs));
 
     const isBinary = !!(this.isImageDiff || this.diff.binary);
 
@@ -286,26 +285,6 @@
     this._layers = layers;
   }
 
-  getLineElByChild(node?: Node): HTMLElement | null {
-    while (node) {
-      if (node instanceof Element) {
-        if (node.classList.contains('lineNum')) {
-          return node as HTMLElement;
-        }
-        if (node.classList.contains('section')) {
-          return null;
-        }
-      }
-      node = node.previousSibling ?? node.parentElement ?? undefined;
-    }
-    return null;
-  }
-
-  getLineNumberByChild(node: Node) {
-    const lineEl = this.getLineElByChild(node);
-    return getLineNumber(lineEl);
-  }
-
   getContentTdByLine(lineNumber: LineNumber, side?: Side, root?: Element) {
     if (!this._builder) return null;
     return this._builder.getContentTdByLine(lineNumber, side, root);
@@ -322,7 +301,7 @@
     if (!lineEl) return null;
     const line = getLineNumber(lineEl);
     if (!line) return null;
-    const side = this.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     // Performance optimization because we already have an element in the
     // correct row
     const row = this._getDiffRowByChild(lineEl);
@@ -336,10 +315,6 @@
     );
   }
 
-  getSideByLineEl(lineEl: Element) {
-    return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
-  }
-
   emitGroup(group: GrDiffGroup, sectionEl: HTMLElement) {
     if (!this._builder) return;
     this._builder.emitGroup(group, sectionEl);
@@ -549,6 +524,11 @@
     if (!this._builder) return;
     this._builder.setBlame(blame);
   }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    this._builder?.updateRenderPrefs(renderPrefs);
+    this.$.processor.updateRenderPrefs(renderPrefs);
+  }
 }
 
 declare global {
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 7c01a95..44b0b8b 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,6 +18,7 @@
 import '../../../test/common-test-setup-karma.js';
 import '../gr-diff/gr-diff-group.js';
 import './gr-diff-builder.js';
+import '../gr-context-controls/gr-context-controls.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import './gr-diff-builder-element.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
@@ -52,7 +53,8 @@
   let element;
   let builder;
 
-  const LINE_FEED_HTML = '<span class="style-scope gr-diff br"></span>';
+  const LINE_BREAK_HTML = '<span class="style-scope gr-diff br"></span>';
+  const WBR_HTML = '<wbr class="style-scope gr-diff">';
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -74,139 +76,63 @@
     assert.isTrue(node.classList.contains('classes'));
   });
 
-  suite('context control', () => {
-    function createContextGroups(options) {
-      const offset = options.offset || 0;
-      const numLines = options.count || 10;
-      const lines = [];
-      for (let i = 0; i < numLines; i++) {
-        const line = new GrDiffLine(GrDiffLineType.BOTH);
-        line.beforeNumber = offset + i + 1;
-        line.afterNumber = offset + i + 1;
-        line.text = 'lorem upsum';
-        lines.push(line);
-      }
-
-      return [new GrDiffGroup(GrDiffGroupType.BOTH, lines)];
-    }
-
-    function createContextSectionForGroups(options) {
-      const section = document.createElement('div');
-      builder._createContextControls(
-          section, createContextGroups(options), DiffViewMode.UNIFIED);
-      return section;
-    }
-
-    setup(() => {
-      builder = new GrDiffBuilder({content: []}, prefs, null, []);
-    });
-
-    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');
-    });
-
-    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.include([...buttons[0].classList.values()], 'belowButton');
-      assert.include([...buttons[1].classList.values()], 'belowButton');
-    });
-
-    test('context control in the middle', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 10, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 3);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-      assert.equal(buttons[2].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'centeredButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-      assert.include([...buttons[2].classList.values()], 'belowButton');
-    });
-
-    test('context control at the bottom', () => {
-      builder._numLinesLeft = 50;
-      const section = createContextSectionForGroups({offset: 30, count: 20});
-      const buttons = section.querySelectorAll('gr-button.showContext');
-
-      assert.equal(buttons.length, 2);
-      assert.equal(buttons[0].textContent, '+20 common lines');
-      assert.equal(buttons[1].textContent, '+10');
-
-      assert.include([...buttons[0].classList.values()], 'aboveButton');
-      assert.include([...buttons[1].classList.values()], 'aboveButton');
-    });
-  });
-
   test('newlines 1', () => {
     let text = 'abcdef';
 
-    assert.equal(builder._formatText(text, 4, 10).innerHTML, text);
+    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML, text);
     text = 'a'.repeat(20);
-    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
         'a'.repeat(10) +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         'a'.repeat(10));
   });
 
   test('newlines 2', () => {
     const text = '<span class="thumbsup">👍</span>';
-    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
         '&lt;span clas' +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         's="thumbsu' +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         'p"&gt;👍&lt;/span' +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         '&gt;');
   });
 
   test('newlines 3', () => {
     const text = '01234\t56789';
-    assert.equal(builder._formatText(text, 4, 10).innerHTML,
+    assert.equal(builder._formatText(text, 'NONE', 4, 10).innerHTML,
         '01234' + builder._getTabWrapper(3).outerHTML + '56' +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         '789');
   });
 
   test('newlines 4', () => {
     const text = '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍';
-    assert.equal(builder._formatText(text, 4, 20).innerHTML,
+    assert.equal(builder._formatText(text, 'NONE', 4, 20).innerHTML,
         '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍' +
-        LINE_FEED_HTML +
+        LINE_BREAK_HTML +
         '👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍👍');
   });
 
-  test('line_length ignored if line_wrapping is true', () => {
+  test('line_length applied with <wbr> if line_wrapping is true', () => {
     builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
     const text = 'a'.repeat(51);
 
     const line = {text, highlights: []};
+    const expected = 'a'.repeat(50) + WBR_HTML + 'a';
     const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
-    assert.equal(result, text);
+    assert.equal(result, expected);
   });
 
-  test('line_length applied if line_wrapping is false', () => {
+  test('line_length applied with line break if line_wrapping is false', () => {
     builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
     const text = 'a'.repeat(51);
 
     const line = {text, highlights: []};
-    const expected = 'a'.repeat(50) + LINE_FEED_HTML + 'a';
+    const expected = 'a'.repeat(50) + LINE_BREAK_HTML + 'a';
     const result = builder._createTextEl(undefined, line).firstChild.innerHTML;
     assert.equal(result, expected);
   });
@@ -249,22 +175,25 @@
   test('text length with tabs and unicode', () => {
     function expectTextLength(text, tabSize, expected) {
       // Formatting to |expected| columns should not introduce line breaks.
-      const result = builder._formatText(text, tabSize, expected);
+      const result = builder._formatText(text, 'NONE', tabSize, expected);
       assert.isNotOk(result.querySelector('.contentText > .br'),
           `  Expected the result of: \n` +
-          `      _formatText(${text}', ${tabSize}, ${expected})\n` +
+          `      _formatText(${text}', 'NONE',  ${tabSize}, ${expected})\n` +
           `  to not contain a br. But the actual result HTML was:\n` +
           `      '${result.innerHTML}'\nwhereupon`);
 
       // Increasing the line limit should produce the same markup.
-      assert.equal(builder._formatText(text, tabSize, Infinity).innerHTML,
+      assert.equal(
+          builder._formatText(text, 'NONE', tabSize, Infinity).innerHTML,
           result.innerHTML);
-      assert.equal(builder._formatText(text, tabSize, expected + 1).innerHTML,
+      assert.equal(
+          builder._formatText(text, 'NONE', tabSize, expected + 1).innerHTML,
           result.innerHTML);
 
       // Decreasing the line limit should introduce line breaks.
       if (expected > 0) {
-        const tooSmall = builder._formatText(text, tabSize, expected - 1);
+        const tooSmall = builder._formatText(text,
+            'NONE', tabSize, expected - 1);
         assert.isOk(tooSmall.querySelector('.contentText > .br'),
             `  Expected the result of: \n` +
             `      _formatText(${text}', ${tabSize}, ${expected - 1})\n` +
@@ -292,7 +221,7 @@
     assert.ok(wrapper);
     assert.equal(wrapper.innerText, '\t');
     assert.equal(
-        builder._formatText(html, tabSize, Infinity).innerHTML,
+        builder._formatText(html, 'NONE', tabSize, Infinity).innerHTML,
         'abc' + wrapper.outerHTML + 'def');
   });
 
@@ -822,7 +751,7 @@
     let outputEl;
     let keyLocations;
 
-    setup(done => {
+    setup(async () => {
       const prefs = {
         line_length: 10,
         show_tabs: true,
@@ -857,11 +786,11 @@
         return builder;
       });
       element.diff = {content};
-      element.render(keyLocations, prefs).then(done);
+      await element.render(keyLocations, prefs);
     });
 
-    test('addColumns is called', done => {
-      element.render(keyLocations, {}).then(done);
+    test('addColumns is called', async () => {
+      await element.render(keyLocations, {});
       assert.isTrue(element._builder.addColumns.called);
     });
 
@@ -883,15 +812,13 @@
       assert.strictEqual(sections[1], section[1]);
     });
 
-    test('render-start and render-content are fired', done => {
+    test('render-start and render-content are fired', async () => {
       const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-      element.render(keyLocations, {}).then(() => {
-        const firedEventTypes = dispatchEventStub.getCalls()
-            .map(c => c.args[0].type);
-        assert.include(firedEventTypes, 'render-start');
-        assert.include(firedEventTypes, 'render-content');
-        done();
-      });
+      await element.render(keyLocations, {});
+      const firedEventTypes = dispatchEventStub.getCalls()
+          .map(c => c.args[0].type);
+      assert.include(firedEventTypes, 'render-start');
+      assert.include(firedEventTypes, 'render-content');
     });
 
     test('cancel', () => {
@@ -908,7 +835,7 @@
     let prefs;
     let keyLocations;
 
-    setup(done => {
+    setup(async () => {
       element = mockDiffFixture.instantiate();
       diff = getMockDiffResponse();
       element.diff = diff;
@@ -920,10 +847,8 @@
       };
       keyLocations = {left: {}, right: {}};
 
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
-        done();
-      });
+      await element.render(keyLocations, prefs);
+      builder = element._builder;
     });
 
     test('aria-labels on added line numbers', () => {
@@ -1057,34 +982,30 @@
       assert.isTrue(lineNumberEl.classList.contains('right'));
     });
 
-    test('_getLineNumberEl unified left', done => {
+    test('_getLineNumberEl unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
+      await element.render(keyLocations, prefs);
+      builder = element._builder;
 
-        const contentEl = builder.getContentByLine(5, 'left',
-            element.$.diffTable);
-        const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
-        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl.classList.contains('left'));
-        done();
-      });
+      const contentEl = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'left');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('left'));
     });
 
-    test('_getLineNumberEl unified right', done => {
+    test('_getLineNumberEl unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
+      await element.render(keyLocations, prefs);
+      builder = element._builder;
 
-        const contentEl = builder.getContentByLine(5, 'right',
-            element.$.diffTable);
-        const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
-        assert.isTrue(lineNumberEl.classList.contains('lineNum'));
-        assert.isTrue(lineNumberEl.classList.contains('right'));
-        done();
-      });
+      const contentEl = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const lineNumberEl = builder._getLineNumberEl(contentEl, 'right');
+      assert.isTrue(lineNumberEl.classList.contains('lineNum'));
+      assert.isTrue(lineNumberEl.classList.contains('right'));
     });
 
     test('_getNextContentOnSide side-by-side left', () => {
@@ -1111,44 +1032,38 @@
       assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide unified left', done => {
+    test('_getNextContentOnSide unified left', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
+      await element.render(keyLocations, prefs);
+      builder = element._builder;
 
-        const startElem = builder.getContentByLine(5, 'left',
-            element.$.diffTable);
-        const expectedStartString = diff.content[2].ab[0];
-        const expectedNextString = diff.content[2].ab[1];
-        assert.equal(startElem.textContent, expectedStartString);
+      const startElem = builder.getContentByLine(5, 'left',
+          element.$.diffTable);
+      const expectedStartString = diff.content[2].ab[0];
+      const expectedNextString = diff.content[2].ab[1];
+      assert.equal(startElem.textContent, expectedStartString);
 
-        const nextElem = builder._getNextContentOnSide(startElem,
-            'left');
-        assert.equal(nextElem.textContent, expectedNextString);
-
-        done();
-      });
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'left');
+      assert.equal(nextElem.textContent, expectedNextString);
     });
 
-    test('_getNextContentOnSide unified right', done => {
+    test('_getNextContentOnSide unified right', async () => {
       // Re-render as unified:
       element.viewMode = 'UNIFIED_DIFF';
-      element.render(keyLocations, prefs).then(() => {
-        builder = element._builder;
+      await element.render(keyLocations, prefs);
+      builder = element._builder;
 
-        const startElem = builder.getContentByLine(5, 'right',
-            element.$.diffTable);
-        const expectedStartString = diff.content[1].b[0];
-        const expectedNextString = diff.content[1].b[1];
-        assert.equal(startElem.textContent, expectedStartString);
+      const startElem = builder.getContentByLine(5, 'right',
+          element.$.diffTable);
+      const expectedStartString = diff.content[1].b[0];
+      const expectedNextString = diff.content[1].b[1];
+      assert.equal(startElem.textContent, expectedStartString);
 
-        const nextElem = builder._getNextContentOnSide(startElem,
-            'right');
-        assert.equal(nextElem.textContent, expectedNextString);
-
-        done();
-      });
+      const nextElem = builder._getNextContentOnSide(startElem,
+          'right');
+      assert.equal(nextElem.textContent, expectedNextString);
     });
 
     test('escaping HTML', () => {
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 650a9e7..5629aa4 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
@@ -96,6 +96,8 @@
 
     imageViewer.baseUrl = this._getImageSrc(this._baseImage);
     imageViewer.revisionUrl = this._getImageSrc(this._revisionImage);
+    imageViewer.automaticBlink =
+      !!this._renderPrefs?.image_diff_prefs?.automatic_blink;
 
     td.appendChild(imageViewer);
     tr.appendChild(td);
@@ -213,6 +215,16 @@
 
     section.appendChild(tr);
   }
+
+  override updateRenderPrefs(renderPrefs: RenderPreferences) {
+    const imageViewer = this._outputEl.querySelector(
+      'gr-image-viewer'
+    ) as GrImageViewer;
+    if (this._useNewImageDiffUi && imageViewer) {
+      imageViewer.automaticBlink =
+        !!renderPrefs?.image_diff_prefs?.automatic_blink;
+    }
+  }
 }
 
 function _getImageLabel(image: ImageInfo | null) {
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 f44e006..bd7dc29 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
@@ -28,7 +28,7 @@
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = [],
+    layers: DiffLayer[] = [],
     renderPrefs?: RenderPreferences
   ) {
     super(diff, prefs, outputEl, layers, renderPrefs);
@@ -39,6 +39,7 @@
       numberOfCells: 4,
       movedOutIndex: 1,
       movedInIndex: 3,
+      lineNumberCols: [0, 2],
     };
   }
 
@@ -74,8 +75,7 @@
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, fontSize: number): void {
-    const width = fontSize * 4;
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -84,7 +84,7 @@
 
     // Add left-side line number.
     col = this._createElement('col', 'left');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add left-side content.
@@ -92,7 +92,7 @@
 
     // Add right-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side content.
@@ -137,4 +137,6 @@
     }
     return null;
   }
+
+  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
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 e927fdf..a7b3a42 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
@@ -27,7 +27,7 @@
     diff: DiffInfo,
     prefs: DiffPreferencesInfo,
     outputEl: HTMLElement,
-    readonly layers: DiffLayer[] = [],
+    layers: DiffLayer[] = [],
     renderPrefs?: RenderPreferences
   ) {
     super(diff, prefs, outputEl, layers, renderPrefs);
@@ -38,6 +38,7 @@
       numberOfCells: 3,
       movedOutIndex: 2,
       movedInIndex: 2,
+      lineNumberCols: [0, 1],
     };
   }
 
@@ -78,8 +79,7 @@
     return sectionEl;
   }
 
-  addColumns(outputEl: HTMLElement, fontSize: number): void {
-    const width = fontSize * 4;
+  addColumns(outputEl: HTMLElement, lineNumberWidth: number): void {
     const colgroup = document.createElement('colgroup');
 
     // Add the blame column.
@@ -88,12 +88,12 @@
 
     // Add left-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add right-side line number.
     col = document.createElement('col');
-    col.setAttribute('width', width.toString());
+    col.setAttribute('width', lineNumberWidth.toString());
     colgroup.appendChild(col);
 
     // Add the content.
@@ -122,7 +122,14 @@
       Side.RIGHT
     );
     row.appendChild(lineNumberEl);
-    row.appendChild(this._createTextEl(lineNumberEl, line));
+    let side = undefined;
+    if (line.type === GrDiffLineType.ADD || line.type === GrDiffLineType.BOTH) {
+      side = Side.RIGHT;
+    }
+    if (line.type === GrDiffLineType.REMOVE) {
+      side = Side.LEFT;
+    }
+    row.appendChild(this._createTextEl(lineNumberEl, line, side));
     return row;
   }
 
@@ -139,4 +146,6 @@
     }
     return null;
   }
+
+  override updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
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 c08764e..2be85c3 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
@@ -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('moveLabel'));
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
       assert.equal(cells[2].textContent, 'Moved out');
     });
 
@@ -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('moveLabel'));
+      assert.isTrue(cells[2].classList.contains('moveHeader'));
       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 eb943f7..663ee7e 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
@@ -16,22 +16,28 @@
  */
 import {
   ContentLoadNeededEventDetail,
+  DiffContextExpandedExternalDetail,
   MovedLinkClickedEventDetail,
   RenderPreferences,
 } from '../../../api/diff';
 import {getBaseUrl} from '../../../utils/url-util';
+import {fire} from '../../../utils/event-util';
 import {GrDiffLine, GrDiffLineType, LineNumber} from '../gr-diff/gr-diff-line';
+import {GrDiffGroup, GrDiffGroupType} from '../gr-diff/gr-diff-group';
+
+import '../gr-context-controls/gr-context-controls';
 import {
-  GrDiffGroup,
-  GrDiffGroupType,
-  hideInContextControl,
-} from '../gr-diff/gr-diff-group';
+  GrContextControls,
+  GrContextControlsShowConfig,
+} from '../gr-context-controls/gr-context-controls';
 import {BlameInfo} from '../../../types/common';
-import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
+import {
+  DiffInfo,
+  DiffPreferencesInfo,
+  DiffResponsiveMode,
+} from '../../../types/diff';
 import {DiffViewMode, Side} from '../../../constants/constants';
 import {DiffLayer} from '../../../types/types';
-import {pluralize} from '../../../utils/string-util';
-import {fire} from '../../../utils/event-util';
 
 /**
  * In JS, unicode code points above 0xFFFF occupy two elements of a string.
@@ -55,15 +61,8 @@
  */
 const REGEX_TAB_OR_SURROGATE_PAIR = /\t|[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-const PARTIAL_CONTEXT_AMOUNT = 10;
-
-enum ContextButtonType {
-  ABOVE = 'above',
-  BELOW = 'below',
-  ALL = 'all',
-}
-
-export interface DiffContextExpandedEventDetail {
+export interface DiffContextExpandedEventDetail
+  extends DiffContextExpandedExternalDetail {
   groups: GrDiffGroup[];
   section: HTMLElement;
   numLines: number;
@@ -76,6 +75,26 @@
   }
 }
 
+export function getResponsiveMode(
+  prefs: DiffPreferencesInfo,
+  renderPrefs?: RenderPreferences
+): DiffResponsiveMode {
+  if (renderPrefs?.responsive_mode) {
+    return renderPrefs.responsive_mode;
+  }
+  // Backwards compatibility to the line_wrapping param.
+  if (prefs.line_wrapping) {
+    return 'FULL_RESPONSIVE';
+  }
+  return 'NONE';
+}
+
+export function isResponsive(responsiveMode: DiffResponsiveMode) {
+  return (
+    responsiveMode === 'FULL_RESPONSIVE' || responsiveMode === 'SHRINK_ONLY'
+  );
+}
+
 export abstract class GrDiffBuilder {
   private readonly _diff: DiffInfo;
 
@@ -83,7 +102,7 @@
 
   private readonly _prefs: DiffPreferencesInfo;
 
-  private readonly _renderPrefs?: RenderPreferences;
+  protected readonly _renderPrefs?: RenderPreferences;
 
   protected readonly _outputEl: HTMLElement;
 
@@ -158,13 +177,6 @@
     REMOVED: 'edit_a',
   };
 
-  // TODO(TS): Replace usages with ContextButtonType enum.
-  static readonly ContextButtonType = {
-    ABOVE: 'above',
-    BELOW: 'below',
-    ALL: 'all',
-  };
-
   abstract addColumns(outputEl: HTMLElement, fontSize: number): void;
 
   abstract buildSectionElement(group: GrDiffGroup): HTMLElement;
@@ -324,14 +336,12 @@
     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');
-
     const firstGroupIsSkipped = !!contextGroups[0].skip;
     const lastGroupIsSkipped = !!contextGroups[contextGroups.length - 1].skip;
 
-    const showAbove = leftStart > 1 && !firstGroupIsSkipped;
+    const containsWholeFile = this._numLinesLeft === leftEnd - leftStart + 1;
+    const showAbove =
+      (leftStart > 1 && !firstGroupIsSkipped) || containsWholeFile;
     const showBelow = leftEnd < this._numLinesLeft && !lastGroupIsSkipped;
 
     if (showAbove) {
@@ -345,7 +355,6 @@
         contextGroups,
         showAbove,
         showBelow,
-        numLines,
         viewMode
       )
     );
@@ -365,20 +374,21 @@
     contextGroups: GrDiffGroup[],
     showAbove: boolean,
     showBelow: boolean,
-    numLines: number,
     viewMode: DiffViewMode
   ): HTMLElement {
     const row = this._createElement('tr', 'dividerRow');
+    let showConfig: GrContextControlsShowConfig;
     if (showAbove && !showBelow) {
-      row.classList.add('showAboveOnly');
+      showConfig = 'above';
     } else if (!showAbove && showBelow) {
-      row.classList.add('showBelowOnly');
+      showConfig = 'below';
     } else {
       // Note that !showAbove && !showBelow also intentionally creates
-      // "showBoth". This means the file is completely collapsed, which is
+      // "show-both". This means the file is completely collapsed, which is
       // unusual, but at least happens in one test.
-      row.classList.add('showBoth');
+      showConfig = 'both';
     }
+    row.classList.add(`show-${showConfig}`);
 
     row.appendChild(this._createBlameCell(0));
     if (viewMode === DiffViewMode.SIDE_BY_SIDE) {
@@ -388,54 +398,16 @@
     const cell = this._createElement('td', 'dividerCell');
     cell.setAttribute('colspan', '3');
     row.appendChild(cell);
-    const verticalFlex = this._createElement('div', 'verticalFlex');
-    cell.appendChild(verticalFlex);
-    const horizontalFlex = this._createElement('div', 'horizontalFlex');
-    verticalFlex.appendChild(horizontalFlex);
 
-    const showAllContainer = this._createElement('div', 'aboveBelowButtons');
-    horizontalFlex.appendChild(showAllContainer);
-    const showAllButton = this._createContextButton(
-      ContextButtonType.ALL,
-      section,
-      contextGroups,
-      numLines
-    );
-    showAllButton.classList.add(
-      showAbove && showBelow
-        ? 'centeredButton'
-        : showAbove
-        ? 'aboveButton'
-        : 'belowButton'
-    );
-    showAllContainer.appendChild(showAllButton);
-
-    const showPartialLinks = numLines > PARTIAL_CONTEXT_AMOUNT;
-    if (showPartialLinks) {
-      const container = this._createElement('div', 'aboveBelowButtons');
-      if (showAbove) {
-        container.appendChild(
-          this._createContextButton(
-            ContextButtonType.ABOVE,
-            section,
-            contextGroups,
-            numLines
-          )
-        );
-      }
-      if (showBelow) {
-        container.appendChild(
-          this._createContextButton(
-            ContextButtonType.BELOW,
-            section,
-            contextGroups,
-            numLines
-          )
-        );
-      }
-      horizontalFlex.appendChild(container);
-    }
-
+    const contextControls = this._createElement(
+      'gr-context-controls'
+    ) as GrContextControls;
+    contextControls.diff = this._diff;
+    contextControls.renderPreferences = this._renderPrefs;
+    contextControls.section = section;
+    contextControls.contextGroups = contextGroups;
+    contextControls.showConfig = showConfig;
+    cell.appendChild(contextControls);
     return row;
   }
 
@@ -467,87 +439,6 @@
     return row;
   }
 
-  _createContextButton(
-    type: ContextButtonType,
-    section: HTMLElement,
-    contextGroups: GrDiffGroup[],
-    numLines: number
-  ) {
-    const context = PARTIAL_CONTEXT_AMOUNT;
-    const button = this._createElement('gr-button', 'showContext');
-    button.classList.add('contextControlButton');
-    button.setAttribute('link', 'true');
-    button.setAttribute('no-uppercase', 'true');
-
-    let text = '';
-    let groups: GrDiffGroup[] = []; // The groups that replace this one if tapped.
-    let requiresLoad = false;
-    if (type === GrDiffBuilder.ContextButtonType.ALL) {
-      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
-        text += ' (too large)';
-      }
-      groups.push(...contextGroups);
-    } else if (type === GrDiffBuilder.ContextButtonType.ABOVE) {
-      groups = hideInContextControl(contextGroups, context, numLines);
-      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);
-      text = `+${context}`;
-      button.classList.add('belowButton');
-      button.setAttribute(
-        'aria-label',
-        `Show ${pluralize(context, 'line')} below`
-      );
-    }
-    const textSpan = this._createElement('span', 'showContext');
-    textSpan.textContent = text;
-    button.appendChild(textSpan);
-
-    if (requiresLoad) {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        const firstRange = groups[0].lineRange;
-        const lastRange = groups[groups.length - 1].lineRange;
-        const lineRange = {
-          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,
-          },
-        };
-        fire(button, 'content-load-needed', {
-          lineRange,
-        });
-      });
-    } else {
-      button.addEventListener('click', e => {
-        e.stopPropagation();
-        fire(button, 'diff-context-expanded', {
-          groups,
-          section,
-          numLines,
-        });
-      });
-    }
-
-    return button;
-  }
-
   _createLineEl(
     line: GrDiffLine,
     number: LineNumber,
@@ -594,11 +485,20 @@
           button.setAttribute('aria-label', `${number} added`);
         }
       }
+      this._addLineNumberMouseEvents(td, number, side);
     }
-
     return td;
   }
 
+  _addLineNumberMouseEvents(el: HTMLElement, number: LineNumber, side: Side) {
+    el.addEventListener('mouseenter', () => {
+      fire(el, 'line-mouse-enter', {lineNum: number, side});
+    });
+    el.addEventListener('mouseleave', () => {
+      fire(el, 'line-mouse-leave', {lineNum: number, side});
+    });
+  }
+
   _createTextEl(
     lineNumberEl: HTMLElement | null,
     line: GrDiffLine,
@@ -616,28 +516,30 @@
     }
     td.classList.add(line.type);
 
-    if (line.beforeNumber !== 'FILE' && line.beforeNumber !== 'LOST') {
-      const lineLimit = !this._prefs.line_wrapping
-        ? this._prefs.line_length
-        : Infinity;
+    const {beforeNumber, afterNumber} = line;
+    if (beforeNumber !== 'FILE' && beforeNumber !== 'LOST') {
+      const responsiveMode = getResponsiveMode(this._prefs, this._renderPrefs);
       const contentText = this._formatText(
         line.text,
+        responsiveMode,
         this._prefs.tab_size,
-        lineLimit
+        this._prefs.line_length
       );
 
       if (side) {
         contentText.setAttribute('data-side', side);
+        const number = side === Side.LEFT ? beforeNumber : afterNumber;
+        this._addLineNumberMouseEvents(td, number, side);
       }
 
-      if (lineNumberEl) {
+      if (lineNumberEl && side) {
         for (const layer of this.layers) {
           if (typeof layer.annotate === 'function') {
-            layer.annotate(contentText, lineNumberEl, line);
+            layer.annotate(contentText, lineNumberEl, line, side);
           }
         }
       } else {
-        console.error('The lineNumberEl is null, skipping layer annotations.');
+        console.error('lineNumberEl or side not set, skipping layer.annotate');
       }
 
       td.appendChild(contentText);
@@ -647,6 +549,12 @@
     return td;
   }
 
+  private createLineBreak(responsive: boolean) {
+    return responsive
+      ? this._createElement('wbr')
+      : this._createElement('span', 'br');
+  }
+
   /**
    * Returns a 'div' element containing the supplied |text| as its innerText,
    * with '\t' characters expanded to a width determined by |tabSize|, and the
@@ -657,9 +565,15 @@
    * @param tabSize The width of each tab stop.
    * @param lineLimit The column after which to wrap lines.
    */
-  _formatText(text: string, tabSize: number, lineLimit: number): HTMLElement {
+  _formatText(
+    text: string,
+    responsiveMode: DiffResponsiveMode,
+    tabSize: number,
+    lineLimit: number
+  ): HTMLElement {
     const contentText = this._createElement('div', 'contentText');
-
+    contentText.ariaLabel = text;
+    const responsive = isResponsive(responsiveMode);
     let columnPos = 0;
     let textOffset = 0;
     for (const segment of text.split(REGEX_TAB_OR_SURROGATE_PAIR)) {
@@ -673,7 +587,7 @@
           contentText.appendChild(
             document.createTextNode(segment.substring(rowStart, rowEnd))
           );
-          contentText.appendChild(this._createElement('span', 'br'));
+          contentText.appendChild(this.createLineBreak(responsive));
           columnPos = 0;
           rowStart = rowEnd;
           rowEnd += lineLimit;
@@ -691,7 +605,7 @@
           // Append a single '\t' character.
           let effectiveTabSize = tabSize - (columnPos % tabSize);
           if (columnPos + effectiveTabSize > lineLimit) {
-            contentText.appendChild(this._createElement('span', 'br'));
+            contentText.appendChild(this.createLineBreak(responsive));
             columnPos = 0;
             effectiveTabSize = tabSize;
           }
@@ -701,7 +615,7 @@
         } else {
           // Append a single surrogate pair.
           if (columnPos >= lineLimit) {
-            contentText.appendChild(this._createElement('span', 'br'));
+            contentText.appendChild(this.createLineBreak(responsive));
             columnPos = 0;
           }
           contentText.appendChild(
@@ -770,6 +684,7 @@
     numberOfCells: number;
     movedOutIndex: number;
     movedInIndex: number;
+    lineNumberCols: number[];
   };
 
   /**
@@ -863,11 +778,8 @@
 
   _buildMoveControls(group: GrDiffGroup) {
     const movedIn = group.adds.length > 0;
-    const {
-      numberOfCells,
-      movedOutIndex,
-      movedInIndex,
-    } = this._getMoveControlsConfig();
+    const {numberOfCells, movedOutIndex, movedInIndex, lineNumberCols} =
+      this._getMoveControlsConfig();
 
     let controlsClass;
     let descriptionIndex;
@@ -884,13 +796,15 @@
     const cells = [...Array(numberOfCells).keys()].map(() =>
       this._createElement('td')
     );
-    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');
+    lineNumberCols.forEach(index => {
+      cells[index].classList.add('moveControlsLineNumCol');
+    });
+
+    const moveRangeHeader = this._createElement('gr-range-header');
+    moveRangeHeader.setAttribute('icon', 'gr-icons:move-item');
+    moveRangeHeader.appendChild(descriptionTextDiv);
+    cells[descriptionIndex].classList.add('moveHeader');
+    cells[descriptionIndex].appendChild(moveRangeHeader);
     cells.forEach(c => {
       controls.appendChild(c);
     });
@@ -1005,4 +919,6 @@
     while (row && !row.classList.contains('diff-row')) row = row.parentElement;
     return row ? (row.querySelector('.lineNum.' + side) as HTMLElement) : null;
   }
+
+  updateRenderPrefs(_renderPrefs: RenderPreferences) {}
 }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
new file mode 100644
index 0000000..de7d007
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer.ts
@@ -0,0 +1,360 @@
+/**
+ * @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 {DiffLayer, DiffLayerListener} from '../../../types/types';
+import {GrDiffLine, Side, TokenHighlightListener} from '../../../api/diff';
+import {assertIsDefined} from '../../../utils/common-util';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {debounce, DelayedTask} from '../../../utils/async-util';
+
+import {
+  getLineElByChild,
+  getSideByLineEl,
+  getPreviousContentNodes,
+} from '../gr-diff/gr-diff-utils';
+
+import {
+  getLineNumberByChild,
+  lineNumberToNumber,
+} from '../gr-diff/gr-diff-utils';
+
+const tokenMatcher = new RegExp(/[\w]+/g);
+
+/** CSS class for all tokens. */
+const CSS_TOKEN = 'token';
+
+/** CSS class for the currently hovered token. */
+const CSS_HIGHLIGHT = 'token-highlight';
+
+export const HOVER_DELAY_MS = 200;
+
+const LINE_LENGTH_LIMIT = 500;
+
+const TOKEN_LENGTH_LIMIT = 100;
+
+const TOKEN_COUNT_LIMIT = 10000;
+
+const TOKEN_OCCURRENCES_LIMIT = 1000;
+
+/**
+ * Token highlighting is only useful for code on-screen, so we only highlight
+ * the nearest set of tokens up to this limit.
+ */
+const TOKEN_HIGHLIGHT_LIMIT = 100;
+
+/**
+ * When a user hovers over a token in the diff, then this layer makes sure that
+ * all occurrences of this token are annotated with the 'token-highlight' css
+ * class. And removes that class when the user moves the mouse away from the
+ * token.
+ *
+ * The layer does not react to mouse events directly by adding a css class to
+ * the appropriate elements, but instead it just sets the currently highlighted
+ * token and notifies the diff renderer that certain lines must be re-rendered.
+ * And when that re-rendering happens the appropriate css class is added.
+ */
+export class TokenHighlightLayer implements DiffLayer {
+  /** The only listener is typically the renderer of gr-diff. */
+  private listeners: DiffLayerListener[] = [];
+
+  /** The currently highlighted token. */
+  private currentHighlight?: string;
+
+  /** Trigger when a new token starts or stoped being highlighted.*/
+  private readonly tokenHighlightListener?: TokenHighlightListener;
+
+  /**
+   * The line of the currently highlighted token. We store this in order to
+   * re-render only relevant lines of the diff. Only lines visible on the screen
+   * need a highlight. For example in a file with 10,000 lines it is sufficient
+   * to just re-render the ~100 lines that are visible to the user.
+   *
+   * It is a known issue that we are only storing the line number on the side of
+   * where the user is hovering and we use that also to determine which line
+   * numbers to re-render on the other side, but it is non-trivial to look up or
+   * store a reliable mapping of line numbers, so we just accept this
+   * shortcoming with the reasoning that the user is mostly interested in the
+   * tokens on the side where they are hovering anyway.
+   *
+   * Another known issue is that we are not able to see past collapsed lines
+   * with the current implementation.
+   */
+  private currentHighlightLineNumber = 0;
+
+  /**
+   * Keeps track of where tokens occur in a file during rendering, so that it is
+   * easy to look up when processing mouse events.
+   */
+  private tokenToLinesLeft = new Map<string, Set<number>>();
+
+  private tokenToLinesRight = new Map<string, Set<number>>();
+
+  private hoveredElement?: Element;
+
+  private updateTokenTask?: DelayedTask;
+
+  constructor(
+    container: HTMLElement = document.documentElement,
+    tokenHighlightListener?: TokenHighlightListener
+  ) {
+    this.tokenHighlightListener = tokenHighlightListener;
+    container.addEventListener('click', e => {
+      this.handleContainerClick(e);
+    });
+  }
+
+  annotate(
+    el: HTMLElement,
+    _: HTMLElement,
+    line: GrDiffLine,
+    side: Side
+  ): void {
+    const text = el.textContent;
+    if (!text) return;
+    // Binary files encoded as text for example can have super long lines
+    // with super long tokens. Let's guard against against this scenario.
+    if (text.length > LINE_LENGTH_LIMIT) return;
+    let match;
+    let atLeastOneTokenMatched = false;
+    while ((match = tokenMatcher.exec(text))) {
+      const token = match[0];
+      const index = match.index;
+      const length = token.length;
+      // Binary files encoded as text for example can have super long lines
+      // with super long tokens. Let's guard against this scenario.
+      if (length > TOKEN_LENGTH_LIMIT) continue;
+      atLeastOneTokenMatched = true;
+      const css = token === this.currentHighlight ? CSS_HIGHLIGHT : CSS_TOKEN;
+      // We add the tk-* class so that we can look up the token later easily
+      // even if the token element was split up into multiple smaller nodes.
+      GrAnnotation.annotateElement(el, index, length, `tk-${token} ${css}`);
+      // We could try to detect whether we are re-rendering instead of initially
+      // rendering the line. Then we would not have to call storeLineForToken()
+      // again. But since the Set swallows the duplicates we don't care.
+      this.storeLineForToken(token, line, side);
+    }
+    if (atLeastOneTokenMatched) {
+      // These listeners do not have to be cleaned, because listeners are
+      // garbage collected along with the element itself once it is not attached
+      // to the DOM anymore and no references exist anymore.
+      el.addEventListener('mouseover', e => {
+        this.handleTokenMouseOver(e);
+      });
+      el.addEventListener('mouseout', e => {
+        this.handleTokenMouseOut(e);
+      });
+    }
+  }
+
+  private storeLineForToken(token: string, line: GrDiffLine, side: Side) {
+    const tokenToLines =
+      side === Side.LEFT ? this.tokenToLinesLeft : this.tokenToLinesRight;
+    // Just to make sure that we don't break down on large files.
+    if (tokenToLines.size > TOKEN_COUNT_LIMIT) return;
+    let numbers = tokenToLines.get(token);
+    if (!numbers) {
+      numbers = new Set<number>();
+      tokenToLines.set(token, numbers);
+    }
+    // Just to make sure that we don't break down on large files.
+    if (numbers.size > TOKEN_OCCURRENCES_LIMIT) return;
+    const lineNumber =
+      side === Side.LEFT ? line.beforeNumber : line.afterNumber;
+    numbers.add(Number(lineNumber));
+  }
+
+  private handleTokenMouseOut(e: MouseEvent) {
+    // If there's no ongoing hover-task, terminate early.
+    if (!this.updateTokenTask?.isActive()) return;
+    if (e.buttons > 0 || this.interferesWithSelection()) return;
+    const {element} = this.findTokenAncestor(e?.target);
+    if (!element) return;
+    if (element === this.hoveredElement) {
+      // If we are moving out of the currently hovered element, cancel the
+      // update task.
+      this.hoveredElement = undefined;
+      this.updateTokenTask?.cancel();
+    }
+  }
+
+  private handleTokenMouseOver(e: MouseEvent) {
+    if (e.buttons > 0 || this.interferesWithSelection()) return;
+    const {
+      line,
+      token: newHighlight,
+      element,
+    } = this.findTokenAncestor(e?.target);
+    if (!newHighlight || newHighlight === this.currentHighlight) return;
+    if (this.countOccurrences(newHighlight) <= 1) return;
+    this.hoveredElement = element;
+    this.updateTokenTask = debounce(
+      this.updateTokenTask,
+      () => {
+        this.updateTokenHighlight(newHighlight, line, element);
+      },
+      HOVER_DELAY_MS
+    );
+  }
+
+  private handleContainerClick(e: MouseEvent) {
+    if (this.interferesWithSelection()) return;
+    // Ignore the click if the click is on a token.
+    // We can't use e.target becauses it gets retargetted to the container as
+    // it's a shadow dom.
+    const {element} = this.findTokenAncestor(e.composedPath()[0]);
+    if (element) return;
+    this.hoveredElement = undefined;
+    this.updateTokenTask?.cancel();
+    this.updateTokenHighlight(undefined, 0, undefined);
+  }
+
+  private interferesWithSelection() {
+    return document.getSelection()?.type === 'Range';
+  }
+
+  findTokenAncestor(el?: EventTarget | Element | null): {
+    token?: string;
+    line: number;
+    element?: Element;
+  } {
+    if (!(el instanceof Element))
+      return {line: 0, token: undefined, element: undefined};
+    if (
+      el.classList.contains(CSS_TOKEN) ||
+      el.classList.contains(CSS_HIGHLIGHT)
+    ) {
+      const tkClass = [...el.classList].find(c => c.startsWith('tk-'));
+      const line = lineNumberToNumber(getLineNumberByChild(el));
+      if (!line || !tkClass)
+        return {line: 0, token: undefined, element: undefined};
+      return {line, token: tkClass.substring(3), element: el};
+    }
+    if (el.tagName === 'TD')
+      return {line: 0, token: undefined, element: undefined};
+    return this.findTokenAncestor(el.parentElement);
+  }
+
+  countOccurrences(token: string | undefined) {
+    if (!token) return 0;
+    const linesLeft = this.tokenToLinesLeft.get(token);
+    const linesRight = this.tokenToLinesRight.get(token);
+    return (linesLeft?.size ?? 0) + (linesRight?.size ?? 0);
+  }
+
+  private updateTokenHighlight(
+    newHighlight: string | undefined,
+    newLineNumber: number,
+    newHoveredElement: Element | undefined
+  ) {
+    if (
+      this.currentHighlight === newHighlight &&
+      this.currentHighlightLineNumber === newLineNumber
+    )
+      return;
+    const oldHighlight = this.currentHighlight;
+    const oldLineNumber = this.currentHighlightLineNumber;
+    this.currentHighlight = newHighlight;
+    this.currentHighlightLineNumber = newLineNumber;
+    this.triggerTokenHighlightEvent(
+      newHighlight,
+      newLineNumber,
+      newHoveredElement
+    );
+    this.notifyForToken(oldHighlight, oldLineNumber);
+    this.notifyForToken(newHighlight, newLineNumber);
+  }
+
+  triggerTokenHighlightEvent(
+    token: string | undefined,
+    line: number,
+    element: Element | undefined
+  ) {
+    if (!this.tokenHighlightListener) {
+      return;
+    }
+    if (!token || !element) {
+      this.tokenHighlightListener(undefined);
+      return;
+    }
+    const previousTextLength = getPreviousContentNodes(element)
+      .map(sib => sib.textContent!.length)
+      .reduce((partial_sum, a) => partial_sum + a, 0);
+    const lineEl = getLineElByChild(element);
+    assertIsDefined(lineEl, 'Line element should be found!');
+    const side = getSideByLineEl(lineEl);
+    const range = {
+      start_line: line,
+      start_column: previousTextLength + 1, // 1-based inclusive
+      end_line: line,
+      end_column: previousTextLength + token.length, // 1-based inclusive
+    };
+    this.tokenHighlightListener({token, element, side, range});
+  }
+
+  getSortedLinesForSide(
+    lineMapping: Map<string, Set<number>>,
+    token: string | undefined,
+    lineNumber: number
+  ): Array<number> {
+    if (!token) return [];
+    const lineSet = lineMapping.get(token);
+    if (!lineSet) return [];
+    const lines = [...lineSet];
+    lines.sort((a, b) => {
+      const da = Math.abs(a - lineNumber);
+      const db = Math.abs(b - lineNumber);
+      // For equal distance, prefer lines later in the file over earlier in the
+      // file. This ensures total ordering.
+      if (da === db) return b - a;
+      // Compare the distance to lineNumber.
+      return da - db;
+    });
+    return lines.slice(0, TOKEN_HIGHLIGHT_LIMIT);
+  }
+
+  notifyForToken(token: string | undefined, lineNumber: number) {
+    const leftLines = this.getSortedLinesForSide(
+      this.tokenToLinesLeft,
+      token,
+      lineNumber
+    );
+    for (const line of leftLines) {
+      this.notifyListeners(line, Side.LEFT);
+    }
+    const rightLines = this.getSortedLinesForSide(
+      this.tokenToLinesRight,
+      token,
+      lineNumber
+    );
+    for (const line of rightLines) {
+      this.notifyListeners(line, Side.RIGHT);
+    }
+  }
+
+  addListener(listener: DiffLayerListener) {
+    this.listeners.push(listener);
+  }
+
+  removeListener(listener: DiffLayerListener) {
+    this.listeners = this.listeners.filter(f => f !== listener);
+  }
+
+  notifyListeners(line: number, side: Side) {
+    for (const listener of this.listeners) {
+      listener(line, line, side);
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
new file mode 100644
index 0000000..2993d35
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/token-highlight-layer_test.ts
@@ -0,0 +1,339 @@
+/**
+ * @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';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {Side, TokenHighlightEventDetails} from '../../../api/diff';
+import {GrDiffLine, GrDiffLineType} from '../gr-diff/gr-diff-line.js';
+import {HOVER_DELAY_MS, TokenHighlightLayer} from './token-highlight-layer';
+import {GrAnnotation} from '../gr-diff-highlight/gr-annotation';
+import {html, render} from 'lit';
+import {_testOnly_allTasks} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../test/test-utils';
+
+// MockInteractions.makeMouseEvent always sets buttons to 1.
+function dispatchMouseEvent(
+  type: string,
+  xy: {x: number; y: number},
+  node: Element
+) {
+  const props = {
+    bubbles: true,
+    cancellable: true,
+    composed: true,
+    clientX: xy.x,
+    clientY: xy.y,
+    buttons: 0,
+  };
+  node.dispatchEvent(new MouseEvent(type, props));
+}
+
+class MockListener {
+  private results: any[][] = [];
+
+  notify(...args: any[]) {
+    this.results.push(args);
+  }
+
+  shift() {
+    return this.results.shift();
+  }
+
+  flush() {
+    this.results = [];
+  }
+
+  get pending(): number {
+    return this.results.length;
+  }
+}
+
+suite('token-highlight-layer', () => {
+  let container: HTMLElement;
+  let listener: MockListener;
+  let highlighter: TokenHighlightLayer;
+  let tokenHighlightingCalls: {details?: TokenHighlightEventDetails}[] = [];
+
+  function tokenHighlightListener(
+    highlightDetails?: TokenHighlightEventDetails
+  ) {
+    tokenHighlightingCalls.push({details: highlightDetails});
+  }
+
+  setup(async () => {
+    listener = new MockListener();
+    tokenHighlightingCalls = [];
+    container = document.createElement('div');
+    document.body.appendChild(container);
+    highlighter = new TokenHighlightLayer(container, tokenHighlightListener);
+    highlighter.addListener((...args) => listener.notify(...args));
+  });
+
+  teardown(() => {
+    document.body.removeChild(container);
+  });
+
+  function annotate(el: HTMLElement, side: Side = Side.LEFT, line = 1) {
+    const diffLine = new GrDiffLine(GrDiffLineType.BOTH);
+    diffLine.afterNumber = line;
+    diffLine.beforeNumber = line;
+    highlighter.annotate(el, document.createElement('div'), diffLine, side);
+    return el;
+  }
+
+  let uniqueId = 0;
+  function createLineId() {
+    uniqueId++;
+    return `line-${uniqueId.toString()}`;
+  }
+
+  function createLine(text: string, line = 1): HTMLElement {
+    const lineId = createLineId();
+    const template = html`
+      <div class="line">
+        <div data-value=${line} class="lineNum right"></div>
+        <div class="content">
+          <div id=${lineId} class="contentText">${text}</div>
+        </div>
+      </div>
+    `;
+
+    const div = document.createElement('div');
+    render(template, div);
+    container.appendChild(div);
+    const el = queryAndAssert(container, `#${lineId}`);
+    return el as HTMLElement;
+  }
+
+  suite('annotate', () => {
+    function assertAnnotation(
+      args: any[],
+      el: HTMLElement,
+      start: number,
+      length: number,
+      cssClass: string
+    ) {
+      assert.equal(args[0], el);
+      assert.equal(args[1], start);
+      assert.equal(args[2], length);
+      assert.equal(args[3], cssClass);
+    }
+
+    test('annotate adds css token', () => {
+      const annotateElementStub = sinon.stub(GrAnnotation, 'annotateElement');
+      const el = createLine('these are words');
+      annotate(el);
+      assert.isTrue(annotateElementStub.calledThrice);
+      assertAnnotation(annotateElementStub.args[0], el, 0, 5, 'tk-these token');
+      assertAnnotation(annotateElementStub.args[1], el, 6, 3, 'tk-are token');
+      assertAnnotation(
+        annotateElementStub.args[2],
+        el,
+        10,
+        5,
+        'tk-words token'
+      );
+    });
+
+    test('annotate adds mouse handlers', () => {
+      const el = createLine('these are words');
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isTrue(addEventListenerStub.calledTwice);
+      assert.equal(addEventListenerStub.args[0][0], 'mouseover');
+      assert.equal(addEventListenerStub.args[1][0], 'mouseout');
+    });
+
+    test('annotate does not add mouse handlers without words', () => {
+      const el = createLine('  ');
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isFalse(addEventListenerStub.called);
+    });
+
+    test('annotate adds mouse handlers for longest word', () => {
+      const el = createLine('w'.repeat(100));
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isTrue(addEventListenerStub.called);
+    });
+
+    test('annotate does not add mouse handlers for long words', () => {
+      const el = createLine('w'.repeat(101));
+      const addEventListenerStub = sinon.stub(el, 'addEventListener');
+      annotate(el);
+      assert.isFalse(addEventListenerStub.called);
+    });
+  });
+
+  suite('highlight', () => {
+    test('highlighting hover delay', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words');
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+
+      assert.equal(listener.pending, 0);
+      assert.equal(_testOnly_allTasks.size, 1);
+
+      // Too early for hover behavior to trigger.
+      clock.tick(100);
+      assert.equal(listener.pending, 0);
+      assert.equal(_testOnly_allTasks.size, 1);
+
+      // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
+      clock.tick(HOVER_DELAY_MS - 100);
+      assert.equal(listener.pending, 2);
+      assert.equal(_testOnly_allTasks.size, 0);
+      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
+      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
+    });
+
+    test('highlighting spans many lines', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words');
+      annotate(line2, Side.RIGHT, 1000);
+      const words1 = queryAndAssert(line1, '.tk-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+
+      assert.equal(listener.pending, 0);
+
+      // After a total of HOVER_DELAY_MS ms the hover behavior should trigger.
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(listener.pending, 2);
+      assert.equal(_testOnly_allTasks.size, 0);
+      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
+      assert.deepEqual(listener.shift(), [1000, 1000, Side.RIGHT]);
+    });
+
+    test('highlighting mouse out before delay', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+      assert.equal(listener.pending, 0);
+      clock.tick(100);
+      // Mouse out after 100ms but before hover delay.
+      dispatchMouseEvent(
+        'mouseout',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+      assert.equal(listener.pending, 0);
+      clock.tick(HOVER_DELAY_MS - 100);
+      assert.equal(listener.pending, 0);
+      assert.equal(_testOnly_allTasks.size, 0);
+    });
+
+    test('triggers listener for applying and clearing highlighting', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+      assert.equal(tokenHighlightingCalls.length, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(tokenHighlightingCalls.length, 1);
+      assert.deepEqual(tokenHighlightingCalls[0].details, {
+        token: 'words',
+        side: Side.RIGHT,
+        element: words1,
+        range: {start_line: 1, start_column: 5, end_line: 1, end_column: 9},
+      });
+
+      MockInteractions.click(container);
+      assert.equal(tokenHighlightingCalls.length, 2);
+      assert.deepEqual(tokenHighlightingCalls[1].details, undefined);
+    });
+
+    test('clicking clears highlight', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+      assert.equal(listener.pending, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(listener.pending, 2);
+      listener.flush();
+      assert.equal(listener.pending, 0);
+      MockInteractions.click(container);
+      assert.equal(listener.pending, 2);
+      assert.deepEqual(listener.shift(), [1, 1, Side.LEFT]);
+      assert.deepEqual(listener.shift(), [2, 2, Side.RIGHT]);
+    });
+
+    test('clicking on word does not clear highlight', async () => {
+      const clock = sinon.useFakeTimers();
+      const line1 = createLine('two words');
+      annotate(line1);
+      const line2 = createLine('three words', 2);
+      annotate(line2, Side.RIGHT, 2);
+      const words1 = queryAndAssert(line1, '.tk-words');
+      assert.isTrue(words1.classList.contains('token'));
+      dispatchMouseEvent(
+        'mouseover',
+        MockInteractions.middleOfNode(words1),
+        words1
+      );
+      assert.equal(listener.pending, 0);
+      clock.tick(HOVER_DELAY_MS);
+      assert.equal(listener.pending, 2);
+      listener.flush();
+      assert.equal(listener.pending, 0);
+      MockInteractions.click(words1);
+      assert.equal(listener.pending, 0);
+    });
+  });
+});
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 9b7c25a..958f367 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
@@ -15,60 +15,89 @@
  * limitations under the License.
  */
 
-import '../../shared/gr-cursor-manager/gr-cursor-manager';
+import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {Subscription} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {
-  AbortStop,
-  CursorMoveResult,
+  DiffViewMode,
+  GrDiffCursor as GrDiffCursorApi,
+  LineNumberEventDetail,
+} from '../../../api/diff';
+import {ScrollMode, Side} from '../../../constants/constants';
+import {PolymerDomWrapper} from '../../../types/types';
+import {toggleClass} from '../../../utils/dom-util';
+import {
   GrCursorManager,
-  Stop,
   isTargetable,
 } from '../../shared/gr-cursor-manager/gr-cursor-manager';
-import {afterNextRender} from '@polymer/polymer/lib/utils/render-status';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom';
-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';
-import {PolymerSpliceChange} from '@polymer/polymer/interfaces';
-import {PolymerDomWrapper} from '../../../types/types';
 import {GrDiffGroupType} from '../gr-diff/gr-diff-group';
 import {GrDiff} from '../gr-diff/gr-diff';
-import {fireAlert, fireEvent} from '../../../utils/event-util';
-import {Subscription} from 'rxjs';
-import {toggleClass} from '../../../utils/dom-util';
 
 type GrDiffRowType = GrDiffLineType | GrDiffGroupType;
 
 const LEFT_SIDE_CLASS = 'target-side-left';
 const RIGHT_SIDE_CLASS = 'target-side-right';
 
-// Time in which pressing n key again after the toast navigates to next file
-const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
-
-export interface GrDiffCursor {
-  $: {};
+/** A subset of the GrDiff API that the cursor is using. */
+export interface GrDiffCursorable extends HTMLElement {
+  isRangeSelected(): boolean;
+  createRangeComment(): void;
+  getCursorStops(): Stop[];
+  path?: string;
 }
 
-@customElement('gr-diff-cursor')
-export class GrDiffCursor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDiffCursor implements GrDiffCursorApi {
   private preventAutoScrollOnManualScroll = false;
 
-  private lastDisplayedNavigateToFileToast: Map<string, number> = new Map();
+  set side(side: Side) {
+    if (this.sideInternal === side) {
+      return;
+    }
+    if (this.sideInternal && this.diffRow) {
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRow,
+        this.sideInternal
+      );
+    }
+    this.sideInternal = side;
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
 
-  @property({type: String})
-  side = Side.RIGHT;
+  get side(): Side {
+    return this.sideInternal;
+  }
 
-  @property({type: Object, notify: true, observer: '_rowChanged'})
-  diffRow?: HTMLElement;
+  private sideInternal = Side.RIGHT;
 
-  @property({type: Object})
-  diffs: GrDiff[] = [];
+  set diffRow(diffRow: HTMLElement | undefined) {
+    if (this.diffRowInternal) {
+      this.diffRowInternal.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+      this.fireCursorMoved(
+        'line-cursor-moved-out',
+        this.diffRowInternal,
+        this.side
+      );
+    }
+    this.diffRowInternal = diffRow;
+
+    this.updateSideClass();
+    if (this.diffRow) {
+      this.fireCursorMoved('line-cursor-moved-in', this.diffRow, this.side);
+    }
+  }
+
+  get diffRow(): HTMLElement | undefined {
+    return this.diffRowInternal;
+  }
+
+  private diffRowInternal?: HTMLElement;
+
+  private diffs: GrDiffCursorable[] = [];
 
   /**
    * If set, the cursor will attempt to move to the line number (instead of
@@ -78,62 +107,27 @@
    * to that position. This parameter should be set at most for one gr-diff
    * element in the page.
    */
-  @property({type: Number})
   initialLineNumber: number | null = null;
 
-  @property({type: Boolean})
-  _listeningForScroll = false;
-
   private cursorManager = new GrCursorManager();
 
+  private targetSubscription?: Subscription;
+
   constructor() {
-    super();
     this.cursorManager.cursorTargetClass = 'target-row';
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
     this.cursorManager.focusOnMove = true;
-  }
 
-  /** @override */
-  ready() {
-    super.ready();
-    afterNextRender(this, () => {
-      /*
-      This represents the diff cursor is ready for interaction coming from
-      client components. It is more then Polymer "ready" lifecycle, as no
-      "ready" events are automatically fired by Polymer, it means
-      the cursor is completely interactable - in this case attached and
-      painted on the page. We name it "ready" instead of "rendered" as the
-      long-term goal is to make gr-diff-cursor a javascript class - not a DOM
-      element with an actual lifecycle. This will be triggered only once
-      per element.
-      */
-      this.dispatchEvent(
-        new CustomEvent('ready', {
-          composed: true,
-          bubbles: false,
-        })
-      );
-    });
-  }
-
-  private targetSubscription?: Subscription;
-
-  /** @override */
-  connectedCallback() {
-    super.connectedCallback();
-    // Catch when users are scrolling as the view loads.
     window.addEventListener('scroll', this._boundHandleWindowScroll);
     this.targetSubscription = this.cursorManager.target$.subscribe(target => {
       this.diffRow = target || undefined;
     });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  dispose() {
     if (this.targetSubscription) this.targetSubscription.unsubscribe();
     window.removeEventListener('scroll', this._boundHandleWindowScroll);
     this.cursorManager.unsetCursor();
-    super.disconnectedCallback();
   }
 
   // Don't remove - used by clients embedding gr-diff outside of Gerrit.
@@ -190,66 +184,28 @@
     }
   }
 
-  private showToastAndFireEvent(direction: string, shortcut: string) {
-    /*
-     * If user presses p/n on the first/last diff chunk, show a toast informing
-     * user that pressing it again will navigate them to previous/next
-     * unreviewedfile if click happens within the time limit
-     */
-    if (
-      this.lastDisplayedNavigateToFileToast.get(direction) &&
-      Date.now() - this.lastDisplayedNavigateToFileToast.get(direction)! <=
-        NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
-    ) {
-      // reset for next file
-      this.lastDisplayedNavigateToFileToast.delete(direction);
-      fireEvent(this, `navigate-to-${direction}-unreviewed-file`);
-    } else {
-      this.lastDisplayedNavigateToFileToast.set(direction, Date.now());
-      fireAlert(
-        this,
-        `Press ${shortcut} again to navigate to ${direction} unreviewed file`
-      );
-    }
-  }
-
-  moveToNextChunk(
-    clipToTop?: boolean,
-    navigateToNextFile?: boolean
-  ): CursorMoveResult {
+  moveToNextChunk(clipToTop?: boolean): CursorMoveResult {
     const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
       getTargetHeight: target =>
         (target?.parentNode as HTMLElement)?.scrollHeight || 0,
       clipToTop,
     });
-    if (
-      navigateToNextFile &&
-      result === CursorMoveResult.CLIPPED &&
-      this.isAtEnd()
-    ) {
-      this.showToastAndFireEvent('next', 'n');
-    }
-
     this._fixSide();
     return result;
   }
 
-  moveToPreviousChunk(navigateToPreviousFile?: boolean): CursorMoveResult {
+  moveToPreviousChunk(): CursorMoveResult {
     const result = this.cursorManager.previous({
       filter: (row: HTMLElement) => this._isFirstRowOfChunk(row),
     });
-    if (navigateToPreviousFile && this.isAtStart()) {
-      this.showToastAndFireEvent('previous', 'p');
-    }
     this._fixSide();
     return result;
   }
 
-  moveToNextCommentThread(): CursorMoveResult | undefined {
+  moveToNextCommentThread(): CursorMoveResult {
     if (this.isAtEnd()) {
-      fireEvent(this, 'navigate-to-next-file-with-comments');
-      return;
+      return CursorMoveResult.CLIPPED;
     }
     const result = this.cursorManager.next({
       filter: (row: HTMLElement) => this._rowHasThread(row),
@@ -341,10 +297,10 @@
         this.moveToFirstChunk();
       }
     }
-    this.reInit();
+    this.resetScrollMode();
   }
 
-  reInit() {
+  resetScrollMode() {
     this.cursorManager.scrollMode = ScrollMode.KEEP_VISIBLE;
   }
 
@@ -357,7 +313,7 @@
   };
 
   reInitAndUpdateStops() {
-    this.reInit();
+    this.resetScrollMode();
     this._updateStops();
   }
 
@@ -410,23 +366,25 @@
    * {leftSide: false, number: 123} for line 123 of the revision, or
    * {leftSide: true, number: 321} for line 321 of the base patch.
    * Returns null if an address is not available.
-   *
    */
   getAddress() {
     if (!this.diffRow) {
       return null;
     }
-
     // Get the line-number cell targeted by the cursor. If the mode is unified
     // then prefer the revision cell if available.
+    return this.getAddressFor(this.diffRow, this.side);
+  }
+
+  private getAddressFor(diffRow: HTMLElement, side: Side) {
     let cell;
     if (this._getViewMode() === DiffViewMode.UNIFIED) {
-      cell = this.diffRow.querySelector('.lineNum.right');
+      cell = diffRow.querySelector('.lineNum.right');
       if (!cell) {
-        cell = this.diffRow.querySelector('.lineNum.left');
+        cell = diffRow.querySelector('.lineNum.left');
       }
     } else {
-      cell = this.diffRow.querySelector('.lineNum.' + this.side);
+      cell = diffRow.querySelector('.lineNum.' + side);
     }
     if (!cell) {
       return null;
@@ -500,15 +458,28 @@
     );
   }
 
-  _rowChanged(_: HTMLElement, oldRow: HTMLElement) {
-    if (oldRow) {
-      oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
+  private fireCursorMoved(
+    event: 'line-cursor-moved-out' | 'line-cursor-moved-in',
+    row: HTMLElement,
+    side: Side
+  ) {
+    const address = this.getAddressFor(row, side);
+    if (address) {
+      const {leftSide, number} = address;
+      row.dispatchEvent(
+        new CustomEvent<LineNumberEventDetail>(event, {
+          detail: {
+            lineNum: number,
+            side: leftSide ? Side.LEFT : Side.RIGHT,
+          },
+          composed: true,
+          bubbles: true,
+        })
+      );
     }
-    this._updateSideClass();
   }
 
-  @observe('side')
-  _updateSideClass() {
+  private updateSideClass() {
     if (!this.diffRow) {
       return;
     }
@@ -542,69 +513,53 @@
     );
   }
 
-  /**
-   * Setup and tear down on-render listeners for any diffs that are added or
-   * removed from the cursor.
-   */
-  @observe('diffs.splices')
-  _diffsChanged(changeRecord: PolymerSpliceChange<GrDiff[]>) {
-    if (!changeRecord) {
-      return;
+  replaceDiffs(diffs: GrDiffCursorable[]) {
+    for (const diff of this.diffs) {
+      this.removeEventListeners(diff);
     }
-
+    this.diffs = [];
+    for (const diff of diffs) {
+      this.addEventListeners(diff);
+    }
+    this.diffs.push(...diffs);
     this._updateStops();
+  }
 
-    let splice;
-    let i;
-    for (
-      let spliceIdx = 0;
-      changeRecord.indexSplices && spliceIdx < changeRecord.indexSplices.length;
-      spliceIdx++
-    ) {
-      splice = changeRecord.indexSplices[spliceIdx];
-
-      // Removals must come before additions, because the gr-diff instances
-      // 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
-        );
-        splice.removed[i].removeEventListener(
-          'render-content',
-          this._boundHandleDiffRenderContent
-        );
-        splice.removed[i].removeEventListener(
-          'line-selected',
-          this._boundHandleDiffLineSelected
-        );
-      }
-
-      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
-        );
-        this.diffs[i].addEventListener(
-          'render-content',
-          this._boundHandleDiffRenderContent
-        );
-        this.diffs[i].addEventListener(
-          'line-selected',
-          this._boundHandleDiffLineSelected
-        );
-      }
+  unregisterDiff(diff: GrDiffCursorable) {
+    // This can happen during destruction - just don't unregister then.
+    if (!this.diffs) return;
+    const i = this.diffs.indexOf(diff);
+    if (i !== -1) {
+      this.diffs.splice(i, 1);
     }
   }
 
+  private removeEventListeners(diff: GrDiffCursorable) {
+    diff.removeEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.removeEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.removeEventListener(
+      'render-content',
+      this._boundHandleDiffRenderContent
+    );
+    diff.removeEventListener(
+      'line-selected',
+      this._boundHandleDiffLineSelected
+    );
+  }
+
+  private addEventListeners(diff: GrDiffCursorable) {
+    diff.addEventListener(
+      'loading-changed',
+      this.boundHandleDiffLoadingChanged
+    );
+    diff.addEventListener('render-start', this._boundHandleDiffRenderStart);
+    diff.addEventListener('render-content', this._boundHandleDiffRenderContent);
+    diff.addEventListener('line-selected', this._boundHandleDiffLineSelected);
+  }
+
   _findRowByNumberAndFile(
     targetNumber: number,
     side: Side,
@@ -624,9 +579,3 @@
     return targetableStops.find(stop => stop.querySelector(selector));
   }
 }
-
-declare global {
-  interface HTMLElementTagNameMap {
-    'gr-diff-cursor': GrDiffCursor;
-  }
-}
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 75439c8..3b604eb 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.js
@@ -19,29 +19,26 @@
 import '../gr-diff/gr-diff.js';
 import './gr-diff-cursor.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {listenOnce} from '../../../test/test-utils.js';
+import {listenOnce, mockPromise} from '../../../test/test-utils.js';
 import {getMockDiffResponse} from '../../../test/mocks/diff-response.js';
 import {createDefaultDiffPrefs} from '../../../constants/constants.js';
+import {GrDiffCursor} from './gr-diff-cursor.js';
 
 const basicFixture = fixtureFromTemplate(html`
   <gr-diff></gr-diff>
-  <gr-diff-cursor></gr-diff-cursor>
 `);
 
-const emptyFixture = fixtureFromElement('div');
-
 suite('gr-diff-cursor tests', () => {
-  let cursorElement;
+  let cursor;
   let diffElement;
   let diff;
 
-  setup(done => {
-    const fixtureElems = basicFixture.instantiate();
-    diffElement = fixtureElems[0];
-    cursorElement = fixtureElems[1];
+  setup(async () => {
+    diffElement = basicFixture.instantiate();
+    cursor = new GrDiffCursor();
 
     // Register the diff with the cursor.
-    cursorElement.push('diffs', diffElement);
+    cursor.replaceDiffs([diffElement]);
 
     diffElement.loggedIn = false;
     diffElement.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -51,36 +48,38 @@
       meta: {patchRange: undefined},
     };
     diffElement.path = 'some/path.ts';
+    const promise = mockPromise();
     const setupDone = () => {
-      cursorElement._updateStops();
-      cursorElement.moveToFirstChunk();
+      cursor._updateStops();
+      cursor.moveToFirstChunk();
       diffElement.removeEventListener('render', setupDone);
-      done();
+      promise.resolve();
     };
     diffElement.addEventListener('render', setupDone);
 
     diff = getMockDiffResponse();
     diffElement.prefs = createDefaultDiffPrefs();
     diffElement.diff = diff;
+    await promise;
   });
 
   test('diff cursor functionality (side-by-side)', () => {
     // The cursor has been initialized to the first delta.
-    assert.isOk(cursorElement.diffRow);
+    assert.isOk(cursor.diffRow);
 
     const firstDeltaRow = diffElement.shadowRoot
         .querySelector('.section.delta .diff-row');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, firstDeltaRow);
 
-    cursorElement.moveDown();
+    cursor.moveDown();
 
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
 
-    cursorElement.moveUp();
+    cursor.moveUp();
 
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
+    assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
+    assert.equal(cursor.diffRow, firstDeltaRow);
   });
 
   test('moveToFirstChunk', async () => {
@@ -116,24 +115,24 @@
     // moveToFirstChunk() works correctly even if the button is not shown.
     diffElement.prefs.show_file_comment_button = false;
     await flush();
-    cursorElement._updateStops();
+    cursor._updateStops();
 
     const chunks = Array.from(diffElement.root.querySelectorAll(
         '.section.delta'));
     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');
+    cursor.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.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');
+    cursor.moveToNextChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'left');
+    cursor.moveToFirstChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'right');
   });
 
   test('moveToLastChunk', async () => {
@@ -166,45 +165,45 @@
 
     diffElement.diff = diff;
     await flush();
-    cursorElement._updateStops();
+    cursor._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');
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'right');
 
     // 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');
+    cursor.moveToPreviousChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 0);
+    assert.equal(cursor.side, 'left');
+    cursor.moveToLastChunk();
+    assert.equal(chunks.indexOf(cursor.diffRow.parentElement), 1);
+    assert.equal(cursor.side, 'right');
   });
 
   test('cursor scroll behavior', () => {
-    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
 
     diffElement.dispatchEvent(new Event('render-start'));
-    assert.isTrue(cursorElement.cursorManager.focusOnMove);
+    assert.isTrue(cursor.cursorManager.focusOnMove);
 
     window.dispatchEvent(new Event('scroll'));
-    assert.equal(cursorElement.cursorManager.scrollMode, 'never');
-    assert.isFalse(cursorElement.cursorManager.focusOnMove);
+    assert.equal(cursor.cursorManager.scrollMode, 'never');
+    assert.isFalse(cursor.cursorManager.focusOnMove);
 
     diffElement.dispatchEvent(new Event('render-content'));
-    assert.isTrue(cursorElement.cursorManager.focusOnMove);
+    assert.isTrue(cursor.cursorManager.focusOnMove);
 
-    cursorElement.reInitCursor();
-    assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
+    cursor.reInitCursor();
+    assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
   });
 
   test('moves to selected line', () => {
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
 
     diffElement.dispatchEvent(
         new CustomEvent('line-selected', {
@@ -218,38 +217,40 @@
   });
 
   suite('unified diff', () => {
-    setup(done => {
+    setup(async () => {
+      const promise = mockPromise();
       // We must allow the diff to re-render after setting the viewMode.
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
-        done();
+        cursor.reInitCursor();
+        promise.resolve();
       };
       diffElement.addEventListener('render', renderHandler);
       diffElement.viewMode = 'UNIFIED_DIFF';
+      await promise;
     });
 
     test('diff cursor functionality (unified)', () => {
       // The cursor has been initialized to the first delta.
-      assert.isOk(cursorElement.diffRow);
+      assert.isOk(cursor.diffRow);
 
       let firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow);
 
       firstDeltaRow = diffElement.shadowRoot
           .querySelector('.section.delta .diff-row');
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow);
 
-      cursorElement.moveDown();
+      cursor.moveDown();
 
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-      assert.equal(cursorElement.diffRow, firstDeltaRow.nextSibling);
+      assert.notEqual(cursor.diffRow, firstDeltaRow);
+      assert.equal(cursor.diffRow, firstDeltaRow.nextSibling);
 
-      cursorElement.moveUp();
+      cursor.moveUp();
 
-      assert.notEqual(cursorElement.diffRow, firstDeltaRow.nextSibling);
-      assert.equal(cursorElement.diffRow, firstDeltaRow);
+      assert.notEqual(cursor.diffRow, firstDeltaRow.nextSibling);
+      assert.equal(cursor.diffRow, firstDeltaRow);
     });
   });
 
@@ -264,29 +265,29 @@
 
     // Because the first delta in this diff is on the right, it should be set
     // to the right side.
-    assert.equal(cursorElement.side, 'right');
-    assert.equal(cursorElement.diffRow, firstDeltaRow);
-    const firstIndex = cursorElement.cursorManager.index;
+    assert.equal(cursor.side, 'right');
+    assert.equal(cursor.diffRow, firstDeltaRow);
+    const firstIndex = cursor.cursorManager.index;
 
     // Move the side to the left. Because this delta only has a right side, we
     // should be moved up to the previous line where there is content on the
     // right. The previous row is part of the previous section.
-    cursorElement.moveLeft();
+    cursor.moveLeft();
 
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.equal(cursorElement.cursorManager.index, firstIndex - 1);
-    assert.equal(cursorElement.diffRow.parentElement,
+    assert.equal(cursor.side, 'left');
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.equal(cursor.cursorManager.index, firstIndex - 1);
+    assert.equal(cursor.diffRow.parentElement,
         firstDeltaSection.previousSibling);
 
     // If we move down, we should skip everything in the first delta because
     // we are on the left side and the first delta has no content on the left.
-    cursorElement.moveDown();
+    cursor.moveDown();
 
-    assert.equal(cursorElement.side, 'left');
-    assert.notEqual(cursorElement.diffRow, firstDeltaRow);
-    assert.isTrue(cursorElement.cursorManager.index > firstIndex);
-    assert.equal(cursorElement.diffRow.parentElement,
+    assert.equal(cursor.side, 'left');
+    assert.notEqual(cursor.diffRow, firstDeltaRow);
+    assert.isTrue(cursor.cursorManager.index > firstIndex);
+    assert.equal(cursor.diffRow.parentElement,
         firstDeltaSection.nextSibling);
   });
 
@@ -299,27 +300,28 @@
 
     // We should be initialized to the first chunk. Since this chunk only has
     // content on the right side, our side should be right.
-    let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    let currentIndex = indexOfChunk(cursor.diffRow.parentElement);
     assert.equal(currentIndex, 0);
-    assert.equal(cursorElement.side, 'right');
+    assert.equal(cursor.side, 'right');
 
     // Move to the next chunk.
-    cursorElement.moveToNextChunk();
+    cursor.moveToNextChunk();
 
     // Since this chunk only has content on the left side. we should have been
     // automatically moved over.
     const previousIndex = currentIndex;
-    currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+    currentIndex = indexOfChunk(cursor.diffRow.parentElement);
     assert.equal(currentIndex, previousIndex + 1);
-    assert.equal(cursorElement.side, 'left');
+    assert.equal(cursor.side, 'left');
   });
 
   suite('moved chunks without line range)', () => {
-    setup(done => {
+    setup(async () => {
+      const promise = mockPromise();
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
-        done();
+        cursor.reInitCursor();
+        promise.resolve();
       };
       diffElement.addEventListener('render', renderHandler);
       diffElement.diff = {...diff, content: [
@@ -355,6 +357,7 @@
           ],
         },
       ]};
+      await promise;
     });
 
     test('renders moveControls with simple descriptions', () => {
@@ -366,11 +369,12 @@
   });
 
   suite('moved chunks (moveDetails)', () => {
-    setup(done => {
+    setup(async () => {
+      const promise = mockPromise();
       const renderHandler = function() {
         diffElement.removeEventListener('render', renderHandler);
-        cursorElement.reInitCursor();
-        done();
+        cursor.reInitCursor();
+        promise.resolve();
       };
       diffElement.addEventListener('render', renderHandler);
       diffElement.diff = {...diff, content: [
@@ -406,6 +410,7 @@
           ],
         },
       ]};
+      await promise;
     });
 
     test('renders moveControls with simple descriptions', () => {
@@ -415,102 +420,95 @@
       assert.equal(movedOut.textContent, 'Moved to lines 2 - 4');
     });
 
-    test('startLineAnchor of movedIn chunk fires events', done => {
+    test('startLineAnchor of movedIn chunk fires events', async () => {
       const [movedIn] = diffElement.root
           .querySelectorAll('.dueToMove .moveControls');
       const [startLineAnchor] = movedIn.querySelectorAll('a');
 
+      const promise = mockPromise();
       const onMovedLinkClicked = e => {
         assert.deepEqual(e.detail, {lineNum: 4, side: 'left'});
-        done();
+        promise.resolve();
       };
       assert.equal(startLineAnchor.textContent, '4');
       startLineAnchor
           .addEventListener('moved-link-clicked', onMovedLinkClicked);
       MockInteractions.click(startLineAnchor);
+      await promise;
     });
 
-    test('endLineAnchor of movedOut fires events', done => {
+    test('endLineAnchor of movedOut fires events', async () => {
       const [, movedOut] = diffElement.root
           .querySelectorAll('.dueToMove .moveControls');
       const [, endLineAnchor] = movedOut.querySelectorAll('a');
 
+      const promise = mockPromise();
       const onMovedLinkClicked = e => {
         assert.deepEqual(e.detail, {lineNum: 4, side: 'right'});
-        done();
+        promise.resolve();
       };
       assert.equal(endLineAnchor.textContent, '4');
       endLineAnchor.addEventListener('moved-link-clicked', onMovedLinkClicked);
       MockInteractions.click(endLineAnchor);
+      await promise;
     });
   });
 
-  test('navigate to next unreviewed file via moveToNextChunk', () => {
-    const cursorManager = cursorElement.cursorManager;
-    cursorManager.index = cursorManager.stops.length - 1;
-    const dispatchEventStub = sinon.stub(cursorElement, 'dispatchEvent');
-    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
-        /* opt_navigateToNextFile = */true);
-    assert.isTrue(dispatchEventStub.called);
-    assert.equal(dispatchEventStub.getCall(1).args[0].type, 'show-alert');
-
-    cursorElement.moveToNextChunk(/* opt_clipToTop = */false,
-        /* opt_navigateToNextFile = */true);
-    assert.equal(dispatchEventStub.getCall(2).args[0].type,
-        'navigate-to-next-unreviewed-file');
-  });
-
-  test('initialLineNumber not provided', done => {
+  test('initialLineNumber not provided', async () => {
     let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk')
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber');
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk')
         .callsFake(() => {
-          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
 
+    const promise = mockPromise();
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
+      cursor.reInitCursor();
       assert.isFalse(moveToNumStub.called);
       assert.isTrue(moveToChunkStub.called);
       assert.equal(scrollBehaviorDuringMove, 'never');
-      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
-      done();
+      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+      promise.resolve();
     }
     diffElement.addEventListener('render', renderHandler);
     diffElement._diffChanged(getMockDiffResponse());
+    await promise;
   });
 
-  test('initialLineNumber provided', done => {
+  test('initialLineNumber provided', async () => {
     let scrollBehaviorDuringMove;
-    const moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber')
+    const moveToNumStub = sinon.stub(cursor, 'moveToLineNumber')
         .callsFake(() => {
-          scrollBehaviorDuringMove = cursorElement.cursorManager.scrollMode;
+          scrollBehaviorDuringMove = cursor.cursorManager.scrollMode;
         });
-    const moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    const moveToChunkStub = sinon.stub(cursor, 'moveToFirstChunk');
+    const promise = mockPromise();
     function renderHandler() {
       diffElement.removeEventListener('render', renderHandler);
-      cursorElement.reInitCursor();
+      cursor.reInitCursor();
       assert.isFalse(moveToChunkStub.called);
       assert.isTrue(moveToNumStub.called);
       assert.equal(moveToNumStub.lastCall.args[0], 10);
       assert.equal(moveToNumStub.lastCall.args[1], 'right');
       assert.equal(scrollBehaviorDuringMove, 'keep-visible');
-      assert.equal(cursorElement.cursorManager.scrollMode, 'keep-visible');
-      done();
+      assert.equal(cursor.cursorManager.scrollMode, 'keep-visible');
+      promise.resolve();
     }
     diffElement.addEventListener('render', renderHandler);
-    cursorElement.initialLineNumber = 10;
-    cursorElement.side = 'right';
+    cursor.initialLineNumber = 10;
+    cursor.side = 'right';
 
     diffElement._diffChanged(getMockDiffResponse());
+    await promise;
   });
 
   test('getTargetDiffElement', () => {
-    cursorElement.initialLineNumber = 1;
-    assert.isTrue(!!cursorElement.diffRow);
+    cursor.initialLineNumber = 1;
+    assert.isTrue(!!cursor.diffRow);
     assert.equal(
-        cursorElement.getTargetDiffElement(),
+        cursor.getTargetDiffElement(),
         diffElement
     );
   });
@@ -520,31 +518,35 @@
       diffElement.loggedIn = true;
     });
 
-    test('adds new draft for selected line on the left', done => {
-      cursorElement.moveToLineNumber(2, 'left');
+    test('adds new draft for selected line on the left', async () => {
+      cursor.moveToLineNumber(2, 'left');
+      const promise = mockPromise();
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 2);
         assert.equal(range, undefined);
         assert.equal(side, 'left');
-        done();
+        promise.resolve();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
+      await promise;
     });
 
-    test('adds draft for selected line on the right', done => {
-      cursorElement.moveToLineNumber(4, 'right');
+    test('adds draft for selected line on the right', async () => {
+      cursor.moveToLineNumber(4, 'right');
+      const promise = mockPromise();
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 4);
         assert.equal(range, undefined);
         assert.equal(side, 'right');
-        done();
+        promise.resolve();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
+      await promise;
     });
 
-    test('creates comment for range if selected', done => {
+    test('creates comment for range if selected', async () => {
       const someRange = {
         start_line: 2,
         start_character: 3,
@@ -555,22 +557,24 @@
         side: 'right',
         range: someRange,
       };
+      const promise = mockPromise();
       diffElement.addEventListener('create-comment', e => {
         const {lineNum, range, side} = e.detail;
         assert.equal(lineNum, 6);
         assert.equal(range, someRange);
         assert.equal(side, 'right');
-        done();
+        promise.resolve();
       });
-      cursorElement.createCommentInPlace();
+      cursor.createCommentInPlace();
+      await promise;
     });
 
     test('ignores call if nothing is selected', () => {
       const createRangeCommentStub = sinon.stub(diffElement,
           'createRangeComment');
       const addDraftAtLineStub = sinon.stub(diffElement, 'addDraftAtLine');
-      cursorElement.diffRow = undefined;
-      cursorElement.createCommentInPlace();
+      cursor.diffRow = undefined;
+      cursor.createCommentInPlace();
       assert.isFalse(createRangeCommentStub.called);
       assert.isFalse(addDraftAtLineStub.called);
     });
@@ -578,31 +582,31 @@
 
   test('getAddress', () => {
     // It should initialize to the first chunk: line 5 of the revision.
-    assert.deepEqual(cursorElement.getAddress(),
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 5});
 
     // Revision line 4 is up.
-    cursorElement.moveUp();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveUp();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 4});
 
     // Base line 4 is left.
-    cursorElement.moveLeft();
-    assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
+    cursor.moveLeft();
+    assert.deepEqual(cursor.getAddress(), {leftSide: true, number: 4});
 
     // Moving to the next chunk takes it back to the start.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: false, number: 5});
 
     // The following chunk is a removal starting on line 10 of the base.
-    cursorElement.moveToNextChunk();
-    assert.deepEqual(cursorElement.getAddress(),
+    cursor.moveToNextChunk();
+    assert.deepEqual(cursor.getAddress(),
         {leftSide: true, number: 10});
 
     // Should be null if there is no selection.
-    cursorElement.cursorManager.unsetCursor();
-    assert.isNotOk(cursorElement.getAddress());
+    cursor.cursorManager.unsetCursor();
+    assert.isNotOk(cursor.getAddress());
   });
 
   test('_findRowByNumberAndFile', () => {
@@ -610,42 +614,23 @@
     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);
-    assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+    assert.equal(cursor._findRowByNumberAndFile(8, 'right'), row);
+    assert.equal(cursor._findRowByNumberAndFile(5, 'left'), row);
   });
 
-  test('expand context updates stops', done => {
-    sinon.spy(cursorElement, '_updateStops');
+  test('expand context updates stops', async () => {
+    sinon.spy(cursor, '_updateStops');
     MockInteractions.tap(diffElement.shadowRoot
+        .querySelector('gr-context-controls').shadowRoot
         .querySelector('.showContext'));
-    flush(() => {
-      assert.isTrue(cursorElement._updateStops.called);
-      done();
-    });
+    await flush();
+    assert.isTrue(cursor._updateStops.called);
   });
 
   test('updates stops when loading changes', () => {
-    sinon.spy(cursorElement, '_updateStops');
+    sinon.spy(cursor, '_updateStops');
     diffElement.dispatchEvent(new Event('loading-changed'));
-    assert.isTrue(cursorElement._updateStops.called);
-  });
-
-  suite('gr-diff-cursor event tests', () => {
-    let someEmptyDiv;
-
-    setup(() => {
-      someEmptyDiv = emptyFixture.instantiate();
-    });
-
-    teardown(() => sinon.restore());
-
-    test('ready is fired after component is rendered', done => {
-      const cursorElement = document.createElement('gr-diff-cursor');
-      cursorElement.addEventListener('ready', () => {
-        done();
-      });
-      someEmptyDiv.appendChild(cursorElement);
-    });
+    assert.isTrue(cursor._updateStops.called);
   });
 
   suite('multi diff', () => {
@@ -653,18 +638,16 @@
       <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];
+      diffElements = multiDiffFixture.instantiate();
+      cursor = new GrDiffCursor();
 
       // Register the diff with the cursor.
-      cursorElement.push('diffs', ...diffElements);
+      cursor.replaceDiffs(diffElements);
 
       for (const el of diffElements) {
         el.prefs = createDefaultDiffPrefs();
@@ -678,7 +661,7 @@
       // 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());
+      return diffElements.indexOf(cursor.getTargetDiffElement());
     }
 
     test('do not skip loading diffs', async () => {
@@ -692,28 +675,28 @@
       const lastLine = diffElements[0].diff.meta_b.lines;
 
       // Goto second last line of the first diff
-      cursorElement.moveToLineNumber(lastLine - 1, 'right');
+      cursor.moveToLineNumber(lastLine - 1, 'right');
       assert.equal(
-          cursorElement.getTargetLineElement().textContent, lastLine - 1);
+          cursor.getTargetLineElement().textContent, lastLine - 1);
 
       // Can move down until we reach the loading file
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Cannot move down while still loading the diff we would switch to
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 0);
-      assert.equal(cursorElement.getTargetLineElement().textContent, lastLine);
+      assert.equal(cursor.getTargetLineElement().textContent, lastLine);
 
       // Diff 1 finishing to load
       diffElements[1].diff = getMockDiffResponse();
       await diffRenderedPromises[1];
 
       // Now we can go down
-      cursorElement.moveDown();
+      cursor.moveDown();
       assert.equal(getTargetDiffIndex(), 1);
-      assert.equal(cursorElement.getTargetLineElement().textContent, 'File');
+      assert.equal(cursor.getTargetLineElement().textContent, 'File');
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
index 7420dc8..79e7dbc 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.ts
@@ -29,6 +29,7 @@
    *
    */
   getLength(node: Node) {
+    if (node instanceof Comment) return 0;
     return this.getStringLength(node.textContent || '');
   },
 
@@ -133,7 +134,7 @@
 
       if (node instanceof Text) {
         this._annotateText(node, offset, subLength, cssClass);
-      } else if (node instanceof HTMLElement) {
+      } else if (node instanceof Element) {
         this.annotateElement(node, offset, subLength, cssClass);
       }
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
index 65f5e07..d8295a5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.js
@@ -252,6 +252,24 @@
           '0<test-wrapper>123456789<span></span>0</test-wrapper>123456789');
     });
 
+    test('handles comment nodes', () => {
+      const container = document.createElement('div');
+      container.appendChild(document.createComment('comment1'));
+      container.appendChild(document.createTextNode('0123456789'));
+      container.appendChild(document.createComment('comment2'));
+      container.appendChild(document.createElement('span'));
+      container.appendChild(document.createTextNode('0123456789'));
+      GrAnnotation.annotateWithElement(
+          container, 1, 10, {tagName: 'test-wrapper'});
+
+      assert.equal(
+          container.innerHTML,
+          '<!--comment1-->' +
+          '0<test-wrapper>123456789' +
+          '<!--comment2-->' +
+          '<span></span>0</test-wrapper>123456789');
+    });
+
     test('sets sanitized attributes', () => {
       const container = document.createElement('div');
       container.textContent = fullText;
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 6a6e99e..3e292fe 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
@@ -28,8 +28,15 @@
 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';
+import {
+  getLineElByChild,
+  getLineNumberByChild,
+  getRange,
+  getSide,
+  getSideByLineEl,
+} from '../gr-diff/gr-diff-utils';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
 
 interface SidedRange {
   side: Side;
@@ -86,8 +93,7 @@
     );
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.selectionChangeTask?.cancel();
     super.disconnectedCallback();
   }
@@ -359,11 +365,11 @@
   ): NormalizedPosition | null {
     let column;
     if (!node || !this.contains(node)) return null;
-    const lineEl = this.diffBuilder.getLineElByChild(node);
+    const lineEl = getLineElByChild(node);
     if (!lineEl) return null;
-    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     if (!side) return null;
-    const line = this.diffBuilder.getLineNumberByChild(lineEl);
+    const line = getLineNumberByChild(lineEl);
     if (!line || line === FILE || line === 'LOST') return null;
     const contentTd = this.diffBuilder.getContentTdByLineEl(lineEl);
     if (!contentTd) return null;
@@ -568,7 +574,7 @@
   _getLength(node: Node | null): number {
     if (node === null) return 0;
     if (node instanceof Element && node.classList.contains('content')) {
-      return this._getLength(node.querySelector('.contentText')!);
+      return this._getLength(queryAndAssert(node, '.contentText'));
     } else {
       return GrAnnotation.getLength(node);
     }
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 07e83a8..4c1295f 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
@@ -251,7 +251,7 @@
     };
 
     const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
-      const selection = window.getSelection();
+      const selection = document.getSelection();
       const range = document.createRange();
       range.setStart(startNode, startOffset);
       range.setEnd(endNode, endOffset);
@@ -281,7 +281,7 @@
 
     teardown(() => {
       contentStubs = null;
-      window.getSelection().removeAllRanges();
+      document.getSelection().removeAllRanges();
     });
 
     test('single first line', () => {
@@ -389,7 +389,7 @@
     test('collapsed', () => {
       const content = stubContent(138, 'left');
       emulateSelection(content.firstChild, 5, content.firstChild, 5);
-      assert.isOk(window.getSelection().getRangeAt(0).startContainer);
+      assert.isOk(document.getSelection().getRangeAt(0).startContainer);
       assert.isFalse(!!element.selectedRange);
     });
 
@@ -438,7 +438,7 @@
       const contentText = stubContent(140, 'left');
       const contentTd = contentText.parentElement;
 
-      emulateSelection(contentTd.previousElementSibling, 0,
+      emulateSelection(contentTd.parentElement, 0,
           contentText.firstChild, 2);
       assert.isFalse(!!element.selectedRange);
     });
@@ -556,7 +556,7 @@
           content.querySelectorAll('hl')[3], 0,
           content.querySelectorAll('span')[1], 0);
       const spyCall = spy.getCall(0);
-      const range = window.getSelection().getRangeAt(0);
+      const range = document.getSelection().getRangeAt(0);
       assert.notDeepEqual(spyCall.returnValue, range);
     });
 
@@ -584,21 +584,6 @@
       });
       assert.equal(side, 'right');
     });
-
-    test('_fixTripleClickSelection empty line', () => {
-      const startContent = stubContent(146, 'right');
-      const endContent = stubContent(165, 'left');
-      emulateSelection(startContent.firstChild, 0,
-          endContent.parentElement.previousElementSibling, 0);
-      const {range, side} = element.selectedRange;
-      assert.deepEqual(range, {
-        start_line: 146,
-        start_character: 0,
-        end_line: 146,
-        end_character: 84,
-      });
-      assert.equal(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 1f4b778..59bd817 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
@@ -18,12 +18,17 @@
 import '../gr-diff/gr-diff';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-host_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
 import {
+  GerritNav,
+  GeneratedWebLink,
+} from '../../core/gr-navigation/gr-navigation';
+import {
+  anyLineTooLong,
   getLine,
   getRange,
   getSide,
   rangesEqual,
+  SYNTAX_MAX_LINE_LENGTH,
 } from '../gr-diff/gr-diff-utils';
 import {appContext} from '../../../services/app-context';
 import {
@@ -45,13 +50,13 @@
   Base64ImageFile,
   BlameInfo,
   ChangeInfo,
-  CommentRange,
   EditPatchSetNum,
   NumericChangeId,
   ParentPatchSetNum,
   PatchRange,
   PatchSetNum,
   RepoName,
+  UrlEncodedCommentId,
 } from '../../../types/common';
 import {
   DiffInfo,
@@ -80,9 +85,15 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {assertIsDefined} from '../../../utils/common-util';
 import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
+import {TokenHighlightLayer} from '../gr-diff-builder/token-highlight-layer';
 import {Timing} from '../../../constants/reporting';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api';
+import {Subject} from 'rxjs';
+import {RenderPreferences} from '../../../api/diff';
 
-const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+const EMPTY_BLAME = 'No blame information for this diff.';
 
 const EVENT_AGAINST_PARENT = 'diff-against-parent';
 const EVENT_ZERO_REBASE = 'rebase-percent-zero';
@@ -91,10 +102,6 @@
 // Disable syntax highlighting if the overall diff is too large.
 const SYNTAX_MAX_DIFF_LENGTH = 20000;
 
-// If any line of the diff is more than the character limit, then disable
-// syntax highlighting for the entire file.
-const SYNTAX_MAX_LINE_LENGTH = 500;
-
 // 120 lines is good enough threshold for full-sized window viewport
 const NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT = 120;
 
@@ -143,12 +150,6 @@
    * @event show-auth-required
    */
 
-  /**
-   * Fired when a comment is saved or discarded
-   *
-   * @event diff-comments-modified
-   */
-
   @property({type: Number})
   changeNum?: NumericChangeId;
 
@@ -187,10 +188,13 @@
   commitRange?: CommitRange;
 
   @property({type: Object, notify: true})
+  editWeblinks?: GeneratedWebLink[];
+
+  @property({type: Object, notify: true})
   filesWeblinks: FilesWebLinks | {} = {};
 
   @property({type: Boolean, reflectToAttribute: true})
-  hidden = false;
+  override hidden = false;
 
   @property({type: Boolean})
   noRenderOnPrefsChange = false;
@@ -233,6 +237,9 @@
   diff?: DiffInfo;
 
   @property({type: Object})
+  changeComments?: ChangeComments;
+
+  @property({type: Object})
   _fetchDiffPromise: Promise<DiffInfo> | null = null;
 
   @property({type: Object})
@@ -257,6 +264,11 @@
   @property({type: Array})
   _layers: DiffLayer[] = [];
 
+  @property({type: Object})
+  _renderPrefs: RenderPreferences = {
+    num_lines_rendered_at_once: 128,
+  };
+
   private readonly reporting = appContext.reportingService;
 
   private readonly flags = appContext.flagsService;
@@ -267,6 +279,8 @@
 
   private readonly syntaxLayer = new GrSyntaxLayer();
 
+  disconnected$ = new Subject();
+
   constructor() {
     super();
     this.addEventListener(
@@ -279,12 +293,6 @@
       'create-comment',
       e => this._handleCreateComment(e)
     );
-    this.addEventListener('comment-discard', () =>
-      this._handleCommentSaveOrDiscard()
-    );
-    this.addEventListener('comment-save', () =>
-      this._handleCommentSaveOrDiscard()
-    );
     this.addEventListener('render-start', () => this._handleRenderStart());
     this.addEventListener('render-content', () => this._handleRenderContent());
     this.addEventListener('normalize-range', event =>
@@ -295,40 +303,44 @@
     );
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     if (this._canReload()) {
       this.reload();
     }
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this.changeComments = changeComments;
+      });
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
+    this.disconnected$.next();
     this.clear();
     super.disconnectedCallback();
   }
 
-  initLayers() {
-    return getPluginLoader()
-      .awaitPluginsLoaded()
-      .then(() => {
-        assertIsDefined(this.path, 'path');
-        this._layers = this._getLayers(this.path);
-        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();
-      });
+  async initLayers() {
+    const preferencesPromise = appContext.restApiService.getPreferences();
+    await getPluginLoader().awaitPluginsLoaded();
+    const prefs = await preferencesPromise;
+    const enableTokenHighlight = !prefs?.disable_token_highlighting;
+
+    assertIsDefined(this.path, 'path');
+    this._layers = this.getLayers(this.path, enableTokenHighlight);
+    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();
   }
 
   diffChanged(diff?: DiffInfo) {
@@ -371,6 +383,7 @@
       // Not waiting for coverage ranges intentionally as
       // plugin loading should not block the content rendering
 
+      this.editWeblinks = this._getEditWeblinks(diff);
       this.filesWeblinks = this._getFilesWeblinks(diff);
       this.diff = diff;
       const event = (await waitForEventOnce(this, 'render')) as CustomEvent;
@@ -392,16 +405,22 @@
       if (e instanceof Response) {
         this._handleGetDiffError(e);
       } else {
-        console.warn('Error encountered loading diff:', e);
+        this.reporting.error(e);
       }
     } finally {
       this.reporting.timeEnd(Timing.DIFF_TOTAL);
     }
   }
 
-  private _getLayers(path: string): DiffLayer[] {
+  private getLayers(path: string, enableTokenHighlight: boolean): DiffLayer[] {
+    const layers = [];
+    if (enableTokenHighlight) {
+      layers.push(new TokenHighlightLayer(this));
+    }
+    layers.push(this.syntaxLayer);
     // Get layers from plugins (if any).
-    return [this.syntaxLayer, ...this.jsAPI.getDiffLayers(path)];
+    layers.push(...this.jsAPI.getDiffLayers(path));
+    return layers;
   }
 
   clear() {
@@ -469,15 +488,35 @@
               });
             })
             .catch(err => {
-              console.warn('Applying coverage from provider failed: ', err);
+              this.reporting.error(err);
             });
         });
       })
       .catch(err => {
-        console.warn('Loading coverage ranges failed: ', err);
+        this.reporting.error(err);
       });
   }
 
+  _getEditWeblinks(diff: DiffInfo) {
+    if (!this.projectName || !this.commitRange || !this.path) return undefined;
+    return GerritNav.getEditWebLinks(
+      this.projectName,
+      this.commitRange.baseCommit,
+      this.path,
+      {weblinks: diff?.edit_web_links}
+    );
+  }
+
+  @observe('changeComments', 'patchRange', 'file')
+  computeFileThreads(
+    changeComments?: ChangeComments,
+    patchRange?: PatchRange,
+    file?: PatchSetFile
+  ) {
+    if (!changeComments || !patchRange || !file) return;
+    this.threads = changeComments.getThreadsBySideForFile(file, patchRange);
+  }
+
   _getFilesWeblinks(diff: DiffInfo) {
     if (!this.projectName || !this.commitRange || !this.path) return {};
     return {
@@ -529,8 +568,8 @@
       .getBlame(this.changeNum, this.patchRange.patchNum, this.path, true)
       .then(blame => {
         if (!blame || !blame.length) {
-          fireAlert(this, MSG_EMPTY_BLAME);
-          return Promise.reject(MSG_EMPTY_BLAME);
+          fireAlert(this, EMPTY_BLAME);
+          return Promise.reject(EMPTY_BLAME);
         }
 
         this._blame = blame;
@@ -690,14 +729,30 @@
   }
 
   _threadsChanged(threads: CommentThread[]) {
-    // Currently, the only way this is ever changed here is when the initial
-    // 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 threadEls = new Set<GrCommentThread>();
+    const rootIdToThreadEl = new Map<UrlEncodedCommentId, GrCommentThread>();
+    for (const threadEl of this.getThreadEls()) {
+      if (threadEl.rootId) {
+        rootIdToThreadEl.set(threadEl.rootId, threadEl);
+      }
+    }
     for (const thread of threads) {
-      const threadEl = this._createThreadElement(thread);
-      this._attachThreadElement(threadEl);
+      const existingThreadEl =
+        thread.rootId && rootIdToThreadEl.get(thread.rootId);
+      if (existingThreadEl) {
+        this._updateThreadElement(existingThreadEl, thread);
+        threadEls.add(existingThreadEl);
+      } else {
+        const threadEl = this._createThreadElement(thread);
+        this._attachThreadElement(threadEl);
+        threadEls.add(threadEl);
+      }
+    }
+    // Remove all threads that are no longer existing.
+    for (const threadEl of this.getThreadEls()) {
+      if (threadEls.has(threadEl)) continue;
+      const parent = threadEl.parentNode;
+      if (parent) parent.removeChild(threadEl);
     }
     const portedThreadsCount = threads.filter(thread => thread.ported).length;
     const portedThreadsWithoutRange = threads.filter(
@@ -746,14 +801,15 @@
         ? CommentSide.PARENT
         : CommentSide.REVISION;
     if (!this.canCommentOnPatchSetNum(patchNum)) return;
-    const threadEl = this._getOrCreateThread(
-      patchNum,
-      lineNum,
-      side,
-      commentSide,
+    const threadEl = this._getOrCreateThread({
+      comments: [],
       path,
-      range
-    );
+      diffSide: side,
+      commentSide,
+      patchNum,
+      line: lineNum,
+      range,
+    });
     threadEl.addOrEditDraft(lineNum, range);
 
     this.reporting.recordDraftInteraction();
@@ -789,26 +845,13 @@
    * Gets or creates a comment thread at a given location.
    * May provide a range, to get/create a range comment.
    */
-  _getOrCreateThread(
-    patchNum: PatchSetNum,
-    lineNum: LineNumber | undefined,
-    diffSide: Side,
-    commentSide: CommentSide,
-    path: string,
-    range?: CommentRange
-  ): GrCommentThread {
-    let threadEl = this._getThreadEl(lineNum, diffSide, range);
+  _getOrCreateThread(thread: CommentThread): GrCommentThread {
+    let threadEl = this._getThreadEl(thread);
     if (!threadEl) {
-      threadEl = this._createThreadElement({
-        comments: [],
-        path,
-        diffSide,
-        commentSide,
-        patchNum,
-        line: lineNum,
-        range,
-      });
+      threadEl = this._createThreadElement(thread);
       this._attachThreadElement(threadEl);
+    } else {
+      this._updateThreadElement(threadEl, thread);
     }
     return threadEl;
   }
@@ -831,6 +874,11 @@
       'slot',
       `${thread.diffSide}-${thread.line || 'LOST'}`
     );
+    this._updateThreadElement(threadEl, thread);
+    return threadEl;
+  }
+
+  _updateThreadElement(threadEl: GrCommentThread, thread: CommentThread) {
     threadEl.comments = thread.comments;
     threadEl.diffSide = thread.diffSide;
     threadEl.isOnParent = thread.commentSide === CommentSide.PARENT;
@@ -856,41 +904,29 @@
     else threadEl.lineNum = thread.line !== 'FILE' ? thread.line : undefined;
     threadEl.projectName = this.projectName;
     threadEl.range = thread.range;
-    const threadDiscardListener = (e: Event) => {
-      const threadEl = e.currentTarget as Element;
-      const parent = threadEl.parentNode;
-      if (parent) parent.removeChild(threadEl);
-      threadEl.removeEventListener('thread-discard', threadDiscardListener);
-    };
-    threadEl.addEventListener('thread-discard', threadDiscardListener);
-    return threadEl;
   }
 
   /**
    * Gets a comment thread element at a given location.
    * May provide a range, to get a range comment.
    */
-  _getThreadEl(
-    lineNum: LineNumber | undefined,
-    commentSide: Side,
-    range?: CommentRange
-  ): GrCommentThread | null {
+  _getThreadEl(thread: CommentThread): GrCommentThread | null {
     let line: LineInfo;
-    if (commentSide === Side.LEFT) {
-      line = {beforeNumber: lineNum};
-    } else if (commentSide === Side.RIGHT) {
-      line = {afterNumber: lineNum};
+    if (thread.diffSide === Side.LEFT) {
+      line = {beforeNumber: thread.line};
+    } else if (thread.diffSide === Side.RIGHT) {
+      line = {afterNumber: thread.line};
     } else {
-      throw new Error(`Unknown side: ${commentSide}`);
+      throw new Error(`Unknown side: ${thread.diffSide}`);
     }
     function matchesRange(threadEl: GrCommentThread) {
-      return rangesEqual(getRange(threadEl), range);
+      return rangesEqual(getRange(threadEl), thread.range);
     }
 
     const filteredThreadEls = this._filterThreadElsForLocation(
       this.getThreadEls(),
       line,
-      commentSide
+      thread.diffSide
     ).filter(matchesRange);
     return filteredThreadEls.length ? filteredThreadEls[0] : null;
   }
@@ -986,10 +1022,6 @@
       : null;
   }
 
-  _handleCommentSaveOrDiscard() {
-    fireEvent(this, 'diff-comments-modified');
-  }
-
   _syntaxHighlightingEnabledChanged(_syntaxHighlightingEnabled: boolean) {
     this.syntaxLayer.setEnabled(_syntaxHighlightingEnabled);
   }
@@ -1004,7 +1036,7 @@
     if (!preferenceChangeRecord?.base?.syntax_highlighting || !diff) {
       return false;
     }
-    if (this._anyLineTooLong(diff)) {
+    if (anyLineTooLong(diff)) {
       fireAlert(
         this,
         `Files with line longer than ${SYNTAX_MAX_LINE_LENGTH} characters` +
@@ -1023,20 +1055,6 @@
     return true;
   }
 
-  /**
-   * @return whether any of the lines in diff are longer
-   * than SYNTAX_MAX_LINE_LENGTH.
-   */
-  _anyLineTooLong(diff?: DiffInfo) {
-    if (!diff) return false;
-    return diff.content.some(section => {
-      const lines = section.ab
-        ? section.ab
-        : (section.a || []).concat(section.b || []);
-      return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
-    });
-  }
-
   _listenToViewportRender() {
     const renderUpdateListener: DiffLayerListener = start => {
       if (start > NUM_OF_LINES_THRESHOLD_FOR_VIEWPORT) {
@@ -1158,7 +1176,6 @@
     'normalize-range': CustomEvent;
     'diff-context-expanded': CustomEvent<DiffContextExpandedEventDetail>;
     'create-comment': CustomEvent;
-    'comment-discard': CustomEvent;
     'comment-update': CustomEvent;
     'comment-save': CustomEvent;
     'root-id-changed': 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 3dd2b24..e4efb5a 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
@@ -27,6 +27,7 @@
     is-image-diff="[[isImageDiff]]"
     hidden$="[[hidden]]"
     no-render-on-prefs-change="[[noRenderOnPrefsChange]]"
+    render-prefs="[[_renderPrefs]]"
     line-wrapping="[[lineWrapping]]"
     view-mode="[[viewMode]]"
     line-of-interest="[[lineOfInterest]]"
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 5e1b5ff..6facdca 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
@@ -17,15 +17,16 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-diff-host.js';
-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 {createCommentThreads} from '../../../utils/comment-util.js';
-import {Side, createDefaultDiffPrefs} 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, Side} from '../../../constants/constants.js';
+import {_testOnly_resetState} from '../../../services/comments/comments-model.js';
+import {createChange, createComment, createCommentThread} from '../../../test/test-data-generators.js';
+import {addListenerForTest, mockPromise, stubRestApi} from '../../../test/test-utils.js';
 import {EditPatchSetNum, ParentPatchSetNum} from '../../../types/common.js';
+import {CoverageType} from '../../../types/types.js';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import {GrDiffBuilderImage} from '../gr-diff-builder/gr-diff-builder-image.js';
 
 const basicFixture = fixtureFromElement('gr-diff-host');
 
@@ -42,6 +43,7 @@
     element.path = 'some/path';
     sinon.stub(element.reporting, 'time');
     sinon.stub(element.reporting, 'timeEnd');
+    _testOnly_resetState();
     await flush();
   });
 
@@ -62,60 +64,14 @@
     });
   });
 
-  test('thread-discard handling', () => {
-    const threads = createCommentThreads([
-      {
-        id: 4711,
-        diffSide: Side.LEFT,
-        updated: '2015-12-20 15:01:20.396000000',
-        patch_set: 1,
-        path: 'some/path',
-      },
-      {
-        id: 42,
-        diffSide: Side.LEFT,
-        updated: '2017-12-20 15:01:20.396000000',
-        patch_set: 1,
-        path: 'some/path',
-      },
-    ]);
-    element._parentIndex = 1;
-    element.changeNum = 2;
-    element.path = 'some/path';
-    element.projectName = 'Some project';
-    const threadEls = threads.map(
-        thread => {
-          const threadEl = element._createThreadElement(thread);
-          // Polymer 2 doesn't fire ready events and doesn't execute
-          // observers if element is not added to the Dom.
-          // See https://github.com/Polymer/old-docs-site/issues/2322
-          // and https://github.com/Polymer/polymer/issues/4526
-          element._attachThreadElement(threadEl);
-          return threadEl;
-        });
-    assert.equal(threadEls.length, 2);
-    assert.equal(threadEls[0].comments[0].id, 4711);
-    assert.equal(threadEls[1].comments[0].id, 42);
-    for (const threadEl of threadEls) {
-      element.appendChild(threadEl);
-    }
-
-    threadEls[0].dispatchEvent(
-        new CustomEvent('thread-discard', {detail: {rootId: 4711}}));
-    const attachedThreads = element.querySelectorAll('gr-comment-thread');
-    assert.equal(attachedThreads.length, 1);
-    assert.equal(attachedThreads[0].comments[0].id, 42);
-  });
-
   suite('render reporting', () => {
-    test('starts total and content timer on render-start', done => {
+    test('starts total and content timer on render-start', () => {
       element.dispatchEvent(
           new CustomEvent('render-start', {bubbles: true, composed: true}));
       assert.isTrue(element.reporting.time.calledWithExactly(
           'Diff Total Render'));
       assert.isTrue(element.reporting.time.calledWithExactly(
           'Diff Content Render'));
-      done();
     });
 
     test('ends content timer on render-content', () => {
@@ -211,7 +167,7 @@
     assert.isTrue(cancelStub.called);
   });
 
-  test('reload() loads files weblinks', () => {
+  test('reload() loads files weblinks', async () => {
     element.change = createChange();
     const weblinksStub = sinon.stub(GerritNav, '_generateWeblinks')
         .returns({name: 'stubb', url: '#s'});
@@ -222,50 +178,60 @@
     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'}],
-      });
+
+    await element.reload();
+
+    assert.equal(weblinksStub.callCount, 3);
+    assert.deepEqual(weblinksStub.firstCall.args[0], {
+      commit: 'test-base',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.EDIT});
+    assert.deepEqual(element.editWeblinks, [{
+      name: 'stubb', url: '#s',
+    }]);
+    assert.deepEqual(weblinksStub.secondCall.args[0], {
+      commit: 'test-base',
+      file: 'test-path',
+      options: {
+        weblinks: undefined,
+      },
+      repo: 'test-project',
+      type: GerritNav.WeblinkType.FILE});
+    assert.deepEqual(weblinksStub.thirdCall.args[0], {
+      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 => {
+  test('prefetch getDiff', async () => {
     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();
-    });
+    await element._getDiff();
+    assert.isTrue(diffRestApiStub.calledOnce);
   });
 
-  test('_getDiff handles null diff responses', done => {
+  test('_getDiff handles null diff responses', async () => {
     stubRestApi('getDiff').returns(Promise.resolve(null));
     element.changeNum = 123;
     element.patchRange = {basePatchNum: 1, patchNum: 2};
     element.path = 'file.txt';
-    element._getDiff().then(done);
+    await element._getDiff();
   });
 
   test('reload resolves on error', () => {
@@ -343,7 +309,7 @@
       };
     });
 
-    test('renders image diffs with same file name', done => {
+    test('renders image diffs with same file name', async () => {
       const mockDiff = {
         meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
         meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
@@ -374,6 +340,7 @@
         },
       }));
 
+      const promise = mockPromise();
       const rendered = () => {
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
@@ -409,7 +376,7 @@
           leftLoaded = true;
           if (rightLoaded) {
             element.removeEventListener('render', rendered);
-            done();
+            promise.resolve();
           }
         });
 
@@ -422,7 +389,7 @@
           rightLoaded = true;
           if (leftLoaded) {
             element.removeEventListener('render', rendered);
-            done();
+            promise.resolve();
           }
         });
       };
@@ -430,9 +397,10 @@
       element.addEventListener('render', rendered);
       element.prefs = createDefaultDiffPrefs();
       element.reload();
+      await promise;
     });
 
-    test('renders image diffs with a different file name', done => {
+    test('renders image diffs with a different file name', async () => {
       const mockDiff = {
         meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
         meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
@@ -463,6 +431,7 @@
         },
       }));
 
+      const promise = mockPromise();
       const rendered = () => {
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
@@ -500,7 +469,7 @@
           leftLoaded = true;
           if (rightLoaded) {
             element.removeEventListener('render', rendered);
-            done();
+            promise.resolve();
           }
         });
 
@@ -513,7 +482,7 @@
           rightLoaded = true;
           if (leftLoaded) {
             element.removeEventListener('render', rendered);
-            done();
+            promise.resolve();
           }
         });
       };
@@ -521,9 +490,10 @@
       element.addEventListener('render', rendered);
       element.prefs = createDefaultDiffPrefs();
       element.reload();
+      await promise;
     });
 
-    test('renders added image', done => {
+    test('renders added image', async () => {
       const mockDiff = {
         meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
           lines: 560},
@@ -549,6 +519,7 @@
         },
       }));
 
+      const promise = mockPromise();
       element.addEventListener('render', () => {
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
@@ -562,14 +533,15 @@
 
         assert.isNotOk(leftImage);
         assert.isOk(rightImage);
-        done();
+        promise.resolve();
       });
 
       element.prefs = createDefaultDiffPrefs();
       element.reload();
+      await promise;
     });
 
-    test('renders removed image', done => {
+    test('renders removed image', async () => {
       const mockDiff = {
         meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
           lines: 560},
@@ -595,6 +567,7 @@
         revisionImage: null,
       }));
 
+      const promise = mockPromise();
       element.addEventListener('render', () => {
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
@@ -608,14 +581,15 @@
 
         assert.isOk(leftImage);
         assert.isNotOk(rightImage);
-        done();
+        promise.resolve();
       });
 
       element.prefs = createDefaultDiffPrefs();
       element.reload();
+      await promise;
     });
 
-    test('does not render disallowed image type', done => {
+    test('does not render disallowed image type', async () => {
       const mockDiff = {
         meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
           lines: 560},
@@ -643,6 +617,7 @@
         revisionImage: null,
       }));
 
+      const promise = mockPromise();
       element.addEventListener('render', () => {
         // Recognizes that it should be an image diff.
         assert.isTrue(element.isImageDiff);
@@ -651,11 +626,12 @@
         const leftImage =
             element.$.diff.$.diffTable.querySelector('td.left img');
         assert.isNotOk(leftImage);
-        done();
+        promise.resolve();
       });
 
       element.prefs = createDefaultDiffPrefs();
       element.reload();
+      await promise;
     });
   });
 
@@ -1148,6 +1124,43 @@
       assert.equal(threads[0].path, element.file.path);
     });
 
+    test('multiple threads created on the same range', () => {
+      element.patchRange = {
+        basePatchNum: 2,
+        patchNum: 3,
+      };
+      element.file = {basePath: 'file_renamed.txt', path: element.path};
+
+      const comment = createComment();
+      comment.range = {
+        start_line: 1,
+        start_character: 1,
+        end_line: 2,
+        end_character: 2,
+      };
+      const thread = createCommentThread([comment]);
+      element.threads = [thread];
+
+      let threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+
+      assert.equal(threads.length, 1);
+      element.threads= [...element.threads, thread];
+
+      threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      // Threads have same rootId so element is reused
+      assert.equal(threads.length, 1);
+
+      const newThread = {...thread};
+      newThread.rootId = 'differentRootId';
+      element.threads= [...element.threads, newThread];
+      threads = dom(element.$.diff)
+          .queryDistributedElements('gr-comment-thread');
+      // New thread has a different rootId
+      assert.equal(threads.length, 2);
+    });
+
     test('thread should use new file path if first created' +
     'on patch set (left) but is base', () => {
       const diffSide = Side.LEFT;
@@ -1164,8 +1177,8 @@
         },
       }));
 
-      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].diffSide, diffSide);
@@ -1188,8 +1201,8 @@
         },
       }));
 
-      const threads = dom(element.$.diff)
-          .queryDistributedElements('gr-comment-thread');
+      const threads =
+          dom(element.$.diff).queryDistributedElements('gr-comment-thread');
       assert.equal(threads.length, 0);
       assert.isTrue(alertSpy.called);
     });
@@ -1291,7 +1304,7 @@
     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);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
     });
 
     test('rendering normal-sized diff does not disable syntax', () => {
@@ -1345,7 +1358,7 @@
     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);
+      assert.equal(element.$.diff.layers[1], element.syntaxLayer);
     });
 
     test('syntax layer should be disabled', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
index 77fe299..32f5f39 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/gr-image-viewer.ts
@@ -18,28 +18,36 @@
 import '@polymer/paper-card/paper-card';
 import '@polymer/paper-checkbox/paper-checkbox';
 import '@polymer/paper-dropdown-menu/paper-dropdown-menu';
+import '@polymer/paper-fab/paper-fab';
+import '@polymer/paper-icon-button/paper-icon-button';
 import '@polymer/paper-item/paper-item';
 import '@polymer/paper-listbox/paper-listbox';
 import './gr-overview-image';
 import './gr-zoomed-image';
 
-import {
-  css,
-  customElement,
-  html,
-  internalProperty,
-  LitElement,
-  property,
-  PropertyValues,
-  query,
-} from 'lit-element';
-import {classMap} from 'lit-html/directives/class-map';
-import {StyleInfo, styleMap} from 'lit-html/directives/style-map';
+import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
+import {RESEMBLEJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/resemblejs_config';
 
-import {Dimensions, fitToFrame, FrameConstrainer, Point, Rect} from './util';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {classMap} from 'lit/directives/class-map';
+import {StyleInfo, styleMap} from 'lit/directives/style-map';
+
+import {
+  createEvent,
+  Dimensions,
+  fitToFrame,
+  FrameConstrainer,
+  Point,
+  Rect,
+} from './util';
 
 const DRAG_DEAD_ZONE_PIXELS = 5;
 
+const DEFAULT_AUTOMATIC_BLINK_TIME_MS = 1000;
+
+const AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS = 350;
+
 /**
  * This components allows the user to rapidly switch between two given images
  * rendered in the same location, to make subtle differences more noticeable.
@@ -47,23 +55,33 @@
  */
 @customElement('gr-image-viewer')
 export class GrImageViewer extends LitElement {
-  // URL for the image to use as base.
+  /** URL for the image to use as base. */
   @property({type: String}) baseUrl = '';
 
-  // URL for the image to use as revision.
+  /** URL for the image to use as revision. */
   @property({type: String}) revisionUrl = '';
 
-  @internalProperty() protected baseSelected = true;
+  /**
+   * When true, cycle automatically between base and revision image, if both
+   * are available.
+   */
+  @property({type: Boolean}) automaticBlink = false;
 
-  @internalProperty() protected scaledSelected = true;
+  @state() protected baseSelected = false;
 
-  @internalProperty() protected followMouse = false;
+  @state() protected scaledSelected = true;
 
-  @internalProperty() protected scale = 1;
+  @state() protected followMouse = false;
 
-  @internalProperty() protected checkerboardSelected = true;
+  @state() protected scale = 1;
 
-  @internalProperty() protected zoomedImageStyle: StyleInfo = {};
+  @state() protected checkerboardSelected = true;
+
+  @state() protected backgroundColor = '';
+
+  @state() protected automaticBlinkShown = false;
+
+  @state() protected zoomedImageStyle: StyleInfo = {};
 
   @query('.imageArea') protected imageArea!: HTMLDivElement;
 
@@ -71,18 +89,20 @@
 
   @query('#source-image') protected sourceImage!: HTMLImageElement;
 
+  @query('#automatic-blink-button') protected automaticBlinkButton?: Element;
+
   private imageSize: Dimensions = {width: 0, height: 0};
 
-  @internalProperty()
+  @state()
   protected magnifierSize: Dimensions = {width: 0, height: 0};
 
-  @internalProperty()
+  @state()
   protected magnifierFrame: Rect = {
     origin: {x: 0, y: 0},
     dimensions: {width: 0, height: 0},
   };
 
-  @internalProperty()
+  @state()
   protected overviewFrame: Rect = {
     origin: {x: 0, y: 0},
     dimensions: {width: 0, height: 0},
@@ -97,7 +117,13 @@
     2,
   ];
 
-  @internalProperty() protected grabbing = false;
+  @state() protected grabbing = false;
+
+  @state() protected canHighlightDiffs = false;
+
+  @state() protected diffHighlightSrc?: string;
+
+  @state() protected showHighlight = false;
 
   private ownsMouseDown = false;
 
@@ -120,16 +146,38 @@
     }
   );
 
-  static styles = css`
+  // Ensure constant function references, so that render() does not bind a new
+  // event listener on every call, as it would with lambdas.
+  private createColorPickerCallback(color: string) {
+    return {color, callback: () => this.pickColor(color)};
+  }
+
+  private readonly colorPickerCallbacks = [
+    this.createColorPickerCallback('#fff'),
+    this.createColorPickerCallback('#000'),
+    this.createColorPickerCallback('#aaa'),
+  ];
+
+  private automaticBlinkTimer?: ReturnType<typeof setInterval>;
+
+  // TODO(hermannloose): Make GrLibLoader a singleton.
+  private static readonly libLoader = new GrLibLoader();
+
+  static override styles = css`
     :host {
-      display: flex;
+      display: grid;
+      grid-template-rows: 1fr auto;
+      grid-template-columns: 1fr auto;
       width: 100%;
       height: 100%;
       box-sizing: border-box;
+      text-align: initial !important;
       font-size: var(--font-size-normal);
       --image-border-width: 2px;
     }
     .imageArea {
+      grid-row-start: 1;
+      grid-column-start: 1;
       box-sizing: border-box;
       flex-grow: 1;
       overflow: hidden;
@@ -139,6 +187,7 @@
       margin: var(--spacing-m);
       padding: var(--image-border-width);
       max-height: 100%;
+      position: relative;
     }
     #spacer {
       visibility: hidden;
@@ -157,6 +206,21 @@
     gr-zoomed-image.revision {
       border-color: var(--revision-image-border-color, rgb(170, 242, 170));
     }
+    #automatic-blink-button {
+      position: absolute;
+      right: var(--spacing-xl);
+      bottom: var(--spacing-xl);
+      opacity: 0;
+      transition: opacity 200ms ease;
+      --paper-fab-background: var(--primary-button-background-color);
+      --paper-fab-keyboard-focus-background: var(
+        --primary-button-background-color
+      );
+    }
+    #automatic-blink-button.show,
+    #automatic-blink-button:focus-visible {
+      opacity: 1;
+    }
     .checkerboard {
       --square-size: var(--checkerboard-square-size, 10px);
       --square-color: var(--checkerboard-square-color, #808080);
@@ -174,7 +238,21 @@
         var(--square-size) calc(-1 * var(--square-size)),
         calc(-1 * var(--square-size)) 0;
     }
+    .dimensions {
+      grid-row-start: 2;
+      justify-self: center;
+      align-self: center;
+      background: var(--primary-button-background-color);
+      color: var(--primary-button-text-color);
+      font-family: var(--font-family);
+      font-size: var(--font-size-small);
+      line-height: var(--line-height-small);
+      border-radius: var(--border-radius, 4px);
+      margin: var(--spacing-s);
+      padding: var(--spacing-xxs) var(--spacing-s);
+    }
     .controls {
+      grid-column-start: 2;
       flex-grow: 0;
       display: flex;
       flex-direction: column;
@@ -186,55 +264,182 @@
       padding: var(--spacing-m);
       font: var(--image-diff-button-font);
       text-transform: var(--image-diff-button-text-transform, uppercase);
+      outline: 1px solid transparent;
+      border: 1px solid var(--primary-button-background-color);
     }
-    paper-button[unelevated] {
+    paper-button.unelevated {
       color: var(--primary-button-text-color);
       background-color: var(--primary-button-background-color);
     }
-    paper-button[outlined] {
+    paper-button.outlined {
       color: var(--primary-button-background-color);
-      border-color: var(--primary-button-background-color);
     }
     #version-switcher {
       display: flex;
-      margin: var(--spacing-xl);
+      align-items: center;
+      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+      /* Start a stacking context to contain FAB below. */
+      z-index: 0;
     }
     #version-switcher paper-button {
-      flex-basis: 0;
       flex-grow: 1;
       margin: 0;
+      /*
+        The floating action button below overlaps part of the version buttons.
+        This min-width ensures the button text still appears somewhat balanced.
+        */
+      min-width: 7rem;
+    }
+    #version-switcher paper-fab {
+      /* Round button overlaps Base and Revision buttons. */
+      z-index: 1;
+      margin: 0 -12px;
+      /* Styled as an outlined button. */
+      color: var(--primary-button-background-color);
+      border: 1px solid var(--primary-button-background-color);
+      --paper-fab-background: var(--primary-background-color);
+      --paper-fab-keyboard-focus-background: var(--primary-background-color);
     }
     #version-explanation {
       color: var(--deemphasized-text-color);
       text-align: center;
-      margin: var(--spacing-xl);
+      margin: var(--spacing-xl) var(--spacing-xl) var(--spacing-m);
+    }
+    #highlight-changes {
+      margin: var(--spacing-m) var(--spacing-xl);
     }
     gr-overview-image {
       min-width: 200px;
       min-height: 150px;
+      margin-top: var(--spacing-m);
     }
     #zoom-control {
       margin: 0 var(--spacing-xl);
     }
+    paper-item {
+      cursor: pointer;
+    }
+    paper-item:hover {
+      background-color: var(--hover-background-color);
+    }
     #follow-mouse {
+      margin: var(--spacing-m) var(--spacing-xl);
+    }
+    .color-picker {
       margin: var(--spacing-m) var(--spacing-xl) 0;
     }
+    .color-picker .label {
+      margin-bottom: var(--spacing-s);
+    }
+    .color-picker .options {
+      display: flex;
+      /* Ignore selection border for alignment, for visual balance. */
+      margin-left: -3px;
+    }
+    .color-picker-button {
+      border-width: 2px;
+      border-style: solid;
+      border-color: transparent;
+      border-radius: 50%;
+      width: 24px;
+      height: 24px;
+      padding: 1px;
+    }
+    .color-picker-button.selected {
+      border-color: var(--primary-button-background-color);
+    }
+    .color-picker-button:focus-within:not(.selected) {
+      /* Not an actual outline, as those do not follow border-radius. */
+      border-color: var(--outline-color-focus);
+    }
+    .color-picker-button .color {
+      border: 1px solid var(--border-color);
+      border-radius: 50%;
+      width: 100%;
+      height: 100%;
+      box-sizing: border-box;
+    }
+    #source-plus-highlight-container {
+      position: relative;
+    }
+    #source-plus-highlight-container img {
+      position: absolute;
+      top: 0;
+      left: 0;
+    }
   `;
 
-  render() {
+  private renderColorPickerButton(color: string, colorPicked: () => void) {
+    const selected =
+      color === this.backgroundColor && !this.checkerboardSelected;
+    return html`
+      <div
+        class="${classMap({
+          'color-picker-button': true,
+          selected,
+        })}"
+      >
+        <paper-icon-button
+          class="color"
+          style="${styleMap({backgroundColor: color})}"
+          @click="${colorPicked}"
+        ></paper-icon-button>
+      </div>
+    `;
+  }
+
+  private renderCheckerboardButton() {
+    return html`
+      <div
+        class="${classMap({
+          'color-picker-button': true,
+          selected: this.checkerboardSelected,
+        })}"
+      >
+        <paper-icon-button
+          class="color checkerboard"
+          @click="${this.pickCheckerboard}"
+        >
+        </paper-icon-button>
+      </div>
+    `;
+  }
+
+  override render() {
     const src = this.baseSelected ? this.baseUrl : this.revisionUrl;
 
     const sourceImage = html`
       <img
         id="source-image"
         src="${src}"
-        class="${classMap({
-          checkerboard: this.checkerboardSelected,
+        class="${classMap({checkerboard: this.checkerboardSelected})}"
+        style="${styleMap({
+          backgroundColor: this.checkerboardSelected
+            ? ''
+            : this.backgroundColor,
         })}"
         @load="${this.updateSizes}"
       />
     `;
 
+    const sourceImageWithHighlight = html`
+      <div id="source-plus-highlight-container">
+        ${sourceImage}
+        <img
+          id="highlight-image"
+          style="${styleMap({
+            opacity: this.showHighlight ? '1' : '0',
+            // When the highlight layer is not being shown, saving the image or
+            // opening it in a new tab from the context menu, e.g. for external
+            // comparison, should give back the source image, not the highlight
+            // layer.
+            'pointer-events': this.showHighlight ? 'auto' : 'none',
+          })}"
+          src="${this.diffHighlightSrc}"
+        />
+      </div>
+    `;
+
     const versionExplanation = html`
       <div id="version-explanation">
         This file is being ${this.revisionUrl ? 'added' : 'deleted'}.
@@ -243,20 +448,28 @@
 
     // This uses the unelevated and outlined attributes from mwc-button with
     // manual styling, for a more seamless transition later.
+    const leftClasses = {
+      left: true,
+      unelevated: this.baseSelected,
+      outlined: !this.baseSelected,
+    };
+    const rightClasses = {
+      right: true,
+      unelevated: !this.baseSelected,
+      outlined: this.baseSelected,
+    };
     const versionToggle = html`
       <div id="version-switcher">
         <paper-button
-          class="left"
-          ?unelevated=${this.baseSelected}
-          ?outlined=${!this.baseSelected}
+          class="${classMap(leftClasses)}"
           @click="${this.selectBase}"
         >
           Base
         </paper-button>
+        <paper-fab mini icon="gr-icons:swapHoriz" @click="${this.manualBlink}">
+        </paper-fab>
         <paper-button
-          class="right"
-          ?unelevated=${!this.baseSelected}
-          ?outlined=${this.baseSelected}
+          class="${classMap(rightClasses)}"
           @click="${this.selectRevision}"
         >
           Revision
@@ -268,12 +481,32 @@
       ${this.baseUrl && this.revisionUrl ? versionToggle : versionExplanation}
     `;
 
+    const highlightSwitcher = this.diffHighlightSrc
+      ? html`
+          <paper-checkbox
+            id="highlight-changes"
+            ?checked="${this.showHighlight}"
+            @change="${this.showHighlightChanged}"
+          >
+            Highlight differences
+          </paper-checkbox>
+        `
+      : '';
+
     const overviewImage = html`
       <gr-overview-image
         .frameRect="${this.overviewFrame}"
         @center-updated="${this.onOverviewCenterUpdated}"
       >
-        <img src="${src}" class="checkerboard" />
+        <img
+          src="${src}"
+          class="${classMap({checkerboard: this.checkerboardSelected})}"
+          style="${styleMap({
+            backgroundColor: this.checkerboardSelected
+              ? ''
+              : this.backgroundColor,
+          })}"
+        />
       </gr-overview-image>
     `;
 
@@ -282,7 +515,7 @@
         <paper-listbox
           slot="dropdown-content"
           selected="fit"
-          attr-for-selected="value"
+          .attrForSelected="${'value'}"
           @selected-changed="${this.zoomControlChanged}"
         >
           ${this.zoomLevels.map(
@@ -306,6 +539,18 @@
       </paper-checkbox>
     `;
 
+    const backgroundPicker = html`
+      <div class="color-picker">
+        <div class="label">Background</div>
+        <div class="options">
+          ${this.renderCheckerboardButton()}
+          ${this.colorPickerCallbacks.map(({color, callback}) =>
+            this.renderColorPickerButton(color, callback)
+          )}
+        </div>
+      </div>
+    `;
+
     /*
      * We want the content to fill the available space until it can display
      * without being cropped, the maximum of which will be determined by
@@ -327,64 +572,45 @@
       ></div>
     `;
 
-    // To pass CSS mixins for @apply to Polymer components, they need to be
-    // wrapped in a <custom-style>.
+    const automaticBlink = html`
+      <paper-fab
+        id="automatic-blink-button"
+        class="${classMap({show: this.automaticBlinkShown})}"
+        title="Automatic blink"
+        icon="gr-icons:${this.automaticBlink ? 'pause' : 'playArrow'}"
+        @click="${this.toggleAutomaticBlink}"
+      >
+      </paper-fab>
+    `;
+
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
     const customStyle = html`
-      <custom-style>
-        <style>
-            paper-button.left {
-              --paper-button: {
-                border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
-              }
-            }
-            paper-button.left[outlined] {
-              --paper-button: {
-                border-radius: 4px 0 0 4px;
-                border-width: 1px 0 1px 1px;
-                border-style: solid;
-                border-color: var(--primary-button-background-color);
-              }
-            }
-            paper-button.right {
-              --paper-button: {
-                border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
-              }
-            }
-            paper-button.right[outlined] {
-              --paper-button: {
-                border-radius: 0 4px 4px 0;
-                border-width: 1px 1px 1px 0;
-                border-style: solid;
-                border-color: var(--primary-button-background-color);
-              }
-            }
-            paper-item {
-              cursor: pointer;
-              --paper-item-min-height: 48;
-              --paper-item: {
-                min-height: 48px;
-                padding: 0 var(--spacing-xl);
-              }
-              --paper-item-focused-before: {
-                background-color: var(--selection-background-color);
-              }
-              --paper-item-focused: {
-                background-color: var(--selection-background-color);
-              }
-            }
+      <style>
+        paper-item {
+          --paper-item-min-height: 48;
+          --paper-item: {
+            min-height: 48px;
+            padding: 0 var(--spacing-xl);
           }
-          paper-item:hover {
-            background-color: var(--hover-background-color);
+          --paper-item-focused-before: {
+            background-color: var(--selection-background-color);
           }
-        </style>
-      </custom-style>
+          --paper-item-focused: {
+            background-color: var(--selection-background-color);
+          }
+        }
+      </style>
     `;
 
     return html`
       ${customStyle}
-      <div class="imageArea" @mousemove="${this.mousemoveMagnifier}">
+      <div
+        class="imageArea"
+        @mousemove="${this.mousemoveImageArea}"
+        @mouseleave="${this.mouseleaveImageArea}"
+      >
         <gr-zoomed-image
           class="${classMap({
             base: this.baseSelected,
@@ -402,71 +628,207 @@
           @mouseleave="${this.mouseleaveMagnifier}"
           @dragstart="${this.dragstartMagnifier}"
         >
-          ${sourceImage}
+          ${sourceImageWithHighlight}
         </gr-zoomed-image>
-        ${spacer}
+        ${this.baseUrl && this.revisionUrl ? automaticBlink : ''} ${spacer}
+      </div>
+
+      <div class="dimensions">
+        ${this.imageSize.width} x ${this.imageSize.height}
       </div>
 
       <paper-card class="controls">
-        ${versionSwitcher} ${overviewImage} ${zoomControl}
-        ${!this.scaledSelected ? followMouse : ''}
+        ${versionSwitcher} ${highlightSwitcher} ${overviewImage} ${zoomControl}
+        ${!this.scaledSelected ? followMouse : ''} ${backgroundPicker}
       </paper-card>
     `;
   }
 
-  firstUpdated() {
+  override firstUpdated() {
     this.resizeObserver.observe(this.imageArea, {box: 'content-box'});
+    GrImageViewer.libLoader.getLibrary(RESEMBLEJS_LIBRARY_CONFIG).then(() => {
+      this.canHighlightDiffs = true;
+      this.computeDiffImage();
+    });
   }
 
   // We don't want property changes in updateSizes() to trigger infinite update
   // loops, so we perform this in update() instead of updated().
-  update(changedProperties: PropertyValues) {
+  override update(changedProperties: PropertyValues) {
+    // eslint-disable-next-line lit/no-property-change-update
     if (!this.baseUrl) this.baseSelected = false;
+    // eslint-disable-next-line lit/no-property-change-update
     if (!this.revisionUrl) this.baseSelected = true;
     this.updateSizes();
     super.update(changedProperties);
   }
 
-  updated(changedProperties: PropertyValues) {
+  override updated(changedProperties: PropertyValues) {
     if (
       (changedProperties.has('baseUrl') && this.baseSelected) ||
       (changedProperties.has('revisionUrl') && !this.baseSelected)
     ) {
       this.frameConstrainer.requestCenter({x: 0, y: 0});
     }
+    if (changedProperties.has('automaticBlink')) {
+      this.updateAutomaticBlink();
+    }
+    if (
+      this.canHighlightDiffs &&
+      (changedProperties.has('baseUrl') || changedProperties.has('revisionUrl'))
+    ) {
+      this.computeDiffImage();
+    }
+  }
+
+  private computeDiffImage() {
+    if (!(this.baseUrl && this.revisionUrl)) return;
+    window
+      .resemble(this.baseUrl)
+      .compareTo(this.revisionUrl)
+      // By default Resemble.js applies some color / alpha tolerance as well as
+      // min / max brightness beyond which to ignore changes. Until we have
+      // controls to let the user affect these options, always highlight all
+      // changed pixels.
+      .ignoreNothing()
+      .onComplete(result => {
+        this.diffHighlightSrc = result.getImageDataUrl();
+      });
   }
 
   selectBase() {
     if (!this.baseUrl) return;
     this.baseSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'base'})
+    );
   }
 
   selectRevision() {
     if (!this.revisionUrl) return;
     this.baseSelected = false;
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'revision'})
+    );
   }
 
-  toggleImage() {
+  manualBlink() {
+    this.toggleImage();
+    this.dispatchEvent(
+      createEvent({type: 'version-switcher-clicked', button: 'switch'})
+    );
+  }
+
+  private toggleImage() {
     if (this.baseUrl && this.revisionUrl) {
       this.baseSelected = !this.baseSelected;
     }
   }
 
+  toggleAutomaticBlink() {
+    this.automaticBlink = !this.automaticBlink;
+    this.dispatchEvent(
+      createEvent({type: 'automatic-blink-changed', value: this.automaticBlink})
+    );
+  }
+
+  private updateAutomaticBlink() {
+    if (this.automaticBlink) {
+      this.toggleImage();
+      this.setBlinkInterval();
+    } else {
+      this.clearBlinkInterval();
+    }
+  }
+
+  private setBlinkInterval() {
+    this.clearBlinkInterval();
+    this.automaticBlinkTimer = setInterval(() => {
+      this.toggleImage();
+    }, DEFAULT_AUTOMATIC_BLINK_TIME_MS);
+  }
+
+  private clearBlinkInterval() {
+    if (this.automaticBlinkTimer) {
+      clearInterval(this.automaticBlinkTimer);
+      this.automaticBlinkTimer = undefined;
+    }
+  }
+
+  showHighlightChanged() {
+    this.toggleHighlight('controls');
+  }
+
+  private toggleHighlight(source: 'controls' | 'magnifier') {
+    this.showHighlight = !this.showHighlight;
+    this.dispatchEvent(
+      createEvent({
+        type: 'highlight-changes-changed',
+        value: this.showHighlight,
+        source,
+      })
+    );
+  }
+
   zoomControlChanged(event: CustomEvent) {
     const value = event.detail.value;
     if (!value) return;
     if (value === 'fit') {
       this.scaledSelected = true;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: 'fit'})
+      );
     }
     if (value > 0) {
       this.scaledSelected = false;
       this.scale = value;
+      this.dispatchEvent(
+        createEvent({type: 'zoom-level-changed', scale: value})
+      );
     }
     this.updateSizes();
   }
 
   followMouseChanged() {
     this.followMouse = !this.followMouse;
+    this.dispatchEvent(
+      createEvent({type: 'follow-mouse-changed', value: this.followMouse})
+    );
+  }
+
+  pickColor(value: string) {
+    this.checkerboardSelected = false;
+    this.backgroundColor = value;
+    this.dispatchEvent(createEvent({type: 'background-color-changed', value}));
+  }
+
+  pickCheckerboard() {
+    this.checkerboardSelected = true;
+    this.dispatchEvent(
+      createEvent({type: 'background-color-changed', value: 'checkerboard'})
+    );
+  }
+
+  mousemoveImageArea(event: MouseEvent) {
+    if (this.automaticBlinkButton) {
+      this.updateAutomaticBlinkVisibility(event);
+    }
+    this.mousemoveMagnifier(event);
+  }
+
+  private updateAutomaticBlinkVisibility(event: MouseEvent) {
+    const rect = this.automaticBlinkButton!.getBoundingClientRect();
+    const centerX = rect.left + (rect.right - rect.left) / 2;
+    const centerY = rect.top + (rect.bottom - rect.top) / 2;
+    const distX = Math.abs(centerX - event.clientX);
+    const distY = Math.abs(centerY - event.clientY);
+    this.automaticBlinkShown =
+      distX < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS &&
+      distY < AUTOMATIC_BLINK_BUTTON_ACTIVE_AREA_PIXELS;
+  }
+
+  mouseleaveImageArea() {
+    this.automaticBlinkShown = false;
   }
 
   mousedownMagnifier(event: MouseEvent) {
@@ -481,16 +843,26 @@
   }
 
   mouseupMagnifier(event: MouseEvent) {
+    if (!this.ownsMouseDown) return;
+    this.grabbing = false;
+    this.ownsMouseDown = false;
+
+    if (event.shiftKey && this.diffHighlightSrc) {
+      this.toggleHighlight('magnifier');
+      return;
+    }
+
     const offsetX = event.clientX - this.pointerOnDown.x;
     const offsetY = event.clientY - this.pointerOnDown.y;
     const distance = Math.max(Math.abs(offsetX), Math.abs(offsetY));
     // Consider very short drags as clicks. These tend to happen more often on
     // external mice.
-    if (this.ownsMouseDown && distance < DRAG_DEAD_ZONE_PIXELS) {
+    if (distance < DRAG_DEAD_ZONE_PIXELS) {
       this.toggleImage();
+      this.dispatchEvent(createEvent({type: 'magnifier-clicked'}));
+    } else {
+      this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
     }
-    this.grabbing = false;
-    this.ownsMouseDown = false;
   }
 
   mousemoveMagnifier(event: MouseEvent) {
@@ -529,8 +901,10 @@
   }
 
   mouseleaveMagnifier() {
+    if (!this.ownsMouseDown) return;
     this.grabbing = false;
     this.ownsMouseDown = false;
+    this.dispatchEvent(createEvent({type: 'magnifier-dragged'}));
   }
 
   dragstartMagnifier(event: DragEvent) {
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
index 55f83d6..28e6d82 100644
--- 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
@@ -14,19 +14,12 @@
  * 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 {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, query, state} from 'lit/decorators';
+import {StyleInfo, styleMap} from 'lit/directives/style-map';
+import {ImageDiffAction} from '../../../api/diff';
 
-import {Dimensions, fitToFrame, Point, Rect} from './util';
+import {createEvent, Dimensions, fitToFrame, Point, Rect} from './util';
 
 /**
  * Displays a scaled-down version of an image with a draggable frame for
@@ -44,15 +37,13 @@
   @property({type: Object})
   frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
 
-  @internalProperty() protected contentStyle: StyleInfo = {};
+  @state() protected contentStyle: StyleInfo = {};
 
-  @internalProperty() protected contentTransformStyle: StyleInfo = {};
+  @state() protected contentTransformStyle: StyleInfo = {};
 
-  @internalProperty() protected frameStyle: StyleInfo = {};
+  @state() protected frameStyle: StyleInfo = {};
 
-  @internalProperty() protected overlayStyle: StyleInfo = {};
-
-  @internalProperty() protected dragging = false;
+  @state() protected dragging = false;
 
   @query('.content-box') protected contentBox!: HTMLDivElement;
 
@@ -62,6 +53,8 @@
 
   @query('.frame') protected frame!: HTMLDivElement;
 
+  protected overlay?: HTMLDivElement;
+
   private contentBounds: Dimensions = {width: 0, height: 0};
 
   private imageBounds: Dimensions = {width: 0, height: 0};
@@ -92,7 +85,7 @@
     }
   );
 
-  static styles = css`
+  static override styles = css`
     :host {
       --background-color: var(--overview-image-background-color, #000);
       --frame-color: var(--overview-image-frame-color, #f00);
@@ -124,14 +117,9 @@
       position: absolute;
       will-change: transform;
     }
-    .overlay {
-      position: absolute;
-      z-index: 10000;
-      cursor: grabbing;
-    }
   `;
 
-  render() {
+  override render() {
     return html`
       <div class="content-box">
         <div
@@ -158,26 +146,60 @@
             @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() {
+  override connectedCallback() {
+    super.connectedCallback();
+    if (this.isConnected) {
+      this.overlay = document.createElement('div');
+      // The overlay is added directly to document body to ensure it fills the
+      // entire screen to capture events, without being clipped by any parent
+      // overflow properties. This means it has to be styled manually, since
+      // component styles will not affect it.
+      this.overlay.style.position = 'fixed';
+      this.overlay.style.top = '0';
+      this.overlay.style.left = '0';
+      // We subtract 20 pixels in each dimension to prevent the overlay from
+      // extending offscreen under any existing scrollbar and causing the
+      // scrollbar for the other dimension to show up unnecessarily.
+      this.overlay.style.width = 'calc(100vw - 20px)';
+      this.overlay.style.height = 'calc(100vh - 20px)';
+      this.overlay.style.zIndex = '10000';
+      this.overlay.style.display = 'none';
+
+      this.overlay.addEventListener('mousemove', (event: MouseEvent) =>
+        this.maybeDragFrame(event)
+      );
+      this.overlay.addEventListener('mouseleave', (event: MouseEvent) => {
+        // Ignore mouseleave events that are due to closeOverlay() calls.
+        if (this.overlay?.style.display !== 'none') {
+          this.releaseFrame(event);
+        }
+      });
+      this.overlay.addEventListener('mouseup', (event: MouseEvent) =>
+        this.releaseFrame(event)
+      );
+
+      document.body.appendChild(this.overlay);
+    }
+  }
+
+  override disconnectedCallback() {
+    if (this.overlay) {
+      document.body.removeChild(this.overlay);
+      this.overlay = undefined;
+    }
+    super.disconnectedCallback();
+  }
+
+  override firstUpdated() {
     this.resizeObserver.observe(this.contentBox);
     this.resizeObserver.observe(this.contentTransform);
   }
 
-  updated(changedProperties: PropertyValues) {
+  override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('frameRect')) {
       this.updateFrameStyle();
     }
@@ -187,9 +209,9 @@
     if (event.buttons !== 1) return;
     event.preventDefault();
 
-    this.updateOverlaySize();
-
     this.dragging = true;
+    this.openOverlay();
+
     const rect = this.content.getBoundingClientRect();
     this.notifyNewCenter({
       x: (event.clientX - rect.left) / this.scale,
@@ -203,9 +225,9 @@
     // Do not bubble up into clickOverview().
     event.stopPropagation();
 
-    this.updateOverlaySize();
-
     this.dragging = true;
+    this.openOverlay();
+
     const rect = this.frame.getBoundingClientRect();
     const frameCenterX = rect.x + rect.width / 2;
     const frameCenterY = rect.y + rect.height / 2;
@@ -228,13 +250,29 @@
 
   releaseFrame(event: MouseEvent) {
     event.preventDefault();
+
+    const detail: ImageDiffAction = {
+      type: this.dragging ? 'overview-frame-dragged' : 'overview-image-clicked',
+    };
+    this.dispatchEvent(createEvent(detail));
+
     this.dragging = false;
+    this.closeOverlay();
     this.grabOffset = {x: 0, y: 0};
   }
 
-  overlayMouseMove(event: MouseEvent) {
-    event.preventDefault();
-    this.maybeDragFrame(event);
+  private openOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'block';
+      this.overlay.style.cursor = 'grabbing';
+    }
+  }
+
+  private closeOverlay() {
+    if (this.overlay) {
+      this.overlay.style.display = 'none';
+      this.overlay.style.cursor = '';
+    }
   }
 
   private updateScale() {
@@ -269,25 +307,6 @@
     };
   }
 
-  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', {
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
index a14a9cc..66d4671 100644
--- 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
@@ -14,16 +14,9 @@
  * 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 {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {StyleInfo, styleMap} from 'lit/directives/style-map';
 import {Rect} from './util';
 
 /**
@@ -41,9 +34,9 @@
   @property({type: Object})
   frameRect: Rect = {origin: {x: 0, y: 0}, dimensions: {width: 0, height: 0}};
 
-  @internalProperty() protected imageStyles: StyleInfo = {};
+  @state() protected imageStyles: StyleInfo = {};
 
-  static styles = css`
+  static override styles = css`
     :host {
       display: block;
     }
@@ -63,7 +56,7 @@
     }
   `;
 
-  render() {
+  override render() {
     return html`
       <div id="clip">
         <div id="transform" style="${styleMap(this.imageStyles)}">
@@ -73,7 +66,7 @@
     `;
   }
 
-  updated(changedProperties: PropertyValues) {
+  override updated(changedProperties: PropertyValues) {
     if (changedProperties.has('scale') || changedProperties.has('frameRect')) {
       this.updateImageStyles();
     }
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
index b42eea9..7036ce4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-image-viewer/util.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {ImageDiffAction} from '../../../api/diff';
+
 export interface Point {
   x: number;
   y: number;
@@ -234,3 +236,13 @@
     };
   }
 }
+
+export function createEvent(
+  detail: ImageDiffAction
+): CustomEvent<ImageDiffAction> {
+  return new CustomEvent('image-diff-action', {
+    detail,
+    bubbles: true,
+    composed: true,
+  });
+}
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 b576896..b47c51c 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
@@ -43,12 +43,16 @@
   @property({type: Boolean})
   saveOnChange = false;
 
-  private readonly restApiService = appContext.restApiService;
+  @property({type: Boolean})
+  showTooltipBelow = false;
 
-  /** @override */
-  connectedCallback() {
+  private readonly userService = appContext.userService;
+
+  override connectedCallback() {
     super.connectedCallback();
-    ((IronA11yAnnouncer as unknown) as FixIronA11yAnnouncer).requestAvailability();
+    (
+      IronA11yAnnouncer as unknown as FixIronA11yAnnouncer
+    ).requestAvailability();
   }
 
   /**
@@ -56,7 +60,7 @@
    */
   setMode(newMode: DiffViewMode) {
     if (this.saveOnChange && this.mode && this.mode !== newMode) {
-      this.restApiService.savePreferences({diff_view: newMode});
+      this.userService.updatePreferences({diff_view: newMode});
     }
     this.mode = newMode;
     let announcement;
@@ -70,19 +74,19 @@
     }
   }
 
-  _computeSideBySideSelected(mode: DiffViewMode) {
+  _computeSideBySideSelected(mode?: DiffViewMode) {
     return mode === DiffViewMode.SIDE_BY_SIDE ? 'selected' : '';
   }
 
-  _computeUnifiedSelected(mode: DiffViewMode) {
+  _computeUnifiedSelected(mode?: DiffViewMode) {
     return mode === DiffViewMode.UNIFIED ? 'selected' : '';
   }
 
-  isSideBySideSelected(mode: DiffViewMode) {
+  isSideBySideSelected(mode?: DiffViewMode) {
     return mode === DiffViewMode.SIDE_BY_SIDE;
   }
 
-  isUnifiedSelected(mode: DiffViewMode) {
+  isUnifiedSelected(mode?: DiffViewMode) {
     return mode === DiffViewMode.UNIFIED;
   }
 
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 9943b58..8a6d95d 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
@@ -30,26 +30,34 @@
       width: 1.3rem;
     }
   </style>
-  <gr-button
-    id="sideBySideBtn"
-    link=""
+  <gr-tooltip-content
     has-tooltip=""
-    class$="[[_computeSideBySideSelected(mode)]]"
     title="Side-by-side diff"
-    aria-pressed="[[isSideBySideSelected(mode)]]"
-    on-click="_handleSideBySideTap"
+    position-below="[[showTooltipBelow]]"
   >
-    <iron-icon icon="gr-icons:side-by-side"></iron-icon>
-  </gr-button>
-  <gr-button
-    id="unifiedBtn"
-    link=""
+    <gr-button
+      id="sideBySideBtn"
+      link=""
+      class$="[[_computeSideBySideSelected(mode)]]"
+      aria-pressed$="[[isSideBySideSelected(mode)]]"
+      on-click="_handleSideBySideTap"
+    >
+      <iron-icon icon="gr-icons:side-by-side"></iron-icon>
+    </gr-button>
+  </gr-tooltip-content>
+  <gr-tooltip-content
     has-tooltip=""
+    position-below="[[showTooltipBelow]]"
     title="Unified diff"
-    class$="[[_computeUnifiedSelected(mode)]]"
-    aria-pressed="[[isUnifiedSelected(mode)]]"
-    on-click="_handleUnifiedTap"
   >
-    <iron-icon icon="gr-icons:unified"></iron-icon>
-  </gr-button>
+    <gr-button
+      id="unifiedBtn"
+      link=""
+      class$="[[_computeUnifiedSelected(mode)]]"
+      aria-pressed$="[[isUnifiedSelected(mode)]]"
+      on-click="_handleUnifiedTap"
+    >
+      <iron-icon icon="gr-icons:unified"></iron-icon>
+    </gr-button>
+  </gr-tooltip-content>
 `;
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.ts
similarity index 63%
rename from polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.js
rename to polygerrit-ui/app/elements/diff/gr-diff-mode-selector/gr-diff-mode-selector_test.ts
index f554227..8b06c75 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.ts
@@ -15,54 +15,59 @@
  * limitations under the License.
  */
 
-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';
+import '../../../test/common-test-setup-karma';
+import './gr-diff-mode-selector';
+import {GrDiffModeSelector} from './gr-diff-mode-selector';
+import {DiffViewMode} from '../../../constants/constants';
+import {stubUsers} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-diff-mode-selector');
 
 suite('gr-diff-mode-selector tests', () => {
-  let element;
+  let element: GrDiffModeSelector;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
   test('_computeSelectedClass', () => {
-    assert.equal(element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
-        'selected');
-    assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED),
-        '');
-    assert.equal(element._computeUnifiedSelected(DiffViewMode.UNIFIED),
-        'selected');
-    assert.equal(element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
-        '');
+    assert.equal(
+      element._computeSideBySideSelected(DiffViewMode.SIDE_BY_SIDE),
+      'selected'
+    );
+    assert.equal(element._computeSideBySideSelected(DiffViewMode.UNIFIED), '');
+    assert.equal(
+      element._computeUnifiedSelected(DiffViewMode.UNIFIED),
+      'selected'
+    );
+    assert.equal(
+      element._computeUnifiedSelected(DiffViewMode.SIDE_BY_SIDE),
+      ''
+    );
   });
 
   test('setMode', () => {
-    const saveStub = stubRestApi('savePreferences');
+    const saveStub = stubUsers('updatePreferences');
 
     // Setting the mode initially does not save prefs.
     element.saveOnChange = true;
-    element.setMode('SIDE_BY_SIDE');
+    element.setMode(DiffViewMode.SIDE_BY_SIDE);
     assert.isFalse(saveStub.called);
 
     // Setting the mode to itself does not save prefs.
-    element.setMode('SIDE_BY_SIDE');
+    element.setMode(DiffViewMode.SIDE_BY_SIDE);
     assert.isFalse(saveStub.called);
 
     // Setting the mode to something else does not save prefs if saveOnChange
     // is false.
     element.saveOnChange = false;
-    element.setMode('UNIFIED_DIFF');
+    element.setMode(DiffViewMode.UNIFIED);
     assert.isFalse(saveStub.called);
 
     // Setting the mode to something else does not save prefs if saveOnChange
     // is false.
     element.saveOnChange = true;
-    element.setMode('SIDE_BY_SIDE');
+    element.setMode(DiffViewMode.SIDE_BY_SIDE);
     assert.isTrue(saveStub.calledOnce);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
index 787fe30..85edc12 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_html.ts
@@ -49,12 +49,12 @@
   </style>
   <gr-overlay id="diffPrefsOverlay" with-backdrop="">
     <div role="dialog" aria-labelledby="diffPreferencesTitle">
-      <h1
-        class$="diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
+      <h3
+        class$="heading-3 diffHeader [[_computeHeaderClass(_diffPrefsChanged)]]"
         id="diffPreferencesTitle"
       >
         Diff Preferences
-      </h1>
+      </h3>
       <gr-diff-preferences
         id="diffPreferences"
         diff-prefs="{{_editableDiffPrefs}}"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
similarity index 80%
rename from polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js
rename to polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
index 07cca9a..5c62e7a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences-dialog/gr-diff-preferences-dialog_test.ts
@@ -15,17 +15,24 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
+import '../../../test/common-test-setup-karma';
+import './gr-diff-preferences-dialog';
+import {GrDiffPreferencesDialog} from './gr-diff-preferences-dialog';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-diff-preferences-dialog');
 
 suite('gr-diff-preferences-dialog', () => {
-  let element;
+  let element: GrDiffPreferencesDialog;
+
   setup(() => {
     element = basicFixture.instantiate();
   });
+
   test('changes applies only on save', async () => {
     const originalDiffPrefs = {
+      ...createDefaultDiffPrefs(),
       line_wrapping: true,
     };
     element.diffPrefs = originalDiffPrefs;
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 d5e2a07..b1e15a8 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
@@ -32,6 +32,7 @@
 import {DiffContent} from '../../../types/diff';
 import {Side} from '../../../constants/constants';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {RenderPreferences} from '../../../api/diff';
 
 const WHOLE_FILE = -1;
 
@@ -61,7 +62,10 @@
  * _asyncThreshold of 64, but feel free to tune this constant to your
  * performance needs.
  */
-const MAX_GROUP_SIZE = 120;
+function calcMaxGroupSize(asyncThreshold?: number): number {
+  if (!asyncThreshold) return 120;
+  return asyncThreshold * 2;
+}
 
 /**
  * Converts the API's `DiffContent`s  to `GrDiffGroup`s for rendering.
@@ -113,14 +117,12 @@
 
   private resetIsScrollingTask?: DelayedTask;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     window.addEventListener('scroll', this.handleWindowScroll);
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.resetIsScrollingTask?.cancel();
     this.cancel();
     window.removeEventListener('scroll', this.handleWindowScroll);
@@ -487,6 +489,7 @@
       // chunks so they can be rendered incrementally. Note: this is not
       // enabled for any other context preference because manipulating the
       // chunks in this way violates assumptions by the context grouper logic.
+      const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
       if (this.context === -1 && chunk.ab.length > MAX_GROUP_SIZE * 2) {
         // Split large shared chunks in two, where the first is the maximum
         // group size.
@@ -697,6 +700,7 @@
       return [chunk];
     }
 
+    const MAX_GROUP_SIZE = calcMaxGroupSize(this._asyncThreshold);
     return this._breakdown(chunk[key]!, MAX_GROUP_SIZE).map(subChunkLines => {
       const subChunk: DiffContent = {};
       subChunk[key!] = subChunkLines;
@@ -727,6 +731,12 @@
 
     return this._breakdown(head, size).concat([tail]);
   }
+
+  updateRenderPrefs(renderPrefs: RenderPreferences) {
+    if (renderPrefs.num_lines_rendered_at_once) {
+      this._asyncThreshold = renderPrefs.num_lines_rendered_at_once;
+    }
+  }
 }
 
 declare global {
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 0bb5eac..bebdf34 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
@@ -589,39 +589,42 @@
     });
 
     test('breaks down shared chunks w/ whole-file', () => {
-      const size = 120 * 2 + 5;
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 2 + 5;
       const content = [{
         ab: _.times(size, () => `${Math.random()}`),
       }];
       element.context = -1;
       const result = element._splitLargeChunks(content);
       assert.equal(result.length, 2);
-      assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
-      assert.deepEqual(result[1].ab, content[0].ab.slice(120));
+      assert.deepEqual(result[0].ab, content[0].ab.slice(0, maxGroupSize));
+      assert.deepEqual(result[1].ab, content[0].ab.slice(maxGroupSize));
     });
 
     test('breaks down added chunks', () => {
-      const size = 120 * 2 + 5;
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 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));
+      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
+      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
     });
 
     test('breaks down removed chunks', () => {
-      const size = 120 * 2 + 5;
+      const maxGroupSize = 128;
+      const size = maxGroupSize * 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));
+      assert.deepEqual(splitContent[1], content.slice(5, maxGroupSize + 5));
+      assert.deepEqual(splitContent[2], content.slice(maxGroupSize + 5));
     });
 
     test('does not break down moved chunks', () => {
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 3df2e18..2665ef0 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
@@ -28,7 +28,12 @@
 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';
+import {
+  getLineElByChild,
+  getSide,
+  getSideByLineEl,
+  isThreadEl,
+} from '../gr-diff/gr-diff-utils';
 
 /**
  * Possible CSS classes indicating the state of selection. Dynamically added/
@@ -71,8 +76,7 @@
     addListener(this, 'down', e => this._handleDown(e));
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.classList.add(SelectionClass.RIGHT);
   }
@@ -110,7 +114,7 @@
     // Handle the down event on comment thread in Polymer 2
     const handled = this._handleDownOnRangeComment(target);
     if (handled) return;
-    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const lineEl = getLineElByChild(target);
     const blameSelected = this._elementDescendedFromClass(target, 'blame');
     if (!lineEl && !blameSelected) {
       return;
@@ -125,7 +129,7 @@
         target,
         'gr-comment'
       );
-      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const side = getSideByLineEl(lineEl);
 
       targetClasses.push(
         side === 'left' ? SelectionClass.LEFT : SelectionClass.RIGHT
@@ -179,9 +183,9 @@
     if (this.classList.contains(SelectionClass.COMMENT)) {
       commentSelected = true;
     }
-    const lineEl = this.diffBuilder.getLineElByChild(target);
+    const lineEl = getLineElByChild(target);
     if (!lineEl) return;
-    const side = this.diffBuilder.getSideByLineEl(lineEl);
+    const side = getSideByLineEl(lineEl);
     const text = this._getSelectedText(side, commentSelected);
     if (text && e.clipboardData) {
       e.clipboardData.setData('Text', text);
@@ -191,7 +195,7 @@
 
   _getSelection() {
     const diffHosts = querySelectorAll(document.body, 'gr-diff');
-    if (!diffHosts.length) return window.getSelection();
+    if (!diffHosts.length) return document.getSelection();
 
     const curDiffHost = diffHosts.find(diffHost => {
       if (!diffHost?.shadowRoot?.getSelection) return false;
@@ -201,9 +205,9 @@
       return selection && selection.type !== 'None';
     });
 
-    return curDiffHost
-      ? curDiffHost.shadowRoot!.getSelection()
-      : window.getSelection();
+    return curDiffHost?.shadowRoot?.getSelection
+      ? curDiffHost.shadowRoot.getSelection()
+      : document.getSelection();
   }
 
   /**
@@ -224,9 +228,9 @@
       return this._getCommentLines(sel, side);
     }
     const range = normalize(sel.getRangeAt(0));
-    const startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
+    const startLineEl = getLineElByChild(range.startContainer);
     if (!startLineEl) return;
-    const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+    const endLineEl = getLineElByChild(range.endContainer);
     // Happens when triple click in side-by-side mode with other side empty.
     const endsAtOtherEmptySide =
       !endLineEl &&
@@ -265,6 +269,11 @@
     endOffset: number,
     side: Side
   ) {
+    const skipChunk = this.diff?.content.find(chunk => chunk.skip);
+    if (skipChunk) {
+      startLineNum -= skipChunk.skip!;
+      if (endLineNum) endLineNum -= skipChunk.skip!;
+    }
     const lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
     if (lines.length) {
       lines[lines.length - 1] = lines[lines.length - 1].substring(0, endOffset);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
index 5c9fe3f..15454f9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.js
@@ -143,8 +143,8 @@
 
   test('applies selected-left on left side click', () => {
     element.classList.add('selected-right');
-    element._cachedDiffBuilder.getSideByLineEl.returns('left');
-    MockInteractions.down(element);
+    const lineNumberEl = element.querySelector('.lineNum.left');
+    MockInteractions.down(lineNumberEl);
     assert.isTrue(
         element.classList.contains('selected-left'), 'adds selected-left');
     assert.isFalse(
@@ -154,8 +154,8 @@
 
   test('applies selected-right on right side click', () => {
     element.classList.add('selected-left');
-    element._cachedDiffBuilder.getSideByLineEl.returns('right');
-    MockInteractions.down(element);
+    const lineNumberEl = element.querySelector('.lineNum.right');
+    MockInteractions.down(lineNumberEl);
     assert.isTrue(
         element.classList.contains('selected-right'), 'adds selected-right');
     assert.isFalse(
@@ -247,7 +247,7 @@
     element.classList.add('selected-left');
     element.classList.remove('selected-right');
 
-    const selection = window.getSelection();
+    const selection = document.getSelection();
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(element.querySelector('div.contentText').firstChild, 3);
@@ -261,7 +261,7 @@
     element.classList.add('selected-left');
     element.classList.add('selected-comment');
     element.classList.remove('selected-right');
-    const selection = window.getSelection();
+    const selection = document.getSelection();
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(
@@ -277,7 +277,7 @@
     element.classList.add('selected-left');
     element.classList.add('selected-comment');
     element.classList.remove('selected-right');
-    const selection = window.getSelection();
+    const selection = document.getSelection();
     selection.removeAllRanges();
     const range = document.createRange();
     const nodes = element.querySelectorAll('.gr-formatted-text *');
@@ -307,7 +307,7 @@
     element.classList.add('selected-right');
     element.classList.remove('selected-left');
 
-    const selection = window.getSelection();
+    const selection = document.getSelection();
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(
@@ -329,7 +329,7 @@
     };
     element.classList.add('selected-left');
     element.classList.remove('selected-right');
-    const selection = window.getSelection();
+    const selection = document.getSelection();
     selection.removeAllRanges();
     const range = document.createRange();
     range.setStart(element.querySelector('div.contentText').firstChild, 3);
@@ -348,7 +348,7 @@
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      selection = window.getSelection();
+      selection = document.getSelection();
       selection.removeAllRanges();
       range = document.createRange();
       nodes = element.querySelectorAll('.gr-formatted-text *');
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 4a51349..0813900 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
@@ -16,6 +16,7 @@
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-dropdown/gr-dropdown';
@@ -36,8 +37,13 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
+  ShortcutListener,
+  ShortcutSection,
 } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {
+  GeneratedWebLink,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
 import {appContext} from '../../../services/app-context';
 import {
   computeAllPatchSets,
@@ -68,7 +74,6 @@
   ConfigInfo,
   EditInfo,
   EditPatchSetNum,
-  ElementPropertyDeepChange,
   FileInfo,
   NumericChangeId,
   ParentPatchSetNum,
@@ -90,18 +95,29 @@
 import {RevisionInfo as RevisionInfoObj} from '../../shared/revision-info/revision-info';
 import {
   CommentMap,
-  isInBaseOfPatchRange,
   getPatchRangeForCommentUrl,
+  isInBaseOfPatchRange,
 } from '../../../utils/comment-util';
 import {AppElementParams} from '../../gr-app-types';
-import {CustomKeyboardEvent, OpenFixPreviewEvent} from '../../../types/events';
+import {EventType, OpenFixPreviewEvent} from '../../../types/events';
 import {fireAlert, fireEvent, fireTitleChange} from '../../../utils/event-util';
 import {GerritView} from '../../../services/router/router-model';
 import {assertIsDefined} from '../../../utils/common-util';
-import {toggleClass} from '../../../utils/dom-util';
+import {addGlobalShortcut, Key, toggleClass} from '../../../utils/dom-util';
+import {CursorMoveResult} from '../../../api/core';
+import {throttleWrap} from '../../../utils/async-util';
+import {changeComments$} from '../../../services/comments/comments-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
+import {preferences$} from '../../../services/user/user-model';
+import {listen} from '../../../services/shortcuts/shortcuts-service';
+
 const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
-const MSG_LOADING_BLAME = 'Loading blame...';
-const MSG_LOADED_BLAME = 'Blame loaded';
+const LOADING_BLAME = 'Loading blame...';
+const LOADED_BLAME = 'Blame loaded';
+
+// Time in which pressing n key again after the toast navigates to next file
+const NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS = 5000;
 
 interface Files {
   sortedFileList: string[];
@@ -116,7 +132,6 @@
 export interface GrDiffView {
   $: {
     commentAPI: GrCommentApi;
-    cursor: GrDiffCursor;
     diffHost: GrDiffHost;
     reviewed: HTMLInputElement;
     dropdown: GrDropdownList;
@@ -126,8 +141,11 @@
   };
 }
 
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = KeyboardShortcutMixin(PolymerElement);
+
 @customElement('gr-diff-view')
-export class GrDiffView extends KeyboardShortcutMixin(PolymerElement) {
+export class GrDiffView extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -147,21 +165,9 @@
   @property({type: Object, observer: '_paramsChanged'})
   params?: AppElementParams;
 
-  @property({type: Object})
-  keyEventTarget: HTMLElement = document.body;
-
   @property({type: Object, notify: true, observer: '_changeViewStateChanged'})
   changeViewState: Partial<ChangeViewState> = {};
 
-  @property({type: Boolean})
-  disableDiffPrefs = false;
-
-  @property({
-    type: Boolean,
-    computed: '_computeDiffPrefsDisabled(disableDiffPrefs, _loggedIn)',
-  })
-  _diffPrefsDisabled?: boolean;
-
   @property({type: Object})
   _patchRange?: PatchRange;
 
@@ -226,6 +232,9 @@
   _isImageDiff?: boolean;
 
   @property({type: Object})
+  _editWeblinks?: GeneratedWebLink[];
+
+  @property({type: Object})
   _filesWeblinks?: FilesWebLinks;
 
   @property({type: Object})
@@ -261,91 +270,165 @@
   @property({type: Number})
   _focusLineNum?: number;
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-    };
+  private getReviewedParams: {
+    changeNum?: NumericChangeId;
+    patchNum?: PatchSetNum;
+  } = {};
+
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.LEFT_PANE, _ => this.cursor.moveLeft()),
+      listen(Shortcut.RIGHT_PANE, _ => this.cursor.moveRight()),
+      listen(Shortcut.NEXT_LINE, _ => this._handleNextLine()),
+      listen(Shortcut.PREV_LINE, _ => this._handlePrevLine()),
+      listen(Shortcut.VISIBLE_LINE, _ => this.cursor.moveToVisibleArea()),
+      listen(Shortcut.NEXT_FILE_WITH_COMMENTS, _ =>
+        this._moveToNextFileWithComment()
+      ),
+      listen(Shortcut.PREV_FILE_WITH_COMMENTS, _ =>
+        this._moveToPreviousFileWithComment()
+      ),
+      listen(Shortcut.NEW_COMMENT, _ => this._handleNewComment()),
+      listen(Shortcut.SAVE_COMMENT, _ => {}),
+      listen(Shortcut.NEXT_FILE, _ => this._handleNextFile()),
+      listen(Shortcut.PREV_FILE, _ => this._handlePrevFile()),
+      listen(Shortcut.NEXT_CHUNK, _ => this._handleNextChunk()),
+      listen(Shortcut.PREV_CHUNK, _ => this._handlePrevChunk()),
+      listen(Shortcut.NEXT_COMMENT_THREAD, _ =>
+        this._handleNextCommentThread()
+      ),
+      listen(Shortcut.PREV_COMMENT_THREAD, _ =>
+        this._handlePrevCommentThread()
+      ),
+      listen(Shortcut.OPEN_REPLY_DIALOG, _ => this._handleOpenReplyDialog()),
+      listen(Shortcut.TOGGLE_LEFT_PANE, _ => this._handleToggleLeftPane()),
+      listen(Shortcut.OPEN_DOWNLOAD_DIALOG, _ =>
+        this._handleOpenDownloadDialog()
+      ),
+      listen(Shortcut.UP_TO_CHANGE, _ => this._handleUpToChange()),
+      listen(Shortcut.OPEN_DIFF_PREFS, _ => this._handleCommaKey()),
+      listen(Shortcut.TOGGLE_DIFF_MODE, _ => this._handleToggleDiffMode()),
+      listen(Shortcut.TOGGLE_FILE_REVIEWED, e => {
+        if (this._throttledToggleFileReviewed) {
+          this._throttledToggleFileReviewed(e);
+        }
+      }),
+      listen(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, _ =>
+        this._handleToggleAllDiffContext()
+      ),
+      listen(Shortcut.NEXT_UNREVIEWED_FILE, _ =>
+        this._handleNextUnreviewedFile()
+      ),
+      listen(Shortcut.TOGGLE_BLAME, _ => this._handleToggleBlame()),
+      listen(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, _ =>
+        this._handleToggleHideAllCommentThreads()
+      ),
+      listen(Shortcut.OPEN_FILE_LIST, _ => this._handleOpenFileList()),
+      listen(Shortcut.DIFF_AGAINST_BASE, _ => this._handleDiffAgainstBase()),
+      listen(Shortcut.DIFF_AGAINST_LATEST, _ =>
+        this._handleDiffAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LEFT, _ =>
+        this._handleDiffBaseAgainstLeft()
+      ),
+      listen(Shortcut.DIFF_RIGHT_AGAINST_LATEST, _ =>
+        this._handleDiffRightAgainstLatest()
+      ),
+      listen(Shortcut.DIFF_BASE_AGAINST_LATEST, _ =>
+        this._handleDiffBaseAgainstLatest()
+      ),
+      listen(Shortcut.EXPAND_ALL_COMMENT_THREADS, _ => {}), // docOnly
+      listen(Shortcut.COLLAPSE_ALL_COMMENT_THREADS, _ => {}), // docOnly
+    ];
   }
 
-  keyboardShortcuts() {
-    return {
-      [Shortcut.LEFT_PANE]: '_handleLeftPane',
-      [Shortcut.RIGHT_PANE]: '_handleRightPane',
-      [Shortcut.NEXT_LINE]: '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_LINE]: '_handlePrevLineOrFileWithComments',
-      [Shortcut.VISIBLE_LINE]: '_handleVisibleLine',
-      [Shortcut.NEXT_FILE_WITH_COMMENTS]: '_handleNextLineOrFileWithComments',
-      [Shortcut.PREV_FILE_WITH_COMMENTS]: '_handlePrevLineOrFileWithComments',
-      [Shortcut.NEW_COMMENT]: '_handleNewComment',
-      [Shortcut.SAVE_COMMENT]: null, // DOC_ONLY binding
-      [Shortcut.NEXT_FILE]: '_handleNextFile',
-      [Shortcut.PREV_FILE]: '_handlePrevFile',
-      [Shortcut.NEXT_CHUNK]: '_handleNextChunkOrCommentThread',
-      [Shortcut.NEXT_COMMENT_THREAD]: '_handleNextChunkOrCommentThread',
-      [Shortcut.PREV_CHUNK]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.PREV_COMMENT_THREAD]: '_handlePrevChunkOrCommentThread',
-      [Shortcut.OPEN_REPLY_DIALOG]: '_handleOpenReplyDialog',
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-      [Shortcut.OPEN_DOWNLOAD_DIALOG]: '_handleOpenDownloadDialog',
-      [Shortcut.UP_TO_CHANGE]: '_handleUpToChange',
-      [Shortcut.OPEN_DIFF_PREFS]: '_handleCommaKey',
-      [Shortcut.TOGGLE_DIFF_MODE]: '_handleToggleDiffMode',
-      [Shortcut.TOGGLE_FILE_REVIEWED]: '_throttledToggleFileReviewed',
-      [Shortcut.TOGGLE_ALL_DIFF_CONTEXT]: '_handleToggleAllDiffContext',
-      [Shortcut.NEXT_UNREVIEWED_FILE]: '_handleNextUnreviewedFile',
-      [Shortcut.TOGGLE_BLAME]: '_handleToggleBlame',
-      [Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS]:
-        '_handleToggleHideAllCommentThreads',
-      [Shortcut.OPEN_FILE_LIST]: '_handleOpenFileList',
-      [Shortcut.DIFF_AGAINST_BASE]: '_handleDiffAgainstBase',
-      [Shortcut.DIFF_AGAINST_LATEST]: '_handleDiffAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LEFT]: '_handleDiffBaseAgainstLeft',
-      [Shortcut.DIFF_RIGHT_AGAINST_LATEST]: '_handleDiffRightAgainstLatest',
-      [Shortcut.DIFF_BASE_AGAINST_LATEST]: '_handleDiffBaseAgainstLatest',
-
-      // Final two are actually handled by gr-comment-thread.
-      [Shortcut.EXPAND_ALL_COMMENT_THREADS]: null,
-      [Shortcut.COLLAPSE_ALL_COMMENT_THREADS]: null,
-    };
-  }
-
-  reporting = appContext.reportingService;
-
-  flagsService = appContext.flagsService;
+  private readonly reporting = appContext.reportingService;
 
   private readonly restApiService = appContext.restApiService;
 
-  _throttledToggleFileReviewed?: EventListener;
+  private readonly commentsService = appContext.commentsService;
+
+  private readonly shortcuts = appContext.shortcutsService;
+
+  _throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
 
   _onRenderHandler?: EventListener;
 
-  /** @override */
-  connectedCallback() {
+  private cursor = new GrDiffCursor();
+
+  disconnected$ = new Subject();
+
+  override connectedCallback() {
     super.connectedCallback();
-    this._throttledToggleFileReviewed = this._throttleWrap(e =>
-      this._handleToggleFileReviewed(e as CustomKeyboardEvent)
+    this._throttledToggleFileReviewed = throttleWrap(_ =>
+      this._handleToggleFileReviewed()
     );
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
+    // TODO(brohlfs): This just ensures that the userService is instantiated at
+    // all. We need the service to manage the model, but we are not making any
+    // direct calls. Will need to find a better solution to this problem ...
+    assertIsDefined(appContext.userService);
 
+    changeComments$
+      .pipe(takeUntil(this.disconnected$))
+      .subscribe(changeComments => {
+        this._changeComments = changeComments;
+      });
+
+    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(preferences => {
+      this._userPrefs = preferences;
+    });
     this.addEventListener('open-fix-preview', e => this._onOpenFixPreview(e));
-    this.$.cursor.push('diffs', this.$.diffHost);
+    this.cursor.replaceDiffs([this.$.diffHost]);
     this._onRenderHandler = (_: Event) => {
-      this.$.cursor.reInitCursor();
+      this.cursor.reInitCursor();
     };
     this.$.diffHost.addEventListener('render', this._onRenderHandler);
+    this.cleanups.push(
+      addGlobalShortcut(
+        {key: Key.ESC},
+        _ => (this.$.diffHost.displayLine = false)
+      )
+    );
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
+    this.disconnected$.next();
+    this.cursor.dispose();
     if (this._onRenderHandler) {
       this.$.diffHost.removeEventListener('render', this._onRenderHandler);
     }
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
-  _getLoggedIn() {
+  @observe('_changeComments', '_path', '_patchRange')
+  computeThreads(
+    changeComments?: ChangeComments,
+    path?: string,
+    patchRange?: PatchRange
+  ) {
+    if (
+      changeComments === undefined ||
+      path === undefined ||
+      patchRange === undefined
+    ) {
+      return;
+    }
+    // TODO(dhruvsri): check if basePath should be set here
+    this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
+      {path},
+      patchRange
+    );
+  }
+
+  _getLoggedIn(): Promise<boolean> {
     return this.restApiService.getLoggedIn();
   }
 
@@ -429,10 +512,6 @@
     return this.restApiService.getPreferences();
   }
 
-  _getWindowWidth() {
-    return window.innerWidth;
-  }
-
   _handleReviewedChange(e: Event) {
     this._setReviewed(
       ((dom(e) as EventApi).rootTarget as HTMLInputElement).checked
@@ -442,8 +521,15 @@
   _setReviewed(reviewed: boolean) {
     if (this._editMode) return;
     this.$.reviewed.checked = reviewed;
-    if (!this._patchRange?.patchNum) return;
+    if (!this._patchRange?.patchNum || !this._path) return;
+    const path = this._path;
+    // if file is already reviewed then do not make a saveReview request
+    if (this._reviewedFiles.has(path) && reviewed) return;
+    if (reviewed) this._reviewedFiles.add(path);
+    else this._reviewedFiles.delete(path);
     this._saveReviewedState(reviewed).catch(err => {
+      if (this._reviewedFiles.has(path)) this._reviewedFiles.delete(path);
+      else this._reviewedFiles.add(path);
       fireAlert(this, ERR_REVIEW_STATUS);
       throw err;
     });
@@ -461,85 +547,22 @@
     );
   }
 
-  _handleToggleFileReviewed(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
-    e.preventDefault();
+  _handleToggleFileReviewed() {
     this._setReviewed(!this.$.reviewed.checked);
   }
 
-  _handleEscKey(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
-    e.preventDefault();
-    this.$.diffHost.displayLine = false;
-  }
-
-  _handleLeftPane(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-
-    e.preventDefault();
-    this.$.cursor.moveLeft();
-  }
-
-  _handleRightPane(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-
-    e.preventDefault();
-    this.$.cursor.moveRight();
-  }
-
-  _handlePrevLineOrFileWithComments(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-
-    if (
-      e.detail.keyboardEvent?.shiftKey &&
-      e.detail.keyboardEvent?.keyCode === 75
-    ) {
-      // 'K'
-      this._moveToPreviousFileWithComment();
-      return;
-    }
-    if (this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handlePrevLine() {
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveUp();
-  }
-
-  _handleVisibleLine(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-
-    e.preventDefault();
-    this.$.cursor.moveToVisibleArea();
+    this.cursor.moveUp();
   }
 
   _onOpenFixPreview(e: OpenFixPreviewEvent) {
     this.$.applyFixDialog.open(e);
   }
 
-  _handleNextLineOrFileWithComments(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-
-    if (
-      e.detail.keyboardEvent?.shiftKey &&
-      e.detail.keyboardEvent?.keyCode === 74
-    ) {
-      // 'J'
-      this._moveToNextFileWithComment();
-      return;
-    }
-    if (this.modifierPressed(e)) {
-      return;
-    }
-
-    e.preventDefault();
+  _handleNextLine() {
     this.$.diffHost.displayLine = true;
-    this.$.cursor.moveDown();
+    this.cursor.moveDown();
   }
 
   _moveToPreviousFileWithComment() {
@@ -581,69 +604,88 @@
     );
   }
 
-  _handleNewComment(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
-    e.preventDefault();
+  _handleNewComment() {
     this.classList.remove('hideComments');
-    this.$.cursor.createCommentInPlace();
+    this.cursor.createCommentInPlace();
   }
 
-  _handlePrevFile(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.getKeyboardEvent(e).metaKey) return;
+  _handlePrevFile() {
     if (!this._path) return;
     if (!this._fileList) return;
-
-    e.preventDefault();
     this._navToFile(this._path, this._fileList, -1);
   }
 
-  _handleNextFile(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    // Check for meta key to avoid overriding native chrome shortcut.
-    if (this.getKeyboardEvent(e).metaKey) return;
+  _handleNextFile() {
     if (!this._path) return;
     if (!this._fileList) return;
-
-    e.preventDefault();
     this._navToFile(this._path, this._fileList, 1);
   }
 
-  _handleNextChunkOrCommentThread(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleNextChunk() {
+    const result = this.cursor.moveToNextChunk();
+    if (result === CursorMoveResult.CLIPPED && this.cursor.isAtEnd()) {
+      this.showToastAndNavigateFile('next', 'n');
+    }
+  }
 
-    e.preventDefault();
-    if (e.detail.keyboardEvent?.shiftKey) {
-      this.$.cursor.moveToNextCommentThread();
+  _handleNextCommentThread() {
+    const result = this.cursor.moveToNextCommentThread();
+    if (result === CursorMoveResult.CLIPPED) {
+      this._navigateToNextFileWithCommentThread();
+    }
+  }
+
+  private lastDisplayedNavigateToFileToast: Map<string, number> = new Map();
+
+  private showToastAndNavigateFile(direction: string, shortcut: string) {
+    /*
+     * If user presses p/n on the first/last diff chunk, show a toast informing
+     * user that pressing it again will navigate them to previous/next
+     * unreviewedfile if click happens within the time limit
+     */
+    if (
+      this.lastDisplayedNavigateToFileToast.get(direction) &&
+      Date.now() - this.lastDisplayedNavigateToFileToast.get(direction)! <=
+        NAVIGATE_TO_NEXT_FILE_TIMEOUT_MS
+    ) {
+      // reset for next file
+      this.lastDisplayedNavigateToFileToast.delete(direction);
+      this.navigateToUnreviewedFile(direction);
     } else {
-      if (this.modifierPressed(e)) return;
-      // navigate to next file if key is not being held down
-      this.$.cursor.moveToNextChunk(
-        /* opt_clipToTop = */ false,
-        /* opt_navigateToNextFile = */ !e.detail.keyboardEvent?.repeat
+      this.lastDisplayedNavigateToFileToast.set(direction, Date.now());
+      fireAlert(
+        this,
+        `Press ${shortcut} again to navigate to ${direction} unreviewed file`
       );
     }
   }
 
-  _handlePrevChunkOrCommentThread(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  private navigateToUnreviewedFile(direction: string) {
+    if (!this._path) return;
+    if (!this._fileList) return;
+    if (!this._reviewedFiles) return;
+    // Ensure that the currently viewed file always appears in unreviewedFiles
+    // so we resolve the right "next" file.
+    const unreviewedFiles = this._fileList.filter(
+      file => file === this._path || !this._reviewedFiles.has(file)
+    );
 
-    e.preventDefault();
-    if (e.detail.keyboardEvent?.shiftKey) {
-      this.$.cursor.moveToPreviousCommentThread();
-    } else {
-      if (this.modifierPressed(e)) return;
-      this.$.cursor.moveToPreviousChunk(!e.detail.keyboardEvent?.repeat);
+    this._navToFile(this._path, unreviewedFiles, direction === 'next' ? 1 : -1);
+  }
+
+  _handlePrevChunk() {
+    this.cursor.moveToPreviousChunk();
+    if (this.cursor.isAtStart()) {
+      this.showToastAndNavigateFile('previous', 'p');
     }
   }
 
+  _handlePrevCommentThread() {
+    this.cursor.moveToPreviousCommentThread();
+  }
+
   // Similar to gr-change-view._handleOpenReplyDialog
-  _handleOpenReplyDialog(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
+  _handleOpenReplyDialog() {
     this._getLoggedIn().then(isLoggedIn => {
       if (!isLoggedIn) {
         fireEvent(this, 'show-auth-required');
@@ -651,50 +693,29 @@
       }
 
       this.set('changeViewState.showReplyDialog', true);
-      e.preventDefault();
       this._navToChangeView();
     });
   }
 
-  _handleToggleLeftPane(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (!e.detail.keyboardEvent?.shiftKey) return;
-
-    e.preventDefault();
+  _handleToggleLeftPane() {
     this.$.diffHost.toggleLeftDiff();
   }
 
-  _handleOpenDownloadDialog(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
+  _handleOpenDownloadDialog() {
     this.set('changeViewState.showDownloadDialog', true);
-    e.preventDefault();
     this._navToChangeView();
   }
 
-  _handleUpToChange(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
-    e.preventDefault();
+  _handleUpToChange() {
     this._navToChangeView();
   }
 
-  _handleCommaKey(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-    if (this._diffPrefsDisabled) return;
-
-    e.preventDefault();
+  _handleCommaKey() {
+    if (!this._loggedIn) return;
     this.$.diffPreferencesDialog.open();
   }
 
-  _handleToggleDiffMode(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
-    e.preventDefault();
+  _handleToggleDiffMode() {
     if (this._getDiffViewMode() === DiffViewMode.SIDE_BY_SIDE) {
       this.$.modeSelect.setMode(DiffViewMode.UNIFIED);
     } else {
@@ -789,7 +810,7 @@
     if (!this._patchRange) return;
 
     // TODO(taoalpha): add a shortcut for editing
-    const cursorAddress = this.$.cursor.getAddress();
+    const cursorAddress = this.cursor.getAddress();
     const editUrl = GerritNav.getEditUrlForDiff(
       this._change,
       this._path,
@@ -835,31 +856,26 @@
     return {path: fileList[idx]};
   }
 
-  _getReviewedFiles(
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum
-  ): Promise<Set<string>> {
-    if (!changeNum || !patchNum) return Promise.resolve(new Set<string>());
-    return this.restApiService
-      .getReviewedFiles(changeNum, patchNum)
-      .then(files => {
-        this._reviewedFiles = new Set(files);
-        return this._reviewedFiles;
-      });
+  _getReviewedFiles(changeNum?: NumericChangeId, patchNum?: PatchSetNum) {
+    if (!changeNum || !patchNum) return;
+    if (
+      this.getReviewedParams.changeNum === changeNum &&
+      this.getReviewedParams.patchNum === patchNum
+    ) {
+      return;
+    }
+    this.getReviewedParams = {
+      changeNum,
+      patchNum,
+    };
+    this.restApiService.getReviewedFiles(changeNum, patchNum).then(files => {
+      this._reviewedFiles = new Set(files);
+    });
   }
 
-  _getReviewedStatus(
-    editMode?: boolean,
-    changeNum?: NumericChangeId,
-    patchNum?: PatchSetNum,
-    path?: string
-  ) {
-    if (editMode || !path) {
-      return Promise.resolve(false);
-    }
-    return this._getReviewedFiles(changeNum, patchNum).then(files =>
-      files.has(path)
-    );
+  _getReviewedStatus(path: string) {
+    if (this._editMode) return false;
+    return this._reviewedFiles.has(path);
   }
 
   _initLineOfInterestAndCursor(leftSide: boolean) {
@@ -993,7 +1009,7 @@
     this._commentMap = this._getPaths(this._patchRange);
   }
 
-  _isFileUnchanged(diff: DiffInfo) {
+  _isFileUnchanged(diff?: DiffInfo) {
     if (!diff || !diff.content) return false;
     return !diff.content.some(
       content =>
@@ -1006,12 +1022,18 @@
       return;
     }
 
-    this._change = undefined;
+    // Everything in the diff view is tied to the change. It seems better to
+    // force the re-creation of the diff view when the change number changes.
+    const changeChanged = this._changeNum !== value.changeNum;
+    if (this._changeNum !== undefined && changeChanged) {
+      fireEvent(this, EventType.RECREATE_DIFF_VIEW);
+      return;
+    }
+
     this._files = {sortedFileList: [], changeFilesByPath: {}};
     this._path = undefined;
     this._patchRange = undefined;
     this._commitRange = undefined;
-    this._changeComments = undefined;
     this._focusLineNum = undefined;
 
     if (value.changeNum && value.project) {
@@ -1026,7 +1048,9 @@
     // the top-level change info view) and therefore undefined in `params`.
     // If route is of type /comment/<commentId>/ then no patchNum is present
     if (!value.patchNum && !value.commentLink) {
-      console.warn('invalid url, no patchNum found');
+      this.reporting.error(
+        new Error(`Invalid diff view URL, no patchNum found: ${value}`)
+      );
       return;
     }
 
@@ -1034,14 +1058,9 @@
 
     promises.push(this._getDiffPreferences());
 
-    promises.push(
-      this._getPreferences().then(prefs => {
-        this._userPrefs = prefs;
-      })
-    );
+    if (!this._change) promises.push(this._getChangeDetail(this._changeNum));
 
-    promises.push(this._getChangeDetail(this._changeNum));
-    promises.push(this._loadComments(value.patchNum));
+    if (!this._changeComments) this._loadComments(value.patchNum);
 
     promises.push(this._getChangeEdit());
 
@@ -1054,17 +1073,6 @@
         this._initPatchRange();
         this._initCommitRange();
 
-        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}`, {
@@ -1081,7 +1089,6 @@
         this.reporting.diffViewDisplayed();
       })
       .then(() => {
-        if (!this._diff) throw new Error('Missing this._diff');
         const fileUnchanged = this._isFileUnchanged(this._diff);
         if (fileUnchanged && value.commentLink) {
           assertIsDefined(this._change, '_change');
@@ -1130,44 +1137,41 @@
     }
   }
 
-  @observe('_loggedIn', 'params.*', '_prefs', '_patchRange.*')
+  @observe('_path', '_prefs', '_reviewedFiles', '_patchRange')
   _setReviewedObserver(
+    path?: string,
+    prefs?: DiffPreferencesInfo,
+    reviewedFiles?: Set<string>,
+    patchRange?: PatchRange
+  ) {
+    if (prefs === undefined) return;
+    if (path === undefined) return;
+    if (reviewedFiles === undefined) return;
+    if (patchRange === undefined) return;
+    if (prefs.manual_review) {
+      // Checkbox state needs to be set explicitly only when manual_review
+      // is specified.
+      this.$.reviewed.checked = this._getReviewedStatus(path);
+    } else {
+      this._setReviewed(true);
+    }
+  }
+
+  @observe('_loggedIn', '_changeNum', '_patchRange')
+  getReviewedFiles(
     _loggedIn?: boolean,
-    paramsRecord?: ElementPropertyDeepChange<GrDiffView, 'params'>,
-    _prefs?: DiffPreferencesInfo,
-    patchRangeRecord?: ElementPropertyDeepChange<GrDiffView, '_patchRange'>
+    _changeNum?: NumericChangeId,
+    patchRange?: PatchRange
   ) {
     if (_loggedIn === undefined) return;
-    if (paramsRecord === undefined) return;
-    if (_prefs === undefined) return;
-    if (patchRangeRecord === undefined) return;
-    if (patchRangeRecord.base === undefined) return;
+    if (_changeNum === undefined) return;
+    if (patchRange === undefined) return;
 
-    const patchRange = patchRangeRecord.base;
     if (!_loggedIn) {
       return;
     }
 
-    if (_prefs.manual_review) {
-      // Checkbox state needs to be set explicitly only when manual_review
-      // is specified.
-
-      if (patchRange.patchNum) {
-        this._getReviewedStatus(
-          this._editMode,
-          this._changeNum,
-          patchRange.patchNum,
-          this._path
-        ).then((status: boolean) => {
-          this.$.reviewed.checked = status;
-        });
-      }
-      return;
-    }
-
-    if (paramsRecord.base?.view === GerritNav.View.DIFF) {
-      this._setReviewed(true);
-    }
+    this._getReviewedFiles(this._changeNum, patchRange.patchNum);
   }
 
   /**
@@ -1178,11 +1182,11 @@
       return;
     }
     if (leftSide) {
-      this.$.cursor.side = Side.LEFT;
+      this.cursor.side = Side.LEFT;
     } else {
-      this.$.cursor.side = Side.RIGHT;
+      this.cursor.side = Side.RIGHT;
     }
-    this.$.cursor.initialLineNumber = this._focusLineNum;
+    this.cursor.initialLineNumber = this._focusLineNum;
   }
 
   _getLineOfInterest(leftSide: boolean): LineOfInterest | undefined {
@@ -1215,17 +1219,6 @@
     );
   }
 
-  _patchRangeStr(patchRange: PatchRange) {
-    let patchStr = `${patchRange.patchNum}`;
-    if (
-      patchRange.basePatchNum &&
-      patchRange.basePatchNum !== ParentPatchSetNum
-    ) {
-      patchStr = `${patchRange.basePatchNum}..${patchRange.patchNum}`;
-    }
-    return patchStr;
-  }
-
   /**
    * When the latest patch of the change is selected (and there is no base
    * patch) then the patch range need not appear in the URL. Return a patch
@@ -1316,11 +1309,8 @@
     return dropdownContent;
   }
 
-  _computePrefsButtonHidden(
-    prefs?: DiffPreferencesInfo,
-    prefsDisabled?: boolean
-  ) {
-    return prefsDisabled || !prefs;
+  _computePrefsButtonHidden(prefs?: DiffPreferencesInfo, loggedIn?: boolean) {
+    return !loggedIn || !prefs;
   }
 
   _handleFileChange(e: CustomEvent) {
@@ -1496,15 +1486,18 @@
 
   _loadComments(patchSet?: PatchSetNum) {
     assertIsDefined(this._changeNum, '_changeNum');
-    return this.$.commentAPI
-      .loadAll(this._changeNum, patchSet)
-      .then(comments => {
-        this._changeComments = comments;
-      });
+    return this.commentsService.loadAll(this._changeNum, patchSet);
   }
 
-  @observe('_files.changeFilesByPath', '_path', '_patchRange', '_projectConfig')
+  @observe(
+    '_changeComments',
+    '_files.changeFilesByPath',
+    '_path',
+    '_patchRange',
+    '_projectConfig'
+  )
   _recomputeComments(
+    changeComments?: ChangeComments,
     files?: {[path: string]: FileInfo},
     path?: string,
     patchRange?: PatchRange,
@@ -1514,11 +1507,11 @@
     if (!path) return;
     if (!patchRange) return;
     if (!projectConfig) return;
-    if (!this._changeComments) return;
+    if (!changeComments) return;
 
     const file = files[path];
     if (file && file.old_path) {
-      this.$.diffHost.threads = this._changeComments.getThreadsBySideForFile(
+      this.$.diffHost.threads = changeComments.getThreadsBySideForFile(
         {path, basePath: file.old_path},
         patchRange
       );
@@ -1530,12 +1523,6 @@
     return this._changeComments.getPaths(patchRange);
   }
 
-  _getDiffDrafts() {
-    assertIsDefined(this._changeNum, '_changeNum');
-
-    return this.restApiService.getDiffDrafts(this._changeNum);
-  }
-
   _computeCommentSkips(
     commentMap?: CommentMap,
     fileList?: string[],
@@ -1587,12 +1574,12 @@
 
   _loadBlame() {
     this._isBlameLoading = true;
-    fireAlert(this, MSG_LOADING_BLAME);
+    fireAlert(this, LOADING_BLAME);
     this.$.diffHost
       .loadBlame()
       .then(() => {
         this._isBlameLoading = false;
-        fireAlert(this, MSG_LOADED_BLAME);
+        fireAlert(this, LOADED_BLAME);
       })
       .catch(() => {
         this._isBlameLoading = false;
@@ -1611,28 +1598,19 @@
     this._loadBlame();
   }
 
-  _handleToggleBlame(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
+  _handleToggleBlame() {
     this._toggleBlame();
   }
 
-  _handleToggleHideAllCommentThreads(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
-
+  _handleToggleHideAllCommentThreads() {
     toggleClass(this, 'hideComments');
   }
 
-  _handleOpenFileList(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-    if (this.modifierPressed(e)) return;
+  _handleOpenFileList() {
     this.$.dropdown.open();
   }
 
-  _handleDiffAgainstBase(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleDiffAgainstBase() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1648,8 +1626,7 @@
     );
   }
 
-  _handleDiffBaseAgainstLeft(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleDiffBaseAgainstLeft() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1669,8 +1646,7 @@
     );
   }
 
-  _handleDiffAgainstLatest(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleDiffAgainstLatest() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1689,8 +1665,7 @@
     );
   }
 
-  _handleDiffRightAgainstLatest(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleDiffRightAgainstLatest() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1708,8 +1683,7 @@
     );
   }
 
-  _handleDiffBaseAgainstLatest(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleDiffBaseAgainstLatest() {
     if (!this._change) return;
     if (!this._path) return;
     if (!this._patchRange) return;
@@ -1746,44 +1720,13 @@
     return '';
   }
 
-  _handleToggleAllDiffContext(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
-
+  _handleToggleAllDiffContext() {
     this.$.diffHost.toggleAllContext();
   }
 
-  _computeDiffPrefsDisabled(disableDiffPrefs?: boolean, loggedIn?: boolean) {
-    return disableDiffPrefs || !loggedIn;
-  }
-
-  _navigateToNextUnreviewedFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
-    // Ensure that the currently viewed file always appears in unreviewedFiles
-    // so we resolve the right "next" file.
-    const unreviewedFiles = this._fileList.filter(
-      file => file === this._path || !this._reviewedFiles.has(file)
-    );
-    this._navToFile(this._path, unreviewedFiles, 1);
-  }
-
-  _navigateToPreviousUnreviewedFile() {
-    if (!this._path) return;
-    if (!this._fileList) return;
-    if (!this._reviewedFiles) return;
-    // Ensure that the currently viewed file always appears in unreviewedFiles
-    // so we resolve the right "next" file.
-    const unreviewedFiles = this._fileList.filter(
-      file => file === this._path || !this._reviewedFiles.has(file)
-    );
-    this._navToFile(this._path, unreviewedFiles, -1);
-  }
-
-  _handleNextUnreviewedFile(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) return;
+  _handleNextUnreviewedFile() {
     this._setReviewed(true);
-    this._navigateToNextUnreviewedFile();
+    this.navigateToUnreviewedFile('next');
   }
 
   _navigateToNextFileWithCommentThread() {
@@ -1806,14 +1749,19 @@
 
   _computeCanEdit(
     loggedIn?: boolean,
+    editWeblinks?: GeneratedWebLink[],
     changeChangeRecord?: PolymerDeepPropertyChange<ChangeInfo, ChangeInfo>
   ) {
     if (!changeChangeRecord?.base) return false;
-    return loggedIn && changeIsOpen(changeChangeRecord.base);
+    return (
+      loggedIn &&
+      changeIsOpen(changeChangeRecord.base) &&
+      (!editWeblinks || editWeblinks.length === 0)
+    );
   }
 
-  _computeIsLoggedIn(loggedIn: boolean) {
-    return loggedIn ? true : false;
+  _computeShowEditLinks(editWeblinks?: GeneratedWebLink[]) {
+    return !!editWeblinks && editWeblinks.length > 0;
   }
 
   /**
@@ -1836,6 +1784,10 @@
   _computeTruncatedPath(path?: string) {
     return path ? computeTruncatedPath(path) : '';
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
 
 declare global {
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 30c6f49..308c353 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       display: block;
@@ -30,9 +33,6 @@
     }
     gr-diff {
       border: none;
-      --diff-container-styles: {
-        border-bottom: 1px solid var(--border-color);
-      }
     }
     .stickyHeader {
       background-color: var(--view-background-color);
@@ -86,6 +86,7 @@
     }
     .jumpToFileContainer {
       display: inline-block;
+      word-break: break-all;
     }
     .mobile {
       display: none;
@@ -112,10 +113,6 @@
     .prefsButton {
       text-align: right;
     }
-    .noOverflow {
-      display: block;
-      overflow: auto;
-    }
     .editMode .hideOnEdit {
       display: none;
     }
@@ -144,11 +141,6 @@
     .separator.hide {
       display: none;
     }
-    gr-dropdown-list {
-      --trigger-style: {
-        text-transform: none;
-      }
-    }
     .editButtona a {
       text-decoration: none;
     }
@@ -193,6 +185,7 @@
       .jumpToFileContainer {
         display: block;
         width: 100%;
+        word-break: break-all;
       }
       gr-dropdown-list {
         width: 100%;
@@ -320,7 +313,10 @@
             _isBlameLoading)]]</gr-button
           >
         </span>
-        <template is="dom-if" if="[[_computeCanEdit(_loggedIn, _change.*)]]">
+        <template
+          is="dom-if"
+          if="[[_computeCanEdit(_loggedIn, _editWeblinks, _change.*)]]"
+        >
           <span class="separator"></span>
           <span class="editButton">
             <gr-button
@@ -331,29 +327,37 @@
             >
           </span>
         </template>
+        <template is="dom-if" if="[[_computeShowEditLinks(_editWeblinks)]]">
+          <span class="separator"></span>
+          <template is="dom-repeat" items="[[_editWeblinks]]" as="weblink">
+            <a target="_blank" href$="[[weblink.url]]">[[weblink.name]]</a>
+          </template>
+        </template>
         <span class="separator"></span>
         <div class$="diffModeSelector [[_computeModeSelectHideClass(_diff)]]">
           <span>Diff view:</span>
           <gr-diff-mode-selector
             id="modeSelect"
-            save-on-change="[[!_diffPrefsDisabled]]"
+            save-on-change="[[_loggedIn]]"
             mode="{{changeViewState.diffMode}}"
+            show-tooltip-below=""
           ></gr-diff-mode-selector>
         </div>
         <span
           id="diffPrefsContainer"
-          hidden$="[[_computePrefsButtonHidden(_prefs, _diffPrefsDisabled)]]"
+          hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]"
           hidden=""
         >
           <span class="preferences desktop">
-            <gr-button
-              link=""
-              class="prefsButton"
+            <gr-tooltip-content
               has-tooltip=""
+              position-below=""
               title="Diff preferences"
-              on-click="_handlePrefsTap"
-              ><iron-icon icon="gr-icons:settings"></iron-icon
-            ></gr-button>
+            >
+              <gr-button link="" class="prefsButton" on-click="_handlePrefsTap"
+                ><iron-icon icon="gr-icons:settings"></iron-icon
+              ></gr-button>
+            </gr-tooltip-content>
           </span>
         </span>
         <gr-endpoint-decorator name="annotation-toggler">
@@ -394,6 +398,7 @@
     hidden=""
     hidden$="[[_loading]]"
     is-image-diff="{{_isImageDiff}}"
+    edit-weblinks="{{_editWeblinks}}"
     files-weblinks="{{_filesWeblinks}}"
     diff="{{_diff}}"
     change-num="[[_changeNum]]"
@@ -423,11 +428,5 @@
     on-reload-diff-preference="_handleReloadingDiffPreference"
   >
   </gr-diff-preferences-dialog>
-  <gr-diff-cursor
-    id="cursor"
-    on-navigate-to-next-unreviewed-file="_navigateToNextUnreviewedFile"
-    on-navigate-to-previous-unreviewed-file="_navigateToPreviousUnreviewedFile"
-    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 5c3dfdc..e4a8aa4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.js
@@ -19,9 +19,8 @@
 import './gr-diff-view.js';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
 import {ChangeStatus} from '../../../constants/constants.js';
-import {TestKeyboardShortcutBinder, stubRestApi} from '../../../test/test-utils.js';
-import {Shortcut} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin.js';
-import {ChangeComments, _testOnly_findCommentById, _testOnly_getCommentsForPath} from '../gr-comment-api/gr-comment-api.js';
+import {stubRestApi} from '../../../test/test-utils.js';
+import {ChangeComments} from '../gr-comment-api/gr-comment-api.js';
 import {GerritView} from '../../../services/router/router-model.js';
 import {
   createChange,
@@ -29,6 +28,8 @@
   createComment,
 } from '../../../test/test-data-generators.js';
 import {EditPatchSetNum} from '../../../types/common.js';
+import {CursorMoveResult} from '../../../api/core.js';
+import {EventType} from '../../../types/events.js';
 
 const basicFixture = fixtureFromElement('gr-diff-view');
 
@@ -38,42 +39,7 @@
   suite('basic tests', () => {
     let element;
     let clock;
-
-    suiteSetup(() => {
-      const kb = TestKeyboardShortcutBinder.push();
-      kb.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-      kb.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-      kb.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-      kb.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-      kb.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-      kb.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-      kb.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-      kb.bindShortcut(Shortcut.SAVE_COMMENT, 'ctrl+s');
-      kb.bindShortcut(Shortcut.NEXT_FILE, ']');
-      kb.bindShortcut(Shortcut.PREV_FILE, '[');
-      kb.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-      kb.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-      kb.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-      kb.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-      kb.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a');
-      kb.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd');
-      kb.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-      kb.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
-      kb.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-      kb.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm');
-      kb.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r');
-      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');
-      kb.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-      kb.bindShortcut(Shortcut.TOGGLE_BLAME, 'b');
-      kb.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
-    });
-
-    suiteTeardown(() => {
-      TestKeyboardShortcutBinder.pop();
-    });
+    let diffCommentsStub;
 
     const PARENT = 'PARENT';
 
@@ -89,7 +55,6 @@
     }
 
     let getDiffChangeDetailStub;
-    let getReviewedFilesStub;
     setup(async () => {
       clock = sinon.useFakeTimers();
       stubRestApi('getConfig').returns(Promise.resolve({change: {}}));
@@ -99,12 +64,11 @@
           Promise.resolve({}));
       stubRestApi('getChangeFiles').returns(Promise.resolve({}));
       stubRestApi('saveFileReviewed').returns(Promise.resolve());
-      stubRestApi('getDiffComments').returns(Promise.resolve({}));
+      diffCommentsStub = stubRestApi('getDiffComments');
+      diffCommentsStub.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';
@@ -115,32 +79,21 @@
         patchNum: 77,
         basePatchNum: 'PARENT',
       };
-      sinon.stub(element.$.commentAPI, 'loadAll').returns(Promise.resolve({
-        _comments: {'/COMMIT_MSG': [
-          {
-            ...createComment(),
-            id: 'c1',
-            line: 10,
-            patch_set: 2,
-            path: '/COMMIT_MSG',
-          }, {
-            ...createComment(),
-            id: 'c3',
-            line: 10,
-            patch_set: 'PARENT',
-            path: '/COMMIT_MSG',
-          },
-        ]},
-        computeCommentThreadCount: () => {},
-        computeCommentsString: () => '',
-        computeUnresolvedNum: () => {},
-        getPaths: () => {},
-        getThreadsBySideForFile: () => [],
-        getCommentsForPath: _testOnly_getCommentsForPath,
-        findCommentById: _testOnly_findCommentById,
-
-      }));
-      await element._loadComments();
+      element._changeComments = new ChangeComments({'/COMMIT_MSG': [
+        {
+          ...createComment(),
+          id: 'c1',
+          line: 10,
+          patch_set: 2,
+          path: '/COMMIT_MSG',
+        }, {
+          ...createComment(),
+          id: 'c3',
+          line: 10,
+          patch_set: 'PARENT',
+          path: '/COMMIT_MSG',
+        },
+      ]});
       await flush();
     });
 
@@ -187,11 +140,28 @@
       });
 
       test('comment url resolves to comment.patch_set vs latest', () => {
+        diffCommentsStub.returns(Promise.resolve({
+          '/COMMIT_MSG': [
+            {
+              ...createComment(),
+              id: 'c1',
+              line: 10,
+              patch_set: 2,
+              path: '/COMMIT_MSG',
+            }, {
+              ...createComment(),
+              id: 'c3',
+              line: 10,
+              patch_set: 'PARENT',
+              path: '/COMMIT_MSG',
+            },
+          ]}));
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
           commentLink: true,
           commentId: 'c1',
+          path: 'abcd',
         };
         element._change = {
           ...createChange(),
@@ -237,6 +207,22 @@
     test('unchanged diff X vs latest from comment links navigates to base vs X'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -266,6 +252,22 @@
     test('unchanged diff Base vs latest from comment does not navigate'
         , () => {
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
+          diffCommentsStub.returns(Promise.resolve({
+            '/COMMIT_MSG': [
+              {
+                ...createComment(),
+                id: 'c1',
+                line: 10,
+                patch_set: 2,
+                path: '/COMMIT_MSG',
+              }, {
+                ...createComment(),
+                id: 'c3',
+                line: 10,
+                patch_set: 'PARENT',
+                path: '/COMMIT_MSG',
+              },
+            ]}));
           sinon.stub(element.reporting, 'diffViewDisplayed');
           sinon.stub(element, '_loadBlame');
           sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
@@ -322,11 +324,82 @@
       assert.equal(element._isFileUnchanged(diff), true);
     });
 
+    test('change detail is not rerequested if changeNum doesnt change',
+        async () => {
+          const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+          assert.isFalse(getDiffChangeDetailStub.called);
+          sinon.stub(element.reporting, 'diffViewDisplayed');
+          sinon.stub(element, '_loadBlame');
+          sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
+          sinon.spy(element, '_paramsChanged');
+          element._change = undefined;
+          getDiffChangeDetailStub.returns(
+              Promise.resolve({
+                ...createChange(),
+                revisions: createRevisions(11),
+              }));
+          element._patchRange = {
+            patchNum: 2,
+            basePatchNum: 1,
+          };
+          sinon.stub(element, '_isFileUnchanged').returns(false);
+
+          element.params = {
+            view: GerritNav.View.DIFF,
+            changeNum: '42',
+            project: 'p',
+            commentId: 'c1',
+            commentLink: true,
+          };
+          await element._paramsChanged.returnValues[0];
+
+          assert.equal(getDiffChangeDetailStub.callCount, 1);
+          element.params = {
+            view: GerritNav.View.DIFF,
+            changeNum: '42',
+            project: 'p',
+            commentId: 'c1',
+            commentLink: true,
+          };
+          await element._paramsChanged.returnValues[0];
+
+          assert.equal(getDiffChangeDetailStub.callCount, 1);
+          element.params = {
+            view: GerritNav.View.DIFF,
+            changeNum: '43',
+            project: 'p',
+            commentId: 'c1',
+            commentLink: true,
+          };
+          await element._paramsChanged.returnValues[0];
+
+          // change page is recreated now
+          assert.equal(dispatchEventStub.lastCall.args[0].type,
+              EventType.RECREATE_DIFF_VIEW);
+        });
+
     test('diff toast to go to latest is shown and not base', async () => {
+      diffCommentsStub.returns(Promise.resolve({
+        '/COMMIT_MSG': [
+          {
+            ...createComment(),
+            id: 'c1',
+            line: 10,
+            patch_set: 2,
+            path: '/COMMIT_MSG',
+          }, {
+            ...createComment(),
+            id: 'c3',
+            line: 10,
+            patch_set: 'PARENT',
+            path: '/COMMIT_MSG',
+          },
+        ]}));
       sinon.stub(element.reporting, 'diffViewDisplayed');
       sinon.stub(element, '_loadBlame');
       sinon.stub(element.$.diffHost, 'reload').returns(Promise.resolve());
       sinon.spy(element, '_paramsChanged');
+      element._change = undefined;
       getDiffChangeDetailStub.returns(
           Promise.resolve({
             ...createChange(),
@@ -353,7 +426,7 @@
     test('toggle left diff with a hotkey', () => {
       const toggleLeftDiffStub = sinon.stub(
           element.$.diffHost, 'toggleLeftDiff');
-      MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
+      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'A');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
 
@@ -416,25 +489,21 @@
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
-      element.disableDiffPrefs = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
-      assert(showPrefsStub.calledOnce);
-
-      let scrollStub = sinon.stub(element.$.cursor, 'moveToNextChunk');
+      let scrollStub = sinon.stub(element.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToPreviousChunk');
+      scrollStub = sinon.stub(element.cursor, 'moveToPreviousChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor, 'moveToNextCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      scrollStub = sinon.stub(element.cursor, 'moveToNextCommentThread');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
       assert(scrollStub.calledOnce);
 
-      scrollStub = sinon.stub(element.$.cursor,
+      scrollStub = sinon.stub(element.cursor,
           'moveToPreviousCommentThread');
-      MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
+      MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'P');
       assert(scrollStub.calledOnce);
 
       const computeContainerClassStub = sinon.stub(element.$.diffHost.$.diff,
@@ -443,32 +512,39 @@
       assert(computeContainerClassStub.lastCall.calledWithExactly(
           false, 'SIDE_BY_SIDE', true));
 
-      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
           false, 'SIDE_BY_SIDE', false));
 
+      // Note that stubbing _setReviewed means that the value of the
+      // `element.$.reviewed` checkbox is not flipped.
       sinon.stub(element, '_setReviewed');
       sinon.spy(element, '_handleToggleFileReviewed');
       element.$.reviewed.checked = false;
-      MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+      assert.isFalse(element._handleToggleFileReviewed.called);
       assert.isFalse(element._setReviewed.called);
-      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
 
       MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+      assert.isTrue(element._setReviewed.calledOnce);
+      assert.equal(element._setReviewed.lastCall.args[0], true);
+
+      // Handler is throttled, so another key press within 500 ms is ignored.
+      clock.tick(100);
+      MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+      assert.isTrue(element._handleToggleFileReviewed.calledOnce);
+      assert.isTrue(element._setReviewed.calledOnce);
 
       clock.tick(1000);
-
       MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
       assert.isTrue(element._handleToggleFileReviewed.calledTwice);
-      assert.isTrue(element._setReviewed.called);
-      assert.equal(element._setReviewed.lastCall.args[0], true);
+      assert.isTrue(element._setReviewed.calledTwice);
     });
 
     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);
+      sinon.stub(element.cursor, 'isAtEnd').returns(true);
       element._changeNum = '42';
       const comment = {
         'wheatley.md': [{
@@ -494,14 +570,14 @@
       element.changeViewState.selectedFileIndex = 1;
       element._loggedIn = true;
 
-      MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, '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');
+      MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'N');
       flush();
 
       assert.isTrue(diffChangeStub.called);
@@ -509,7 +585,7 @@
 
     test('shift+x shortcut toggles all diff context', () => {
       const toggleStub = sinon.stub(element.$.diffHost, 'toggleAllContext');
-      MockInteractions.pressAndReleaseKeyOn(element, 88, 'shift', 'x');
+      MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'X');
       flush();
       assert.isTrue(toggleStub.called);
     });
@@ -519,7 +595,6 @@
         basePatchNum: 5,
         patchNum: 10,
       };
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       element._handleDiffAgainstBase(new CustomEvent(''));
       const args = diffNavStub.getCall(0).args;
@@ -536,7 +611,6 @@
         basePatchNum: 5,
         patchNum: 10,
       };
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       element._handleDiffAgainstLatest(new CustomEvent(''));
       const args = diffNavStub.getCall(0).args;
@@ -554,7 +628,6 @@
         basePatchNum: 1,
       };
       element.params = {};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       element._handleDiffBaseAgainstLeft(new CustomEvent(''));
       assert(diffNavStub.called);
@@ -577,7 +650,6 @@
           sinon.stub(element, '_paramsChanged');
           element.params = {commentLink: true, view: GerritView.DIFF};
           element._focusLineNum = 10;
-          sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
           const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
           element._handleDiffBaseAgainstLeft(new CustomEvent(''));
           assert(diffNavStub.called);
@@ -596,7 +668,6 @@
         basePatchNum: 1,
         patchNum: 3,
       };
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       element._handleDiffRightAgainstLatest(new CustomEvent(''));
       assert(diffNavStub.called);
@@ -614,7 +685,6 @@
         basePatchNum: 1,
         patchNum: 3,
       };
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
       const diffNavStub = sinon.stub(GerritNav, 'navigateToDiff');
       element._handleDiffBaseAgainstLatest(new CustomEvent(''));
       assert(diffNavStub.called);
@@ -623,23 +693,21 @@
       assert.isNotOk(args[3]);
     });
 
-    test('A fires an error event when not logged in', done => {
+    test('A fires an error event when not logged in', async () => {
       const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
       sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
-          'should only work when the user is logged in.');
-        assert.isNull(window.sessionStorage.getItem(
-            'changeView.showReplyDialog'));
-        assert.isTrue(loggedInErrorSpy.called);
-        done();
-      });
+      await flush();
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+        'should only work when the user is logged in.');
+      assert.isNull(window.sessionStorage.getItem(
+          'changeView.showReplyDialog'));
+      assert.isTrue(loggedInErrorSpy.called);
     });
 
-    test('A navigates to change with logged in', done => {
+    test('A navigates to change with logged in', async () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 5,
@@ -657,41 +725,38 @@
       const loggedInErrorSpy = sinon.spy();
       element.addEventListener('show-auth-required', loggedInErrorSpy);
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isTrue(element.changeViewState.showReplyDialog);
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
-            5), 'Should navigate to /c/42/5..10');
-        assert.isFalse(loggedInErrorSpy.called);
-        done();
-      });
+      await flush();
+      assert.isTrue(element.changeViewState.showReplyDialog);
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, 10,
+          5), 'Should navigate to /c/42/5..10');
+      assert.isFalse(loggedInErrorSpy.called);
     });
 
-    test('A navigates to change with old patch number with logged in', done => {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: PARENT,
-        patchNum: 1,
-      };
-      element._change = {
-        _number: 42,
-        revisions: {
-          a: {_number: 1, commit: {parents: []}},
-          b: {_number: 2, commit: {parents: []}},
-        },
-      };
-      const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
-      sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
-      const loggedInErrorSpy = sinon.spy();
-      element.addEventListener('show-auth-required', loggedInErrorSpy);
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      flush(() => {
-        assert.isTrue(element.changeViewState.showReplyDialog);
-        assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
-            PARENT), 'Should navigate to /c/42/1');
-        assert.isFalse(loggedInErrorSpy.called);
-        done();
-      });
-    });
+    test('A navigates to change with old patch number with logged in',
+        async () => {
+          element._changeNum = '42';
+          element._patchRange = {
+            basePatchNum: PARENT,
+            patchNum: 1,
+          };
+          element._change = {
+            _number: 42,
+            revisions: {
+              a: {_number: 1, commit: {parents: []}},
+              b: {_number: 2, commit: {parents: []}},
+            },
+          };
+          const changeNavStub = sinon.stub(GerritNav, 'navigateToChange');
+          sinon.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+          const loggedInErrorSpy = sinon.spy();
+          element.addEventListener('show-auth-required', loggedInErrorSpy);
+          MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+          await flush();
+          assert.isTrue(element.changeViewState.showReplyDialog);
+          assert(changeNavStub.lastCall.calledWithExactly(element._change, 1,
+              PARENT), 'Should navigate to /c/42/1');
+          assert.isFalse(loggedInErrorSpy.called);
+        });
 
     test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
@@ -805,7 +870,7 @@
       assert.isTrue(changeNavStub.calledOnce);
     });
 
-    test('edit should redirect to edit page', done => {
+    test('edit should redirect to edit page', async () => {
       element._loggedIn = true;
       element._path = 't.txt';
       element._patchRange = {
@@ -822,23 +887,21 @@
         },
       };
       const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      flush(() => {
-        const editBtn = element.shadowRoot
-            .querySelector('.editButton gr-button');
-        assert.isTrue(!!editBtn);
-        MockInteractions.tap(editBtn);
-        assert.isTrue(redirectStub.called);
-        assert.isTrue(redirectStub.lastCall.calledWithExactly(
-            GerritNav.getEditUrlForDiff(
-                element._change,
-                element._path,
-                element._patchRange.patchNum
-            )));
-        done();
-      });
+      await flush();
+      const editBtn = element.shadowRoot
+          .querySelector('.editButton gr-button');
+      assert.isTrue(!!editBtn);
+      MockInteractions.tap(editBtn);
+      assert.isTrue(redirectStub.called);
+      assert.isTrue(redirectStub.lastCall.calledWithExactly(
+          GerritNav.getEditUrlForDiff(
+              element._change,
+              element._path,
+              element._patchRange.patchNum
+          )));
     });
 
-    test('edit should redirect to edit page with line number', done => {
+    test('edit should redirect to edit page with line number', async () => {
       const lineNumber = 42;
       element._loggedIn = true;
       element._path = 't.txt';
@@ -855,24 +918,22 @@
           b: {_number: 2, commit: {parents: []}},
         },
       };
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: lineNumber, isLeftSide: false});
       const redirectStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
-      flush(() => {
-        const editBtn = element.shadowRoot
-            .querySelector('.editButton gr-button');
-        assert.isTrue(!!editBtn);
-        MockInteractions.tap(editBtn);
-        assert.isTrue(redirectStub.called);
-        assert.isTrue(redirectStub.lastCall.calledWithExactly(
-            GerritNav.getEditUrlForDiff(
-                element._change,
-                element._path,
-                element._patchRange.patchNum,
-                lineNumber
-            )));
-        done();
-      });
+      await flush();
+      const editBtn = element.shadowRoot
+          .querySelector('.editButton gr-button');
+      assert.isTrue(!!editBtn);
+      MockInteractions.tap(editBtn);
+      assert.isTrue(redirectStub.called);
+      assert.isTrue(redirectStub.lastCall.calledWithExactly(
+          GerritNav.getEditUrlForDiff(
+              element._change,
+              element._path,
+              element._patchRange.patchNum,
+              lineNumber
+          )));
     });
 
     function isEditVisibile({loggedIn, changeStatus}) {
@@ -930,8 +991,7 @@
     });
 
     suite('diff prefs hidden', () => {
-      test('when no prefs or logged out', () => {
-        element.disableDiffPrefs = false;
+      test('whenlogged out', () => {
         element._loggedIn = false;
         flush();
         assert.isTrue(element.$.diffPrefsContainer.hidden);
@@ -946,21 +1006,9 @@
         assert.isTrue(element.$.diffPrefsContainer.hidden);
 
         element._loggedIn = true;
-        flush();
-        assert.isFalse(element.$.diffPrefsContainer.hidden);
-      });
-
-      test('when disableDiffPrefs is set', () => {
-        element._loggedIn = true;
         element._prefs = {font_size: '12'};
-        element.disableDiffPrefs = false;
         flush();
-
         assert.isFalse(element.$.diffPrefsContainer.hidden);
-        element.disableDiffPrefs = true;
-        flush();
-
-        assert.isTrue(element.$.diffPrefsContainer.hidden);
       });
     });
 
@@ -1155,10 +1203,11 @@
       const saveReviewedStub = sinon.stub(element, '_saveReviewedState')
           .callsFake(() => Promise.resolve());
       const getReviewedStub = sinon.stub(element, '_getReviewedStatus')
-          .callsFake(() => Promise.resolve());
+          .returns(false);
 
       sinon.stub(element.$.diffHost, 'reload');
       element._loggedIn = true;
+      element._prefs = {manual_review: true};
       element.params = {
         view: GerritNav.View.DIFF,
         changeNum: '42',
@@ -1170,17 +1219,19 @@
         patchNum: 2,
         basePatchNum: 1,
       };
-      element._prefs = {manual_review: true};
       flush();
 
       assert.isFalse(saveReviewedStub.called);
       assert.isTrue(getReviewedStub.called);
 
+      const oldCount = getReviewedStub.callCount;
+
       element._prefs = {};
+      element._path = 'abcd';
       flush();
 
       assert.isTrue(saveReviewedStub.called);
-      assert.isTrue(getReviewedStub.calledOnce);
+      assert.equal(getReviewedStub.callCount, oldCount);
     });
 
     test('file review status', () => {
@@ -1200,6 +1251,7 @@
         patchNum: 2,
         basePatchNum: 1,
       };
+      element._path = 'abcd';
       element._prefs = {};
       flush();
 
@@ -1235,7 +1287,7 @@
       assert.isFalse(saveReviewedStub.called);
     });
 
-    test('hash is determined from params', done => {
+    test('hash is determined from params', async () => {
       sinon.stub(element.$.diffHost, 'reload');
       sinon.stub(element, '_initLineOfInterestAndCursor');
 
@@ -1249,10 +1301,8 @@
         hash: 10,
       };
 
-      flush(() => {
-        assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
-        done();
-      });
+      await flush();
+      assert.isTrue(element._initLineOfInterestAndCursor.calledOnce);
     });
 
     test('diff mode selector correctly toggles the diff', () => {
@@ -1301,15 +1351,13 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
-    test('diff mode selector should be hidden for binary', done => {
+    test('diff mode selector should be hidden for binary', async () => {
       element._diff = {binary: true, content: []};
 
-      flush(() => {
-        const diffModeSelector = element.shadowRoot
-            .querySelector('.diffModeSelector');
-        assert.isTrue(diffModeSelector.classList.contains('hide'));
-        done();
-      });
+      await flush();
+      const diffModeSelector = element.shadowRoot
+          .querySelector('.diffModeSelector');
+      assert.isTrue(diffModeSelector.classList.contains('hide'));
     });
 
     suite('_commitRange', () => {
@@ -1341,7 +1389,7 @@
             change));
       });
 
-      test('uses the patchNum and basePatchNum ', done => {
+      test('uses the patchNum and basePatchNum ', async () => {
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
@@ -1350,16 +1398,14 @@
           path: '/COMMIT_MSG',
         };
         element._change = change;
-        flush(() => {
-          assert.deepEqual(element._commitRange, {
-            baseCommit: 'commit-sha-2',
-            commit: 'commit-sha-4',
-          });
-          done();
+        await flush();
+        assert.deepEqual(element._commitRange, {
+          baseCommit: 'commit-sha-2',
+          commit: 'commit-sha-4',
         });
       });
 
-      test('uses the parent when there is no base patch num ', done => {
+      test('uses the parent when there is no base patch num ', async () => {
         element.params = {
           view: GerritNav.View.DIFF,
           changeNum: '42',
@@ -1367,45 +1413,43 @@
           path: '/COMMIT_MSG',
         };
         element._change = change;
-        flush(() => {
-          assert.deepEqual(element._commitRange, {
-            commit: 'commit-sha-5',
-            baseCommit: 'sha-5-parent',
-          });
-          done();
+        await flush();
+        assert.deepEqual(element._commitRange, {
+          commit: 'commit-sha-5',
+          baseCommit: 'sha-5-parent',
         });
       });
     });
 
     test('_initCursor', () => {
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify no cursor address:
       element._initCursor(false);
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Does nothing when params specify side but no number:
       element._initCursor(true);
-      assert.isNotOk(element.$.cursor.initialLineNumber);
+      assert.isNotOk(element.cursor.initialLineNumber);
 
       // Revision hash: specifies lineNum but not side.
 
       element._focusLineNum = 234;
       element._initCursor(false);
-      assert.equal(element.$.cursor.initialLineNumber, 234);
-      assert.equal(element.$.cursor.side, 'right');
+      assert.equal(element.cursor.initialLineNumber, 234);
+      assert.equal(element.cursor.side, 'right');
 
       // Base hash: specifies lineNum and side.
       element._focusLineNum = 345;
       element._initCursor(true);
-      assert.equal(element.$.cursor.initialLineNumber, 345);
-      assert.equal(element.$.cursor.side, 'left');
+      assert.equal(element.cursor.initialLineNumber, 345);
+      assert.equal(element.cursor.side, 'left');
 
       // Specifies right side:
       element._focusLineNum = 123;
       element._initCursor(false);
-      assert.equal(element.$.cursor.initialLineNumber, 123);
-      assert.equal(element.$.cursor.side, 'right');
+      assert.equal(element.cursor.initialLineNumber, 123);
+      assert.equal(element.cursor.side, 'right');
     });
 
     test('_getLineOfInterest', () => {
@@ -1424,7 +1468,7 @@
     test('_onLineSelected', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: false});
 
       element._changeNum = 321;
@@ -1446,7 +1490,7 @@
     test('line selected on left side', () => {
       const getUrlStub = sinon.stub(GerritNav, 'getUrlForDiffById');
       const replaceStateStub = sinon.stub(history, 'replaceState');
-      sinon.stub(element.$.cursor, 'getAddress')
+      sinon.stub(element.cursor, 'getAddress')
           .returns({number: 123, isLeftSide: true});
 
       element._changeNum = 321;
@@ -1470,6 +1514,7 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
 
       // User prefs but no change view state set.
+      element.changeViewState.diffMode = undefined;
       element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
       assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
 
@@ -1479,8 +1524,9 @@
     });
 
     test('_handleToggleDiffMode', () => {
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      const e = {preventDefault: () => {}};
+      const e = new CustomEvent('keydown', {
+        detail: {keyboardEvent: new KeyboardEvent('keydown'), key: 'x'},
+      });
       // Initial state.
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
 
@@ -1493,10 +1539,12 @@
 
     suite('_initPatchRange', () => {
       setup(async () => {
+        stubRestApi('getDiff').returns(Promise.resolve({}));
         element.params = {
           view: GerritView.DIFF,
           changeNum: '42',
           patchNum: 3,
+          path: 'abcd',
         };
         await flush();
       });
@@ -1671,25 +1719,6 @@
           [{value: '/foo'}, {value: '/bar'}]), 'show');
     });
 
-    test('_getReviewedStatus', () => {
-      const promises = [];
-      getReviewedFilesStub.returns(Promise.resolve(['path']));
-
-      promises.push(element._getReviewedStatus(true, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'otherPath')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, null, null, 'path')
-          .then(reviewed => assert.isFalse(reviewed)));
-
-      promises.push(element._getReviewedStatus(false, 3, 5, 'path')
-          .then(reviewed => assert.isTrue(reviewed)));
-
-      return Promise.all(promises);
-    });
-
     test('f open file dropdown', () => {
       assert.isFalse(element.$.dropdown.$.dropdown.opened);
       MockInteractions.pressAndReleaseKeyOn(element, 70, null, 'f');
@@ -1735,17 +1764,131 @@
       });
     });
 
-    test('_paramsChanged sets in projectLookup', () => {
-      sinon.stub(element, '_initLineOfInterestAndCursor');
-      const setStub = stubRestApi('setInProjectLookup');
-      element._paramsChanged({
-        view: GerritNav.View.DIFF,
-        changeNum: 101,
-        project: 'test-project',
-        path: '',
+    suite('switching files', () => {
+      let dispatchEventStub;
+      let navToFileStub;
+      let moveToPreviousChunkStub;
+      let moveToNextChunkStub;
+      let isAtStartStub;
+      let isAtEndStub;
+      let nowStub;
+
+      setup(() => {
+        dispatchEventStub = sinon.stub(
+            element, 'dispatchEvent').callThrough();
+        navToFileStub = sinon.stub(element, '_navToFile');
+        moveToPreviousChunkStub =
+            sinon.stub(element.cursor, 'moveToPreviousChunk');
+        moveToNextChunkStub =
+            sinon.stub(element.cursor, 'moveToNextChunk');
+        isAtStartStub = sinon.stub(element.cursor, 'isAtStart');
+        isAtEndStub = sinon.stub(element.cursor, 'isAtEnd');
+        nowStub = sinon.stub(Date, 'now');
       });
-      assert.isTrue(setStub.calledOnce);
-      assert.isTrue(setStub.calledWith(101, 'test-project'));
+
+      test('shows toast when at the end of file', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        assert.isTrue(moveToNextChunkStub.called);
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to next file when n is tapped again', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element._reviewedFiles = new Set(['file2']);
+        element._path = 'file1';
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        nowStub.returns(10);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [
+          'file1',
+          ['file1', 'file3'],
+          1,
+        ]);
+      });
+
+      test('does not navigate if n is tapped twice too slow', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        nowStub.returns(6000);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('shows toast when at the start of file', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isTrue(moveToPreviousChunkStub.called);
+        assert.equal(dispatchEventStub.lastCall.args[0].type, 'show-alert');
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('navigates to prev file when p is tapped again', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
+        element._reviewedFiles = new Set(['file2']);
+        element._path = 'file3';
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+        nowStub.returns(10);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isTrue(navToFileStub.called);
+        assert.deepEqual(navToFileStub.lastCall.args, [
+          'file3',
+          ['file1', 'file3'],
+          -1,
+        ]);
+      });
+
+      test('does not navigate if p is tapped twice too slow', () => {
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+        nowStub.returns(6000);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
+
+      test('does not navigate when tapping n then p', () => {
+        moveToNextChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtEndStub.returns(true);
+
+        nowStub.returns(5);
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+
+        moveToPreviousChunkStub.returns(CursorMoveResult.CLIPPED);
+        isAtStartStub.returns(true);
+
+        nowStub.returns(10);
+        MockInteractions.pressAndReleaseKeyOn(element, 80, null, 'p');
+
+        assert.isFalse(navToFileStub.called);
+      });
     });
 
     test('shift+m navigates to next unreviewed file', () => {
@@ -1754,7 +1897,7 @@
       element._path = 'file1';
       const reviewedStub = sinon.stub(element, '_setReviewed');
       const navStub = sinon.stub(element, '_navToFile');
-      MockInteractions.pressAndReleaseKeyOn(element, 77, 'shift', 'm');
+      MockInteractions.pressAndReleaseKeyOn(element, 77, null, 'M');
       flush();
 
       assert.isTrue(reviewedStub.lastCall.args[0]);
@@ -1765,7 +1908,7 @@
       ]);
     });
 
-    test('File change should trigger navigateToDiff once', done => {
+    test('File change should trigger navigateToDiff once', async () => {
       element._files = getFilesFromFileList(['file1', 'file2', 'file3']);
       sinon.stub(element, '_initLineOfInterestAndCursor');
       sinon.stub(GerritNav, 'navigateToDiff');
@@ -1786,7 +1929,7 @@
         ...createChange(),
         revisions: createRevisions(1),
       };
-      flush();
+      await flush();
       assert.isTrue(GerritNav.navigateToDiff.notCalled);
 
       // Switch to file2
@@ -1808,7 +1951,6 @@
 
       // No extra call
       assert.isTrue(GerritNav.navigateToDiff.calledOnce);
-      done();
     });
 
     test('_computeDownloadDropdownLinks', () => {
@@ -1907,7 +2049,7 @@
     });
   });
 
-  suite('gr-diff-view tests unmodified files with comments', () => {
+  suite('unmodified files with comments', () => {
     let element;
     setup(() => {
       const changedFiles = {
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 0ca929a..7393606 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
@@ -18,6 +18,11 @@
 import {CommentRange} from '../../../types/common';
 import {FILE, LineNumber} from './gr-diff-line';
 import {Side} from '../../../constants/constants';
+import {DiffInfo} from '../../../types/diff';
+
+// If any line of the diff is more than the character limit, then disable
+// syntax highlighting for the entire file.
+export const SYNTAX_MAX_LINE_LENGTH = 500;
 
 /**
  * Compare two ranges. Either argument may be falsy, but will only return
@@ -43,6 +48,36 @@
   return range.end_line - range.start_line > 10;
 }
 
+export function getLineNumberByChild(node?: Node) {
+  return getLineNumber(getLineElByChild(node));
+}
+
+export function lineNumberToNumber(lineNumber?: LineNumber | null): number {
+  if (!lineNumber) return 0;
+  if (lineNumber === 'LOST') return 0;
+  if (lineNumber === 'FILE') return 0;
+  return lineNumber;
+}
+
+export function getLineElByChild(node?: Node): HTMLElement | null {
+  while (node) {
+    if (node instanceof Element) {
+      if (node.classList.contains('lineNum')) {
+        return node as HTMLElement;
+      }
+      if (node.classList.contains('section')) {
+        return null;
+      }
+    }
+    node = node.previousSibling ?? node.parentElement ?? undefined;
+  }
+  return null;
+}
+
+export function getSideByLineEl(lineEl: Element) {
+  return lineEl.classList.contains(Side.RIGHT) ? Side.RIGHT : Side.LEFT;
+}
+
 export function getLineNumber(lineEl?: Element | null): LineNumber | null {
   if (!lineEl) return null;
   const lineNumberStr = lineEl.getAttribute('data-value');
@@ -94,9 +129,46 @@
   rootId: string;
 }
 
+const VISIBLE_TEXT_NODE_TYPES = [Node.TEXT_NODE, Node.ELEMENT_NODE];
+
+export function getPreviousContentNodes(node?: Node | null) {
+  const sibs = [];
+  while (node) {
+    const {parentNode, previousSibling} = node;
+    const topContentLevel =
+      parentNode &&
+      (parentNode as HTMLElement).classList.contains('contentText');
+    let previousEl: Node | undefined | null;
+    if (previousSibling) {
+      previousEl = previousSibling;
+    } else if (!topContentLevel) {
+      previousEl = parentNode?.previousSibling;
+    }
+    if (previousEl && VISIBLE_TEXT_NODE_TYPES.includes(previousEl.nodeType)) {
+      sibs.push(previousEl);
+    }
+    node = previousEl;
+  }
+  return sibs;
+}
+
 export function isThreadEl(node: Node): node is GrDiffThreadElement {
   return (
     node.nodeType === Node.ELEMENT_NODE &&
     (node as Element).classList.contains('comment-thread')
   );
 }
+
+/**
+ * @return whether any of the lines in diff are longer
+ * than SYNTAX_MAX_LINE_LENGTH.
+ */
+export function anyLineTooLong(diff?: DiffInfo) {
+  if (!diff) return false;
+  return diff.content.some(section => {
+    const lines = section.ab
+      ? section.ab
+      : (section.a || []).concat(section.b || []);
+    return lines.some(line => line.length >= SYNTAX_MAX_LINE_LENGTH);
+  });
+}
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 ae51246..6003a2f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.ts
@@ -29,6 +29,7 @@
 import {LineNumber} from './gr-diff-line';
 import {
   getLine,
+  getLineElByChild,
   getLineNumber,
   getRange,
   getSide,
@@ -39,14 +40,22 @@
 } from './gr-diff-utils';
 import {getHiddenScroll} from '../../../scripts/hiddenscroll';
 import {customElement, observe, property} from '@polymer/decorators';
-import {BlameInfo, CommentRange, ImageInfo} from '../../../types/common';
+import {
+  BlameInfo,
+  CommentRange,
+  ImageInfo,
+  NumericChangeId,
+} from '../../../types/common';
 import {
   DiffInfo,
   DiffPreferencesInfo,
   DiffPreferencesInfoKey,
 } from '../../../types/diff';
 import {GrDiffHighlight} from '../gr-diff-highlight/gr-diff-highlight';
-import {GrDiffBuilderElement} from '../gr-diff-builder/gr-diff-builder-element';
+import {
+  GrDiffBuilderElement,
+  getLineNumberCellWidth,
+} from '../gr-diff-builder/gr-diff-builder-element';
 import {
   CoverageRange,
   DiffLayer,
@@ -61,19 +70,23 @@
 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';
 import {getContentEditableRange} from '../../../utils/safari-selection-util';
-
+import {AbortStop} from '../../../api/core';
 import {
   CreateCommentEventDetail as CreateCommentEventDetailApi,
   RenderPreferences,
+  GrDiff as GrDiffApi,
 } from '../../../api/diff';
 import {isSafari, toggleClass} from '../../../utils/dom-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
-import {DiffContextExpandedEventDetail} from '../gr-diff-builder/gr-diff-builder';
+import {
+  DiffContextExpandedEventDetail,
+  getResponsiveMode,
+  isResponsive,
+} from '../gr-diff-builder/gr-diff-builder';
 
 const NO_NEWLINE_BASE = 'No newline at end of base file.';
 const NO_NEWLINE_REVISION = 'No newline at end of revision file.';
@@ -109,7 +122,7 @@
 }
 
 @customElement('gr-diff')
-export class GrDiff extends PolymerElement {
+export class GrDiff extends PolymerElement implements GrDiffApi {
   static get template() {
     return htmlTemplate;
   }
@@ -148,7 +161,7 @@
    */
 
   @property({type: String})
-  changeNum?: string;
+  changeNum?: NumericChangeId;
 
   @property({type: Boolean})
   noAutoRender = false;
@@ -169,7 +182,7 @@
   isImageDiff?: boolean;
 
   @property({type: Boolean, reflectToAttribute: true})
-  hidden = false;
+  override hidden = false;
 
   @property({type: Boolean})
   noRenderOnPrefsChange?: boolean;
@@ -230,7 +243,7 @@
    * obtained by making the content of the diff table "contentEditable".
    */
   @property({type: Boolean})
-  isContentEditable = isSafari();
+  override isContentEditable = isSafari();
 
   /**
    * Whether the safety check for large diffs when whole-file is set has
@@ -306,15 +319,13 @@
     this.addEventListener('moved-link-clicked', e => this._movedLinkClicked(e));
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._observeNodes();
     this.isAttached = true;
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.isAttached = false;
     this.renderDiffTableTask?.cancel();
     this._unobserveIncrementalNodes();
@@ -322,6 +333,12 @@
     super.disconnectedCallback();
   }
 
+  getLineNumEls(side: Side): HTMLElement[] {
+    return Array.from(
+      this.root?.querySelectorAll<HTMLElement>(`.lineNum.${side}`) ?? []
+    );
+  }
+
   showNoChangeMessage(
     loading?: boolean,
     prefs?: DiffPreferencesInfo,
@@ -549,7 +566,7 @@
       el.classList.contains('content') ||
       el.classList.contains('contentText')
     ) {
-      const target = this.$.diffBuilder.getLineElByChild(el);
+      const target = getLineElByChild(el);
       if (target) {
         this._selectLine(target);
       }
@@ -719,33 +736,69 @@
     if (!prefs) return;
 
     this.blame = null;
+    this._updatePreferenceStyles(prefs, this.renderPrefs);
 
+    if (this.diff && !this.noRenderOnPrefsChange) {
+      this._debounceRenderDiffTable();
+    }
+  }
+
+  _updatePreferenceStyles(
+    prefs: DiffPreferencesInfo,
+    renderPrefs?: RenderPreferences
+  ) {
     const lineLength =
       this.path === COMMIT_MSG_PATH
         ? COMMIT_MSG_LINE_LENGTH
         : prefs.line_length;
+    const sideBySide = this.viewMode === 'SIDE_BY_SIDE';
     const stylesToUpdate: {[key: string]: string} = {};
 
-    if (prefs.line_wrapping) {
-      this._diffTableClass = 'full-width';
-      if (this.viewMode === 'SIDE_BY_SIDE') {
-        stylesToUpdate['--content-width'] = 'none';
-        stylesToUpdate['--line-limit'] = `${lineLength}ch`;
-      }
-    } else {
-      this._diffTableClass = '';
-      stylesToUpdate['--content-width'] = `${lineLength}ch`;
-    }
+    const responsiveMode = getResponsiveMode(prefs, renderPrefs);
+    const responsive = isResponsive(responsiveMode);
+    this._diffTableClass = responsive ? 'responsive' : '';
+    const lineLimit = `${lineLength}ch`;
+    stylesToUpdate['--line-limit-marker'] =
+      responsiveMode === 'FULL_RESPONSIVE' ? lineLimit : '-1px';
+    stylesToUpdate['--content-width'] = responsive ? 'none' : lineLimit;
+    if (responsiveMode === 'SHRINK_ONLY') {
+      // Calculating ideal (initial) width for the whole table including
+      // width of each table column (content and line number columns) and
+      // border. We also add a 1px correction as some values are calculated
+      // in 'ch'.
 
+      // We might have 1 to 2 columns for content depending if side-by-side
+      // or unified mode
+      const contentWidth = `${sideBySide ? 2 : 1} * ${lineLimit}`;
+
+      // We always have 2 columns for line number
+      const lineNumberWidth = `2 * ${getLineNumberCellWidth(prefs)}px`;
+
+      // border-right in ".section" css definition (in gr-diff_html.ts)
+      const sectionRightBorder = '1px';
+
+      // As some of these calculations are done using 'ch' we end up
+      // having <1px difference between ideal and calculated size for each side
+      // leading to lines using the max columns (e.g. 80) to wrap (decided
+      // exclusively by the browser).This happens even in monospace fonts.
+      // Empirically adding 2px as correction to be sure wrapping won't happen in these
+      // cases so it doesn' block further experimentation with the SHRINK_MODE.
+      // This was previously set to 1px but due to to a more aggressive
+      // text wrapping (via word-break: break-all; - check .contextText)
+      // we need to be even more lenient in some cases.
+      // If we find another way to avoid this correction we will change it.
+      const dontWrapCorrection = '2px';
+      stylesToUpdate[
+        '--diff-max-width'
+      ] = `calc(${contentWidth} + ${lineNumberWidth} + ${sectionRightBorder} + ${dontWrapCorrection})`;
+    } else {
+      stylesToUpdate['--diff-max-width'] = 'none';
+    }
     if (prefs.font_size) {
       stylesToUpdate['--font-size'] = `${prefs.font_size}px`;
     }
 
     this.updateStyles(stylesToUpdate);
-
-    if (this.diff && !this.noRenderOnPrefsChange) {
-      this._debounceRenderDiffTable();
-    }
   }
 
   _renderPrefsChanged(renderPrefs?: RenderPreferences) {
@@ -759,6 +812,10 @@
     if (renderPrefs.hide_line_length_indicator) {
       this.classList.add('hide-line-length-indicator');
     }
+    if (this.prefs) {
+      this._updatePreferenceStyles(this.prefs, renderPrefs);
+    }
+    this.$.diffBuilder.updateRenderPrefs(renderPrefs);
   }
 
   _diffChanged(newValue?: DiffInfo) {
@@ -825,9 +882,9 @@
     );
     this._setLoading(false);
     this._unobserveIncrementalNodes();
-    this._incrementalNodeObserver = (dom(
-      this
-    ) as PolymerDomWrapper).observeNodes(info => {
+    this._incrementalNodeObserver = (
+      dom(this) as PolymerDomWrapper
+    ).observeNodes(info => {
       const addedThreadEls = info.addedNodes.filter(isThreadEl);
       // Removed nodes do not need to be handled because all this code does is
       // adding a slot for the added thread elements, and the extra slots do
@@ -893,7 +950,7 @@
       // Safari is not binding newly created comment-thread
       // with the slot somehow, replace itself will rebind it
       // @see Issue 11182
-      if (lastEl && lastEl.replaceWith) {
+      if (isSafari() && lastEl && lastEl.replaceWith) {
         lastEl.replaceWith(lastEl);
       }
 
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 e60fee8..67b7a9f 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
@@ -50,9 +50,9 @@
       background-color: var(--diff-blank-background-color);
     }
     .diffContainer {
+      max-width: var(--diff-max-width, none);
       display: flex;
       font-family: var(--monospace-font-family);
-      @apply --diff-container-styles;
     }
     .diffContainer.hiddenscroll {
       margin-bottom: var(--spacing-m);
@@ -61,10 +61,25 @@
       border-collapse: collapse;
       table-layout: fixed;
     }
+    td.lineNum {
+      /* Enforces background whenever lines wrap */
+      background-color: var(--diff-blank-background-color);
+    }
+
+    /* Provides the option to add side borders (left and right) to the line number column. */
+    td.left,
+    td.right,
+    td.moveControlsLineNumCol,
+    td.contextLineNum {
+      box-shadow: var(--line-number-box-shadow, unset);
+    }
 
     /*
       Context controls break up the table visually, so we set the right border
       on individual sections to leave a gap for the divider.
+
+      Also taken into account for max-width calculations in SHRINK_ONLY
+      mode (check GrDiff._updatePreferenceStyles).
       */
     .section {
       border-right: 1px solid var(--border-color);
@@ -96,6 +111,7 @@
       width: 100%;
       height: 100%;
       background-color: var(--diff-blank-background-color);
+      box-shadow: var(--line-number-box-shadow, unset);
     }
     td.lineNum {
       vertical-align: top;
@@ -113,6 +129,14 @@
       height: 100%;
       max-width: var(--image-viewer-max-width, 95vw);
       max-height: var(--image-viewer-max-height, 90vh);
+      /*
+        Defined by paper-styles default-theme and used in various components.
+        background-color-secondary is a compromise between fairly light in
+        light theme (where we ideally would want background-color-primary) yet
+        slightly offset against the app background in dark mode, where drop
+        shadows e.g. around paper-card are almost invisible.
+        */
+      --primary-background-color: var(--background-color-secondary);
     }
     .image-diff .gr-diff {
       text-align: center;
@@ -161,12 +185,12 @@
     .image-diff .content {
       background-color: var(--diff-blank-background-color);
     }
-    .full-width {
+    .responsive {
       width: 100%;
     }
-    .full-width .contentText {
+    .responsive .contentText {
       white-space: break-spaces;
-      word-wrap: break-word;
+      word-break: break-all;
     }
     .lineNumButton,
     .content {
@@ -231,44 +255,25 @@
 
     /* dueToMove */
     .dueToMove .content.add .contentText,
-    .dueToMove .moveControls.movedIn .moveLabel,
+    .dueToMove .moveControls.movedIn .moveHeader,
     .delta.total.dueToMove .content.add .contentText {
       background-color: var(--diff-moved-in-background);
     }
 
     .dueToMove .content.remove .contentText,
-    .dueToMove .moveControls.movedOut .moveLabel,
+    .dueToMove .moveControls.movedOut .moveHeader,
     .delta.total.dueToMove .content.remove .contentText {
       background-color: var(--diff-moved-out-background);
     }
 
-    .delta.dueToMove .movedIn .moveDescription {
-      color: var(--diff-moved-in-label-color);
+    .delta.dueToMove .movedIn .moveHeader {
+      --gr-range-header-color: var(--diff-moved-in-label-color);
     }
-    .delta.dueToMove .movedOut .moveDescription {
-      color: var(--diff-moved-out-label-color);
-    }
-    .moveLabel {
-      font-family: var(--font-family, ''), 'Roboto Mono';
-      font-size: var(--font-size-small, 12px);
-      font-weight: var(--code-hint-font-weight, 500);
-      line-height: var(--line-height-small, 16px);
-      padding: var(--spacing-s) var(--spacing-m);
-      margin: var(--spacing-s);
-    }
-    .delta.dueToMove .moveDescription {
-      display: flex;
-      justify-content: flex-end;
+    .delta.dueToMove .movedOut .moveHeader {
+      --gr-range-header-color: var(--diff-moved-out-label-color);
     }
 
-    .moveDescription iron-icon {
-      color: inherit;
-      margin-right: var(--spacing-s);
-      height: var(--line-height-small, 16px);
-      width: var(--line-height-small, 16px);
-    }
-
-    .moveDescription a {
+    .moveHeader a {
       color: inherit;
     }
 
@@ -325,125 +330,14 @@
     .dividerCell {
       vertical-align: top;
     }
-    .dividerRow.showBoth .dividerCell {
+    .dividerRow.show-both .dividerCell {
       height: var(--divider-height);
     }
-    .dividerRow.showAboveOnly .dividerCell,
-    .dividerRow.showBelowOnly .dividerCell {
+    .dividerRow.show-above .dividerCell,
+    .dividerRow.show-above .dividerCell {
       height: 0;
     }
 
-    .verticalFlex {
-      display: flex;
-      flex-direction: column;
-      position: relative;
-    }
-    .dividerRow.showBoth .verticalFlex {
-      justify-content: center;
-      margin-top: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(
-        2 * var(--line-height-normal) + 2 * var(--spacing-s) +
-          var(--divider-height) - 1px
-      );
-    }
-    .dividerRow.showAboveOnly .verticalFlex {
-      justify-content: flex-end;
-      /* margin-top has to make room for height+1px. */
-      margin-top: calc(-1px - var(--line-height-normal) - var(--spacing-s));
-      height: calc(var(--line-height-normal) + var(--spacing-s));
-    }
-    .dividerRow.showBelowOnly .verticalFlex {
-      justify-content: flex-start;
-      /* This just pushes the container down 1 pixel as to render below the
-         1px border-top of the padding row below. The same could be achieved
-         by position:relative; top:1px.*/
-      margin-top: 1px;
-      margin-bottom: calc(0px - var(--line-height-normal) - var(--spacing-s));
-    }
-
-    .horizontalFlex {
-      display: flex;
-      justify-content: center;
-    }
-    .dividerRow.showBoth .horizontalFlex {
-      align-items: center;
-    }
-    .dividerRow.showAboveOnly .horizontalFlex {
-      align-items: end;
-    }
-    .dividerRow.showBelowOnly .horizontalFlex {
-      align-items: start;
-    }
-    .contextControlButton {
-      background-color: var(--default-button-background-color);
-      font: var(--context-control-button-font, inherit);
-    }
-    .centeredButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-    }
-    .aboveBelowButtons {
-      display: flex;
-      flex-direction: column;
-      justify-content: center;
-      margin-left: var(--spacing-m);
-    }
-    .aboveBelowButtons:first-child {
-      margin-left: 0;
-    }
-    .dividerRow.showBoth .aboveButton {
-      /* The size of the gap between the above and below button. */
-      margin-bottom: calc(var(--divider-height) + 1px);
-    }
-    .aboveButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 1px;
-        border-right-width: 1px;
-        border-bottom-width: 0;
-        border-left-width: 1px;
-        border-top-left-radius: var(--border-radius);
-        border-top-right-radius: var(--border-radius);
-        border-bottom-right-radius: 0;
-        border-bottom-left-radius: 0;
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    .belowButton {
-      --gr-button: {
-        color: var(--diff-context-control-color);
-        border-style: solid;
-        border-color: var(--border-color);
-        border-top-width: 0;
-        border-right-width: 1px;
-        border-bottom-width: 1px;
-        border-left-width: 1px;
-        border-top-left-radius: 0;
-        border-top-right-radius: 0;
-        border-bottom-right-radius: var(--border-radius);
-        border-bottom-left-radius: var(--border-radius);
-        padding: var(--spacing-xxs) var(--spacing-l);
-      }
-    }
-    #diffTable:focus {
-      outline: none;
-    }
-
     .displayLine .diff-row.target-row td {
       box-shadow: inset 0 -1px var(--border-color);
     }
@@ -493,6 +387,9 @@
       color: var(--link-color);
       padding: var(--spacing-m) 0 var(--spacing-m) 48px;
     }
+    #diffTable:focus {
+      outline: none;
+    }
     #loadingError,
     #sizeWarning {
       display: none;
@@ -551,15 +448,22 @@
       color: var(--link-color);
       text-decoration: none;
     }
-    .full-width td.blame {
+    .responsive td.blame {
       overflow: hidden;
       width: 200px;
     }
     /** Support the line length indicator **/
-    .full-width td.content .contentText {
-      background-image: var(--line-length-indicator);
-      background-position: var(--line-limit) 0;
-      background-repeat: repeat-y;
+    .responsive td.content .contentText {
+      /*
+      Same strategy as in https://stackoverflow.com/questions/1179928/how-can-i-put-a-vertical-line-down-the-center-of-a-div
+      */
+      background-image: linear-gradient(
+        var(--line-length-indicator-color),
+        var(--line-length-indicator-color)
+      );
+      background-size: 1px 100%;
+      background-position: var(--line-limit-marker) 0;
+      background-repeat: no-repeat;
     }
     .newlineWarning {
       color: var(--deemphasized-text-color);
@@ -647,6 +551,10 @@
       border: 1px solid var(--diff-context-control-border-color);
       text-align: center;
     }
+
+    .token-highlight {
+      background-color: var(--token-highlighting-color, #fffd54);
+    }
   </style>
   <style include="gr-syntax-theme">
     /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
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 86946a6..9ee779c 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
@@ -23,7 +23,7 @@
 import {_setHiddenScroll} from '../../../scripts/hiddenscroll.js';
 import {runA11yAudit} from '../../../test/a11y-test-utils.js';
 import '@polymer/paper-button/paper-button.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-diff');
 
@@ -36,7 +36,7 @@
 suite('gr-diff tests', () => {
   let element;
 
-  const MINIMAL_PREFS = {tab_size: 2, line_length: 80};
+  const MINIMAL_PREFS = {tab_size: 2, line_length: 80, font_size: 12};
 
   setup(() => {
 
@@ -78,14 +78,68 @@
     element = basicFixture.instantiate();
     element.prefs = {...MINIMAL_PREFS, line_wrapping: true};
     flush();
-    assert.equal(getComputedStyleValue('--line-limit', element), '80ch');
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '80ch');
   });
 
   test('line limit without line_wrapping', () => {
     element = basicFixture.instantiate();
     element.prefs = {...MINIMAL_PREFS, line_wrapping: false};
     flush();
-    assert.isNotOk(getComputedStyleValue('--line-limit', element));
+    assert.equal(getComputedStyleValue('--line-limit-marker', element), '-1px');
+  });
+  suite('FULL_RESPONSIVE mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'FULL_RESPONSIVE'};
+    });
+
+    test('line limit is based on line_length', () => {
+      element.prefs = {...element.prefs, line_length: 100};
+      flush();
+      assert.equal(getComputedStyleValue('--line-limit-marker', element),
+          '100ch');
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+  });
+
+  suite('SHRINK_ONLY mode', () => {
+    setup(() => {
+      element = basicFixture.instantiate();
+      element.prefs = {...MINIMAL_PREFS};
+      element.renderPrefs = {responsive_mode: 'SHRINK_ONLY'};
+    });
+
+    test('content-width should not be defined', () => {
+      flush();
+      assert.equal(getComputedStyleValue('--content-width', element), 'none');
+    });
+
+    test('max-width considers two content columns in side-by-side', () => {
+      element.viewMode = 'SIDE_BY_SIDE';
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 48px + 1px + 2px)');
+    });
+
+    test('max-width considers one content column in unified', () => {
+      element.viewMode = 'UNIFIED_DIFF';
+      flush();
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(1 * 80ch + 2 * 48px + 1px + 2px)');
+    });
+
+    test('max-width considers font-size', () => {
+      element.prefs = {...element.prefs, font_size: 13};
+      flush();
+      // Each line number column: 4 * 13 = 52px
+      assert.equal(getComputedStyleValue('--diff-max-width', element),
+          'calc(2 * 80ch + 2 * 52px + 1px + 2px)');
+    });
   });
 
   suite('not logged in', () => {
@@ -178,7 +232,9 @@
         };
       });
 
-      test('renders image diffs with same file name', done => {
+      test('renders image diffs with same file name', async () => {
+        const leftRendered = mockPromise();
+        const rightRendered = mockPromise();
         const rendered = () => {
           // Recognizes that it should be an image diff.
           assert.isTrue(element.isImageDiff);
@@ -202,19 +258,12 @@
           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\u00d71 image/bmp');// \u00d7 - '×'
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
+            leftRendered.resolve();
           });
 
           rightImage.addEventListener('load', () => {
@@ -223,11 +272,7 @@
                 'data:image/bmp;base64,' + mockFile2.body);
             assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
 
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
+            rightRendered.resolve();
           });
         };
 
@@ -251,9 +296,11 @@
           content: [{skip: 66}],
           binary: true,
         };
+        await Promise.all([leftRendered, rightRendered]);
+        element.removeEventListener('render', rendered);
       });
 
-      test('renders image diffs with a different file name', done => {
+      test('renders image diffs with a different file name', async () => {
         const mockDiff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
           meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
@@ -270,7 +317,8 @@
           content: [{skip: 66}],
           binary: true,
         };
-
+        const leftRendered = mockPromise();
+        const rightRendered = mockPromise();
         const rendered = () => {
           // Recognizes that it should be an image diff.
           assert.isTrue(element.isImageDiff);
@@ -296,19 +344,12 @@
           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\u00d71 image/bmp');// \u00d7 - '×'
-            leftLoaded = true;
-            if (rightLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
+            leftRendered.resolve();
           });
 
           rightImage.addEventListener('load', () => {
@@ -317,11 +358,7 @@
                 'data:image/bmp;base64,' + mockFile2.body);
             assert.equal(rightLabelContent.textContent, '1\u00d71 image/bmp');// \u00d7 - '×'
 
-            rightLoaded = true;
-            if (leftLoaded) {
-              element.removeEventListener('render', rendered);
-              done();
-            }
+            rightRendered.resolve();
           });
         };
 
@@ -332,9 +369,11 @@
         element.revisionImage = mockFile2;
         element.revisionImage._name = mockDiff.meta_b.name;
         element.diff = mockDiff;
+        await Promise.all([leftRendered, rightRendered]);
+        element.removeEventListener('render', rendered);
       });
 
-      test('renders added image', done => {
+      test('renders added image', async () => {
         const mockDiff = {
           meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
             lines: 560},
@@ -351,27 +390,27 @@
           binary: true,
         };
 
-        function rendered() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const rightImage = element.$.diffTable.querySelector('td.right img');
-
-          assert.isNotOk(leftImage);
-          assert.isOk(rightImage);
-          done();
-          element.removeEventListener('render', rendered);
-        }
+        const promise = mockPromise();
+        function rendered() { promise.resolve(); }
         element.addEventListener('render', rendered);
 
         element.revisionImage = mockFile2;
         element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        const leftImage = element.$.diffTable.querySelector('td.left img');
+        const rightImage = element.$.diffTable.querySelector('td.right img');
+
+        assert.isNotOk(leftImage);
+        assert.isOk(rightImage);
       });
 
-      test('renders removed image', done => {
+      test('renders removed image', async () => {
         const mockDiff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
             lines: 560},
@@ -387,28 +426,27 @@
           content: [{skip: 66}],
           binary: true,
         };
-
-        function rendered() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          const rightImage = element.$.diffTable.querySelector('td.right img');
-
-          assert.isOk(leftImage);
-          assert.isNotOk(rightImage);
-          done();
-          element.removeEventListener('render', rendered);
-        }
+        const promise = mockPromise();
+        function rendered() { promise.resolve(); }
         element.addEventListener('render', rendered);
 
         element.baseImage = mockFile1;
         element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+        const leftImage = element.$.diffTable.querySelector('td.left img');
+        const rightImage = element.$.diffTable.querySelector('td.right img');
+
+        assert.isOk(leftImage);
+        assert.isNotOk(rightImage);
       });
 
-      test('does not render disallowed image type', done => {
+      test('does not render disallowed image type', async () => {
         const mockDiff = {
           meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
             lines: 560},
@@ -426,65 +464,73 @@
         };
         mockFile1.type = 'image/jpeg-evil';
 
-        function rendered() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(
-              element.$.diffBuilder._builder, GrDiffBuilderImage);
-          const leftImage = element.$.diffTable.querySelector('td.left img');
-          assert.isNotOk(leftImage);
-          done();
-          element.removeEventListener('render', rendered);
-        }
+        const promise = mockPromise();
+        function rendered() { promise.resolve(); }
         element.addEventListener('render', rendered);
 
         element.baseImage = mockFile1;
         element.diff = mockDiff;
+        await promise;
+        element.removeEventListener('render', rendered);
+        // Recognizes that it should be an image diff.
+        assert.isTrue(element.isImageDiff);
+        assert.instanceOf(
+            element.$.diffBuilder._builder, GrDiffBuilderImage);
+        const leftImage = element.$.diffTable.querySelector('td.left img');
+        assert.isNotOk(leftImage);
       });
     });
 
-    test('_handleTap lineNum', done => {
+    test('_handleTap lineNum', async () => {
       const addDraftStub = sinon.stub(element, 'addDraftAtLine');
       const el = document.createElement('div');
       el.className = 'lineNum';
+      const promise = mockPromise();
       el.addEventListener('click', e => {
         element._handleTap(e);
         assert.isTrue(addDraftStub.called);
         assert.equal(addDraftStub.lastCall.args[0], el);
-        done();
+        promise.resolve();
       });
       el.click();
+      await promise;
     });
 
-    test('_handleTap context', done => {
+    test('_handleTap context', async () => {
       const showContextStub =
           sinon.stub(element.$.diffBuilder, 'showContext');
       const el = document.createElement('div');
       el.className = 'showContext';
+      const promise = mockPromise();
       el.addEventListener('click', e => {
         element._handleDiffContextExpanded(e);
         assert.isTrue(showContextStub.called);
-        done();
+        promise.resolve();
       });
       el.click();
+      await promise;
     });
 
-    test('_handleTap content', done => {
+    test('_handleTap content', async () => {
       const content = document.createElement('div');
       const lineEl = document.createElement('div');
+      lineEl.className = 'lineNum';
+      const row = document.createElement('div');
+      row.appendChild(lineEl);
+      row.appendChild(content);
 
       const selectStub = sinon.stub(element, '_selectLine');
-      sinon.stub(element.$.diffBuilder, 'getLineElByChild')
-          .callsFake(() => lineEl);
 
       content.className = 'content';
+      const promise = mockPromise();
       content.addEventListener('click', e => {
         element._handleTap(e);
         assert.isTrue(selectStub.called);
         assert.equal(selectStub.lastCall.args[0], lineEl);
-        done();
+        promise.resolve();
       });
       content.click();
+      await promise;
     });
 
     suite('getCursorStops', () => {
@@ -760,41 +806,47 @@
       element.noRenderOnPrefsChange = true;
     });
 
-    test('large render w/ context = 10', done => {
+    test('large render w/ context = 10', async () => {
       element.prefs = {...MINIMAL_PREFS, context: 10};
+      const promise = mockPromise();
       function rendered() {
         assert.isTrue(renderStub.called);
         assert.isFalse(element._showWarning);
-        done();
+        promise.resolve();
         element.removeEventListener('render', rendered);
       }
       element.addEventListener('render', rendered);
       element._renderDiffTable();
+      await promise;
     });
 
-    test('large render w/ whole file and bypass', done => {
+    test('large render w/ whole file and bypass', async () => {
       element.prefs = {...MINIMAL_PREFS, context: -1};
       element._safetyBypass = 10;
+      const promise = mockPromise();
       function rendered() {
         assert.isTrue(renderStub.called);
         assert.isFalse(element._showWarning);
-        done();
+        promise.resolve();
         element.removeEventListener('render', rendered);
       }
       element.addEventListener('render', rendered);
       element._renderDiffTable();
+      await promise;
     });
 
-    test('large render w/ whole file and no bypass', done => {
+    test('large render w/ whole file and no bypass', async () => {
       element.prefs = {...MINIMAL_PREFS, context: -1};
+      const promise = mockPromise();
       function rendered() {
         assert.isFalse(renderStub.called);
         assert.isTrue(element._showWarning);
-        done();
+        promise.resolve();
         element.removeEventListener('render', rendered);
       }
       element.addEventListener('render', rendered);
       element._renderDiffTable();
+      await promise;
     });
 
     test('toggles expand context using bypass', async () => {
@@ -1163,16 +1215,18 @@
     assert.equal(element.getDiffLength(diff), 52);
   });
 
-  test('`render` event has contentRendered field in detail', done => {
+  test('`render` event has contentRendered field in detail', async () => {
     element = basicFixture.instantiate();
     element.prefs = {};
     sinon.stub(element.$.diffBuilder, 'render')
         .returns(Promise.resolve());
+    const promise = mockPromise();
     element.addEventListener('render', event => {
       assert.isTrue(event.detail.contentRendered);
-      done();
+      promise.resolve();
     });
     element._renderDiffTable();
+    await promise;
   });
 
   test('_prefsEqual', () => {
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 9bd2904..857ffa2 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
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-dropdown-list/gr-dropdown-list';
 import '../../shared/gr-select/gr-select';
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 5ab8449..26944a4 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       align-items: center;
@@ -30,11 +33,8 @@
       margin: 0 var(--spacing-m);
     }
     gr-dropdown-list {
-      --trigger-style: {
-        color: var(--deemphasized-text-color);
-        text-transform: none;
-        font-family: var(--font-family);
-      }
+      --trigger-style-text-color: var(--deemphasized-text-color);
+      --trigger-style-font-family: var(--font-family);
     }
     @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 a7719fe..28ebbac 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
@@ -63,7 +63,7 @@
 
     // Stub methods on the changeComments object after changeComments has
     // been initialized.
-    return commentApiWrapper.loadComments();
+    element.changeComments = new ChangeComments();
   });
 
   test('enabled/disabled options', () => {
@@ -196,7 +196,7 @@
   });
 
   test('_computeBaseDropdownContent called when changeComments update',
-      done => {
+      async () => {
         element.revisions = [
           {commit: {parents: []}},
           {commit: {parents: []}},
@@ -212,16 +212,14 @@
         ];
         element.patchNum = 2;
         element.basePatchNum = 'PARENT';
-        flush();
+        await flush();
 
         // Should be recomputed for each available patch
         sinon.stub(element, '_computeBaseDropdownContent');
         assert.equal(element._computeBaseDropdownContent.callCount, 0);
-        commentApiWrapper.loadComments().then()
-            .then(() => {
-              assert.equal(element._computeBaseDropdownContent.callCount, 1);
-              done();
-            });
+        element.changeComments = new ChangeComments();
+        await flush();
+        assert.equal(element._computeBaseDropdownContent.callCount, 1);
       });
 
   test('_computePatchDropdownContent called when basePatchNum updates', () => {
@@ -248,33 +246,6 @@
     assert.equal(element._computePatchDropdownContent.callCount, 1);
   });
 
-  test('_computePatchDropdownContent called when comments update', done => {
-    element.revisions = [
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-      {commit: {parents: []}},
-    ];
-    element.revisionInfo = getInfo(element.revisions);
-    element.availablePatches = [
-      {num: 1, sha: '1'},
-      {num: 2, sha: '2'},
-      {num: 3, sha: '3'},
-      {num: 'edit', sha: '4'},
-    ];
-    element.patchNum = 2;
-    element.basePatchNum = 'PARENT';
-    flush();
-
-    // Should be recomputed for each available patch
-    sinon.stub(element, '_computePatchDropdownContent');
-    assert.equal(element._computePatchDropdownContent.callCount, 0);
-    commentApiWrapper.loadComments().then()
-        .then(() => {
-          done();
-        });
-  });
-
   test('_computePatchDropdownContent', () => {
     const availablePatches = [
       {num: 'edit', sha: '1'},
diff --git a/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
new file mode 100644
index 0000000..dcf7236
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-range-header/gr-range-header.ts
@@ -0,0 +1,68 @@
+/**
+ * @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 {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import '@polymer/iron-icon/iron-icon';
+
+/**
+ * Represents a header (label) for a code chunk whenever showing
+ * diffs.
+ * Used as a labeled header to describe selections in code for cases
+ * like long comments and moved in/out chunks.
+ */
+@customElement('gr-range-header')
+export class GrRangeHeader extends LitElement {
+  @property({type: String})
+  icon?: string;
+
+  static override get styles() {
+    return [
+      css`
+        .row {
+          color: var(--gr-range-header-color);
+          display: flex;
+          font-family: var(--font-family, ''), 'Roboto Mono';
+          font-size: var(--font-size-small, 12px);
+          font-weight: var(--code-hint-font-weight, 500);
+          line-height: var(--line-height-small, 16px);
+          justify-content: flex-end;
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .icon {
+          color: var(--gr-range-header-color);
+          height: var(--line-height-small, 16px);
+          width: var(--line-height-small, 16px);
+          margin-right: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const icon = this.icon ?? '';
+    return html` <div class="row">
+      <iron-icon class="icon" .icon=${icon}></iron-icon>
+      <slot></slot>
+    </div>`;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-range-header': GrRangeHeader;
+  }
+}
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
index 3d35c26..3f2258d 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint.ts
@@ -15,20 +15,52 @@
  * limitations under the License.
  */
 
-import {customElement, property} from '@polymer/decorators';
+import '../gr-range-header/gr-range-header';
 import {CommentRange} from '../../../types/common';
-import {htmlTemplate} from './gr-ranged-comment-hint_html';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {grRangedCommentTheme} from '../gr-ranged-comment-themes/gr-ranged-comment-theme';
 
 @customElement('gr-ranged-comment-hint')
-export class GrRangedCommentHint extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrRangedCommentHint extends LitElement {
   @property({type: Object})
   range?: CommentRange;
 
+  static override get styles() {
+    return [
+      grRangedCommentTheme,
+      sharedStyles,
+      css`
+        .row {
+          display: flex;
+        }
+        gr-range-header {
+          flex-grow: 1;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
+    const customStyle = html`
+      <style>
+        .row {
+          --gr-range-header-color: var(--ranged-comment-hint-text-color);
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div class="rangeHighlight row">
+        <gr-range-header icon="gr-icons:comment"
+          >${this._computeRangeLabel(this.range)}</gr-range-header
+        >
+      </div>`;
+  }
+
   _computeRangeLabel(range?: CommentRange): string {
     if (!range) return '';
     return `Long comment range ${range.start_line} - ${range.end_line}`;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
deleted file mode 100644
index 670bfd2..0000000
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_html.ts
+++ /dev/null
@@ -1,46 +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="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-hint-text-color);
-      display: flex;
-      font-family: var(--font-family, ''), 'Roboto Mono';
-      font-size: var(--font-size-small, 12px);
-      font-weight: var(--code-hint-font-weight, 500);
-      line-height: var(--line-height-small, 16px);
-      justify-content: flex-end;
-      margin: var(--spacing-xs) 0;
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .icon {
-      color: var(--ranged-comment-hint-text-color);
-      height: var(--line-height-small, 16px);
-      width: var(--line-height-small, 16px);
-      margin-right: var(--spacing-s);
-    }
-  </style>
-  <div class="row rangeHighlight">
-    <iron-icon class="icon" icon="gr-icons:comment"></iron-icon>
-    [[_computeRangeLabel(range)]]
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
index 932c367..5782e4a 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-hint/gr-ranged-comment-hint_test.ts
@@ -16,14 +16,20 @@
  */
 
 import '../../../test/common-test-setup-karma';
+import './gr-ranged-comment-hint';
 import {CommentRange} from '../../../types/common';
 import {GrRangedCommentHint} from './gr-ranged-comment-hint';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrRangeHeader} from '../gr-range-header/gr-range-header';
+
+const basicFixture = fixtureFromElement('gr-ranged-comment-hint');
 
 suite('gr-ranged-comment-hint tests', () => {
   let element: GrRangedCommentHint;
 
-  setup(() => {
-    element = fixtureFromElement('gr-ranged-comment-hint').instantiate();
+  setup(async () => {
+    element = basicFixture.instantiate();
+    await flush();
   });
 
   test('shows line range', async () => {
@@ -34,7 +40,7 @@
       end_character: 3,
     } as CommentRange;
     await flush();
-    const textDiv = element.root!.querySelector<HTMLDivElement>('.row');
-    assert.equal(textDiv!.innerText.trim(), 'Long comment range 2 - 5');
+    const textDiv = queryAndAssert<GrRangeHeader>(element, 'gr-range-header');
+    assert.equal(textDiv?.innerText.trim(), 'Long comment range 2 - 5');
   });
 });
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 ee9e8f9..fb20a91 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
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {css} from 'lit';
+
 // 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,25 +24,21 @@
 
 const $_documentContainer = document.createElement('template');
 
+export const grRangedCommentTheme = css`
+  .rangeHighlight {
+    background-color: var(--diff-highlight-range-color);
+  }
+  .rangeHoverHighlight {
+    background-color: var(--diff-highlight-range-hover-color);
+  }
+`;
+
 $_documentContainer.innerHTML = `<dom-module id="gr-ranged-comment-theme">
   <template>
     <style>
-      .rangeHighlight {
-        background-color: var(--diff-highlight-range-color);
-        display: inline;
-      }
-      .rangeHoverHighlight {
-        background-color: var(--diff-highlight-range-hover-color);
-        display: inline;
-      }
+    ${grRangedCommentTheme.cssText}
     </style>
   </template>
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
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 3b026b3..551889f 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
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '../../../styles/shared-styles';
+import '../../shared/gr-tooltip/gr-tooltip';
 import {GrTooltip} from '../../shared/gr-tooltip/gr-tooltip';
 import {customElement, property} from '@polymer/decorators';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
@@ -46,9 +47,6 @@
    * @event create-comment-requested
    */
 
-  @property({type: Object})
-  keyEventTarget = document.body;
-
   @property({type: Boolean})
   positionBelow = false;
 
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 9229149..ad671da 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
@@ -20,6 +20,7 @@
 import {DiffFileMetaInfo, DiffInfo} from '../../../types/diff';
 import {DiffLayer, DiffLayerListener, HighlightJS} from '../../../types/types';
 import {GrLibLoader} from '../../shared/gr-lib-loader/gr-lib-loader';
+import {HLJS_LIBRARY_CONFIG} from '../../shared/gr-lib-loader/highlightjs_config';
 import {Side} from '../../../constants/constants';
 
 const LANGUAGE_MAP = new Map<string, string>([
@@ -577,8 +578,8 @@
   }
 
   _loadHLJS() {
-    return this.libLoader.getHLJS().then(hljs => {
-      this.hljs = hljs;
+    return this.libLoader.getLibrary(HLJS_LIBRARY_CONFIG).then(hljs => {
+      this.hljs = hljs as HighlightJS;
     });
   }
 }
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 f9100a2..c907a80 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
@@ -52,6 +52,12 @@
     element.diff = diff;
   });
 
+  teardown(() => {
+    if (window.hljs) {
+      delete window.hljs;
+    }
+  });
+
   test('annotate without range does nothing', () => {
     const annotationSpy = sinon.spy(GrAnnotation, 'annotateElement');
     const el = document.createElement('div');
@@ -114,7 +120,7 @@
     assert.isFalse(annotationSpy.called);
   });
 
-  test('process on empty diff does nothing', done => {
+  test('process on empty diff does nothing', async () => {
     element.diff = {
       meta_a: {content_type: 'application/json'},
       meta_b: {content_type: 'application/json'},
@@ -122,17 +128,14 @@
     };
     const processNextSpy = sinon.spy(element, '_processNextLine');
 
-    const processPromise = element.process();
+    await element.process();
 
-    processPromise.then(() => {
-      assert.isFalse(processNextSpy.called);
-      assert.equal(element.baseRanges.length, 0);
-      assert.equal(element.revisionRanges.length, 0);
-      done();
-    });
+    assert.isFalse(processNextSpy.called);
+    assert.equal(element.baseRanges.length, 0);
+    assert.equal(element.revisionRanges.length, 0);
   });
 
-  test('process for unsupported languages does nothing', done => {
+  test('process for unsupported languages does nothing', async () => {
     element.diff = {
       meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
       meta_b: {content_type: 'application/not-a-real-language'},
@@ -140,100 +143,89 @@
     };
     const processNextSpy = sinon.spy(element, '_processNextLine');
 
-    const processPromise = element.process();
+    await element.process();
 
-    processPromise.then(() => {
-      assert.isFalse(processNextSpy.called);
-      assert.equal(element.baseRanges.length, 0);
-      assert.equal(element.revisionRanges.length, 0);
-      done();
-    });
+    assert.isFalse(processNextSpy.called);
+    assert.equal(element.baseRanges.length, 0);
+    assert.equal(element.revisionRanges.length, 0);
   });
 
-  test('process while disabled does nothing', done => {
+  test('process while disabled does nothing', async () => {
     const processNextSpy = sinon.spy(element, '_processNextLine');
     element.enabled = false;
     const loadHLJSSpy = sinon.spy(element, '_loadHLJS');
 
-    const processPromise = element.process();
+    await element.process();
 
-    processPromise.then(() => {
-      assert.isFalse(processNextSpy.called);
-      assert.equal(element.baseRanges.length, 0);
-      assert.equal(element.revisionRanges.length, 0);
-      assert.isFalse(loadHLJSSpy.called);
-      done();
-    });
+    assert.isFalse(processNextSpy.called);
+    assert.equal(element.baseRanges.length, 0);
+    assert.equal(element.revisionRanges.length, 0);
+    assert.isFalse(loadHLJSSpy.called);
   });
 
-  test('process highlight ipsum', done => {
+  test('process highlight ipsum', async () => {
     element.diff.meta_a.content_type = 'application/json';
     element.diff.meta_b.content_type = 'application/json';
 
     const mockHLJS = getMockHLJS();
+    window.hljs = mockHLJS;
     const highlightSpy = sinon.spy(mockHLJS, 'highlight');
-    sinon.stub(element.libLoader, 'getHLJS').callsFake(
-        () => Promise.resolve(mockHLJS));
     const processNextSpy = sinon.spy(element, '_processNextLine');
-    const processPromise = element.process();
+    await element.process();
 
-    processPromise.then(() => {
-      const linesA = diff.meta_a.lines;
-      const linesB = diff.meta_b.lines;
+    const linesA = diff.meta_a.lines;
+    const linesB = diff.meta_b.lines;
 
-      assert.isTrue(processNextSpy.called);
-      assert.equal(element.baseRanges.length, linesA);
-      assert.equal(element.revisionRanges.length, linesB);
+    assert.isTrue(processNextSpy.called);
+    assert.equal(element.baseRanges.length, linesA);
+    assert.equal(element.revisionRanges.length, linesB);
 
-      assert.equal(highlightSpy.callCount, linesA + linesB);
+    assert.equal(highlightSpy.callCount, linesA + linesB);
 
-      // The first line of both sides have a range.
-      let ranges = [element.baseRanges[0], element.revisionRanges[0]];
-      for (const range of ranges) {
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className,
-            'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 'lorem '.length);
-        assert.equal(range[0].length, 'ipsum'.length);
-      }
-
-      // There are no ranges from ll.1-12 on the left and ll.1-11 on the
-      // right.
-      ranges = element.baseRanges.slice(1, 12)
-          .concat(element.revisionRanges.slice(1, 11));
-
-      for (const range of ranges) {
-        assert.equal(range.length, 0);
-      }
-
-      // There should be another pair of ranges on l.13 for the left and
-      // l.12 for the right.
-      ranges = [element.baseRanges[13], element.revisionRanges[12]];
-
-      for (const range of ranges) {
-        assert.equal(range.length, 1);
-        assert.equal(range[0].className,
-            'gr-diff gr-syntax gr-syntax-string');
-        assert.equal(range[0].start, 32);
-        assert.equal(range[0].length, 'ipsum'.length);
-      }
-
-      // The next group should have a similar instance on either side.
-
-      let range = element.baseRanges[15];
+    // The first line of both sides have a range.
+    let ranges = [element.baseRanges[0], element.revisionRanges[0]];
+    for (const range of ranges) {
       assert.equal(range.length, 1);
-      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 34);
+      assert.equal(range[0].className,
+          'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 'lorem '.length);
       assert.equal(range[0].length, 'ipsum'.length);
+    }
 
-      range = element.revisionRanges[14];
+    // There are no ranges from ll.1-12 on the left and ll.1-11 on the
+    // right.
+    ranges = element.baseRanges.slice(1, 12)
+        .concat(element.revisionRanges.slice(1, 11));
+
+    for (const range of ranges) {
+      assert.equal(range.length, 0);
+    }
+
+    // There should be another pair of ranges on l.13 for the left and
+    // l.12 for the right.
+    ranges = [element.baseRanges[13], element.revisionRanges[12]];
+
+    for (const range of ranges) {
       assert.equal(range.length, 1);
-      assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
-      assert.equal(range[0].start, 35);
+      assert.equal(range[0].className,
+          'gr-diff gr-syntax gr-syntax-string');
+      assert.equal(range[0].start, 32);
       assert.equal(range[0].length, 'ipsum'.length);
+    }
 
-      done();
-    });
+    // The next group should have a similar instance on either side.
+
+    let range = element.baseRanges[15];
+    assert.equal(range.length, 1);
+    assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+    assert.equal(range[0].start, 34);
+    assert.equal(range[0].length, 'ipsum'.length);
+
+    range = element.revisionRanges[14];
+    assert.equal(range.length, 1);
+    assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
+    assert.equal(range[0].start, 35);
+    assert.equal(range[0].length, 'ipsum'.length);
   });
 
   test('init calls cancel', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
index 94e5f87..7f495fa 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-syntax-theme.ts
@@ -121,9 +121,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
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 7e4edd6..3adb0f3 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
@@ -19,18 +19,15 @@
 import '../../shared/gr-list-view/gr-list-view';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-documentation-search_html';
-import {
-  ListViewMixin,
-  ListViewParams,
-} from '../../../mixins/gr-list-view-mixin/gr-list-view-mixin';
 import {getBaseUrl} from '../../../utils/url-util';
 import {customElement, property} from '@polymer/decorators';
 import {DocResult} from '../../../types/common';
 import {fireTitleChange} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
+import {ListViewParams} from '../../gr-app-types';
 
 @customElement('gr-documentation-search')
-export class GrDocumentationSearch extends ListViewMixin(PolymerElement) {
+export class GrDocumentationSearch extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -48,19 +45,18 @@
   _loading = true;
 
   @property({type: String})
-  _filter = '';
+  _filter?: string;
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     fireTitleChange(this, 'Documentation Search');
   }
 
   _paramsChanged(params: ListViewParams) {
     this._loading = true;
-    this._filter = this.getFilterValue(params);
+    this._filter = params?.filter ?? '';
 
     return this._getDocumentationSearches(this._filter);
   }
@@ -85,6 +81,10 @@
     }
     return `${getBaseUrl()}/${url}`;
   }
+
+  computeLoadingClass(loading: boolean) {
+    return loading ? 'loading' : '';
+  }
 }
 
 declare global {
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 dd1faeb..95ce1ec 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
@@ -25,7 +25,6 @@
   </style>
   <gr-list-view
     filter="[[_filter]]"
-    items="false"
     offset="0"
     loading="[[_loading]]"
     path="/Documentation"
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.ts
similarity index 64%
rename from polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.js
rename to polygerrit-ui/app/elements/documentation/gr-documentation-search/gr-documentation-search_test.ts
index f5e47ca..bf6a0d5 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.ts
@@ -15,15 +15,18 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-documentation-search.js';
-import {page} from '../../../utils/page-wrapper-utils.js';
-import 'lodash/lodash.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-documentation-search';
+import {GrDocumentationSearch} from './gr-documentation-search';
+import {page} from '../../../utils/page-wrapper-utils';
+import 'lodash/lodash';
+import {stubRestApi} from '../../../test/test-utils';
+import {DocResult} from '../../../types/common';
+import {ListViewParams} from '../../gr-app-types';
 
 const basicFixture = fixtureFromElement('gr-documentation-search');
 
-let counter;
+let counter: number;
 const documentationGenerator = () => {
   return {
     title: `Gerrit Code Review - REST API Developers Notes${++counter}`,
@@ -32,10 +35,10 @@
 };
 
 suite('gr-documentation-search tests', () => {
-  let element;
-  let documentationSearches;
+  let element: GrDocumentationSearch;
+  let documentationSearches: DocResult[];
 
-  let value;
+  let value: ListViewParams;
 
   setup(() => {
     sinon.stub(page, 'show');
@@ -44,33 +47,36 @@
   });
 
   suite('list with searches for documentation', () => {
-    setup(done => {
+    setup(async () => {
       documentationSearches = _.times(26, documentationGenerator);
       stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
-      element._paramsChanged(value).then(() => { flush(done); });
+        Promise.resolve(documentationSearches)
+      );
+      await element._paramsChanged(value);
+      await flush();
     });
 
-    test('test for test repo in the list', done => {
-      flush(() => {
-        assert.equal(element._documentationSearches[0].title,
-            'Gerrit Code Review - REST API Developers Notes1');
-        assert.equal(element._documentationSearches[0].url,
-            'Documentation/dev-rest-api.html');
-        done();
-      });
+    test('test for test repo in the list', async () => {
+      assert.equal(
+        element._documentationSearches![0].title,
+        'Gerrit Code Review - REST API Developers Notes1'
+      );
+      assert.equal(
+        element._documentationSearches![0].url,
+        'Documentation/dev-rest-api.html'
+      );
     });
   });
 
   suite('filter', () => {
     setup(() => {
       documentationSearches = _.times(25, documentationGenerator);
-      _.times(1, documentationSearches);
     });
 
     test('_paramsChanged', async () => {
       const stub = stubRestApi('getDocumentationSearches').returns(
-          Promise.resolve(documentationSearches));
+        Promise.resolve(documentationSearches)
+      );
       const value = {filter: 'test'};
       await element._paramsChanged(value);
       assert.isTrue(stub.lastCall.calledWithExactly('test'));
@@ -78,18 +84,16 @@
   });
 
   suite('loading', () => {
-    test('correct contents are displayed', () => {
+    test('correct contents are displayed', async () => {
       assert.isTrue(element._loading);
       assert.equal(element.computeLoadingClass(element._loading), 'loading');
       assert.equal(getComputedStyle(element.$.loading).display, 'block');
 
       element._loading = false;
-      element._repos = _.times(25, documentationGenerator);
 
-      flush();
+      await flush();
       assert.equal(element.computeLoadingClass(element._loading), '');
       assert.equal(getComputedStyle(element.$.loading).display, 'none');
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
index 2828918..5312be2 100644
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor.ts
@@ -14,14 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-default-editor_html';
-import {customElement, property} from '@polymer/decorators';
-
-export interface GrDefaultEditor {
-  $: {};
-}
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -30,11 +25,7 @@
 }
 
 @customElement('gr-default-editor')
-export class GrDefaultEditor extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDefaultEditor extends LitElement {
   /**
    * Fired when the content of the editor changes.
    *
@@ -42,7 +33,38 @@
    */
 
   @property({type: String})
-  fileContent: string | null = null;
+  fileContent = '';
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        textarea {
+          border: none;
+          box-sizing: border-box;
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          /* usually 16px = 12px + 4px */
+          line-height: calc(var(--font-size-code) + var(--spacing-s));
+          min-height: 60vh;
+          resize: none;
+          white-space: pre;
+          width: 100%;
+        }
+        textarea:focus {
+          outline: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <textarea
+      id="textarea"
+      .value="${this.fileContent}"
+      @input=${this._handleTextareaInput}
+    ></textarea>`;
+  }
 
   _handleTextareaInput(e: Event) {
     this.dispatchEvent(
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
deleted file mode 100644
index 77b4bc6..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_html.ts
+++ /dev/null
@@ -1,42 +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">
-    textarea {
-      border: none;
-      box-sizing: border-box;
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-code);
-      /* usually 16px = 12px + 4px */
-      line-height: calc(var(--font-size-code) + var(--spacing-s));
-      min-height: 60vh;
-      resize: none;
-      white-space: pre;
-      width: 100%;
-    }
-    textarea:focus {
-      outline: none;
-    }
-  </style>
-  <textarea
-    id="textarea"
-    value="[[fileContent]]"
-    on-input="_handleTextareaInput"
-  ></textarea>
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js
deleted file mode 100644
index d40e83d..0000000
--- a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.js
+++ /dev/null
@@ -1,43 +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 './gr-default-editor.js';
-
-const basicFixture = fixtureFromElement('gr-default-editor');
-
-suite('gr-default-editor tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.fileContent = '';
-  });
-
-  test('fires content-change event', done => {
-    const contentChangedHandler = e => {
-      assert.equal(e.detail.value, 'test');
-      done();
-    };
-    const textarea = element.$.textarea;
-    element.addEventListener('content-change', contentChangedHandler);
-    textarea.value = 'test';
-    textarea.dispatchEvent(new CustomEvent('input',
-        {target: textarea, bubbles: true, composed: true}));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
new file mode 100644
index 0000000..6b7ce34
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-default-editor/gr-default-editor_test.ts
@@ -0,0 +1,45 @@
+/**
+ * @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';
+import './gr-default-editor';
+import {GrDefaultEditor} from './gr-default-editor';
+import {mockPromise, queryAndAssert} from '../../../test/test-utils';
+
+const basicFixture = fixtureFromElement('gr-default-editor');
+
+suite('gr-default-editor tests', () => {
+  let element: GrDefaultEditor;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    element.fileContent = '';
+    await flush();
+  });
+
+  test('fires content-change event', async () => {
+    const textarea = queryAndAssert<HTMLTextAreaElement>(element, '#textarea');
+    const promise = mockPromise();
+    element.addEventListener('content-change', e => {
+      assert.equal((e as CustomEvent).detail.value, 'test');
+      promise.resolve();
+    });
+    textarea.value = 'test';
+    textarea.dispatchEvent(new Event('input', {bubbles: true, composed: true}));
+    await promise;
+  });
+});
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 f8838a3..1615a23 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
@@ -35,9 +35,12 @@
   AutocompleteSuggestion,
 } from '../../shared/gr-autocomplete/gr-autocomplete';
 import {appContext} from '../../../services/app-context';
+import {IronInputElement} from '@polymer/iron-input';
+import {fireAlert, fireReload} from '../../../utils/event-util';
 
 export interface GrEditControls {
   $: {
+    newPathIronInput: IronInputElement;
     overlay: GrOverlay;
     openDialog: GrDialog;
     deleteDialog: GrDialog;
@@ -174,11 +177,15 @@
       '.dialog'
     ) as NodeListOf<GrDialog>;
     for (const dialog of dialogs) {
-      this._closeDialog(dialog);
+      // We set the second param to false, because this function
+      // is called by _showDialog which when you open either restore,
+      // delete or rename dialogs, it reseted the automatically
+      // set input.
+      this._closeDialog(dialog, false);
     }
   }
 
-  _closeDialog(dialog?: GrDialog, clearInputs = false) {
+  _closeDialog(dialog?: GrDialog, clearInputs = true) {
     if (!dialog) return;
 
     if (clearInputs) {
@@ -203,19 +210,25 @@
   }
 
   _handleOpenConfirm(e: Event) {
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(this.$.openDialog);
+      return;
+    }
     const url = GerritNav.getEditUrlForDiff(
       this.change,
       this._path,
       this.patchNum
     );
     GerritNav.navigateToRelativeUrl(url);
-    this._closeDialog(this._getDialogFromEvent(e), true);
+    this._closeDialog(this._getDialogFromEvent(e));
   }
 
   _handleUploadConfirm(path: string, fileData: string) {
     if (!this.change || !path || !fileData) {
-      this._closeDialog(this.$.openDialog, true);
-      return;
+      fireAlert(this, 'You must enter a path and data.');
+      this._closeDialog(this.$.openDialog);
+      return Promise.resolve();
     }
     return this.restApiService
       .saveFileUploadChangeEdit(this.change._number, path, fileData)
@@ -223,8 +236,8 @@
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(this.$.openDialog, true);
-        GerritNav.navigateToChange(this.change);
+        this._closeDialog(this.$.openDialog);
+        fireReload(this, true);
       });
   }
 
@@ -232,40 +245,55 @@
     // 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);
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(dialog);
+      return;
+    }
     this.restApiService
       .deleteFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
-        GerritNav.navigateToChange(this.change);
+        this._closeDialog(dialog);
+        fireReload(this);
       });
   }
 
   _handleRestoreConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path) {
+      fireAlert(this, 'You must enter a path.');
+      this._closeDialog(dialog);
+      return;
+    }
     this.restApiService
       .restoreFileInChangeEdit(this.change._number, this._path)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
-        GerritNav.navigateToChange(this.change);
+        this._closeDialog(dialog);
+        fireReload(this);
       });
   }
 
   _handleRenameConfirm(e: Event) {
     const dialog = this._getDialogFromEvent(e);
+    if (!this.change || !this._path || !this._newPath) {
+      fireAlert(this, 'You must enter a old path and a new path.');
+      this._closeDialog(dialog);
+      return;
+    }
     return this.restApiService
       .renameFileInChangeEdit(this.change._number, this._path, this._newPath)
       .then(res => {
         if (!res || !res.ok) {
           return;
         }
-        this._closeDialog(dialog, true);
-        GerritNav.navigateToChange(this.change);
+        this._closeDialog(dialog);
+        fireReload(this, true);
       });
   }
 
@@ -323,7 +351,7 @@
     }
   }
 
-  _handleKeyPress(event: InputEvent) {
+  _handleKeyPress(event: KeyboardEvent) {
     event.preventDefault();
     event.stopImmediatePropagation();
   }
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 0989f63..2e25659 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
@@ -106,7 +106,6 @@
           <p>
             <iron-input>
               <input
-                is="iron-input"
                 id="fileUploadInput"
                 type="file"
                 on-change="_handleFileUploadChanged"
@@ -156,16 +155,11 @@
           text="{{_path}}"
         ></gr-autocomplete>
         <iron-input
-          class="newPathIronInput"
+          id="newPathIronInput"
           bind-value="{{_newPath}}"
           placeholder="Enter the new path."
         >
-          <input
-            class="newPathInput"
-            is="iron-input"
-            bind-value="{{_newPath}}"
-            placeholder="Enter the new path."
-          />
+          <input id="newPathInput" placeholder="Enter the new path." />
         </iron-input>
       </div>
     </gr-dialog>
@@ -180,7 +174,7 @@
       <div class="header" slot="header">Restore this file?</div>
       <div class="main" slot="main">
         <iron-input disabled="" bind-value="{{_path}}">
-          <input is="iron-input" disabled="" bind-value="{{_path}}" />
+          <input disabled="" />
         </iron-input>
       </div>
     </gr-dialog>
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
deleted file mode 100644
index bbf4790..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.js
+++ /dev/null
@@ -1,416 +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 './gr-edit-controls.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-edit-controls');
-
-suite('gr-edit-controls tests', () => {
-  let element;
-
-  let showDialogSpy;
-  let closeDialogSpy;
-  let queryStub;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    element.change = {_number: '42'};
-    showDialogSpy = sinon.spy(element, '_showDialog');
-    closeDialogSpy = sinon.spy(element, '_closeDialog');
-    sinon.stub(element, '_hideAllDialogs');
-    queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
-    flush();
-  });
-
-  test('all actions exist', () => {
-    // We take 1 away from the total found, due to an extra button being
-    // added for the file uploads (browse).
-    assert.equal(
-        element.root.querySelectorAll('gr-button').length - 1,
-        element._actions.length);
-  });
-
-  suite('edit button CUJ', () => {
-    let navStubs;
-    let openAutoComplete;
-
-    setup(() => {
-      navStubs = [
-        sinon.stub(GerritNav, 'getEditUrlForDiff'),
-        sinon.stub(GerritNav, 'navigateToRelativeUrl'),
-      ];
-      openAutoComplete = element.$.openDialog.querySelector('gr-autocomplete');
-    });
-
-    test('_isValidPath', () => {
-      assert.isFalse(element._isValidPath(''));
-      assert.isFalse(element._isValidPath('test/'));
-      assert.isFalse(element._isValidPath('/'));
-      assert.isTrue(element._isValidPath('test/path.cpp'));
-      assert.isTrue(element._isValidPath('test.js'));
-    });
-
-    test('open', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-      element.patchNum = 1;
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element._hideAllDialogs.called);
-        assert.isTrue(element.$.openDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        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
-            .querySelector('gr-button[primary]'));
-        for (const stub of navStubs) { assert.isTrue(stub.called); }
-        assert.deepEqual(GerritNav.getEditUrlForDiff.lastCall.args,
-            [element.change, 'src/test.cpp', element.patchNum]);
-        assert.isTrue(closeDialogSpy.called);
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#open'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.openDialog.disabled);
-        openAutoComplete.noDebounce = true;
-        openAutoComplete.text = 'src/test.cpp';
-        assert.isFalse(element.$.openDialog.disabled);
-        MockInteractions.tap(element.$.openDialog.shadowRoot
-            .querySelector('gr-button'));
-        for (const stub of navStubs) { assert.isFalse(stub.called); }
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('delete button CUJ', () => {
-    let navStub;
-    let deleteStub;
-    let deleteAutocomplete;
-
-    setup(() => {
-      navStub = sinon.stub(GerritNav, 'navigateToChange');
-      deleteStub = stubRestApi('deleteFileInChangeEdit');
-      deleteAutocomplete =
-          element.$.deleteDialog.querySelector('gr-autocomplete');
-    });
-
-    test('delete', () => {
-      deleteStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        deleteAutocomplete._focused = true;
-        deleteAutocomplete.noDebounce = true;
-        deleteAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.isTrue(deleteStub.called);
-
-        return deleteStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('delete fails', () => {
-      deleteStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        deleteAutocomplete._focused = true;
-        deleteAutocomplete.noDebounce = true;
-        deleteAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.isTrue(deleteStub.called);
-
-        return deleteStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#delete'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.deleteDialog.disabled);
-        element.$.deleteDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        assert.isFalse(element.$.deleteDialog.disabled);
-        MockInteractions.tap(element.$.deleteDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('rename button CUJ', () => {
-    let navStub;
-    let renameStub;
-    let renameAutocomplete;
-    const inputSelector = PolymerElement ?
-      '.newPathIronInput' :
-      '.newPathInput';
-
-    setup(() => {
-      navStub = sinon.stub(GerritNav, 'navigateToChange');
-      renameStub = stubRestApi('renameFileInChangeEdit');
-      renameAutocomplete =
-          element.$.renameDialog.querySelector('gr-autocomplete');
-    });
-
-    test('rename', () => {
-      renameStub.returns(Promise.resolve({ok: true}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        renameAutocomplete._focused = true;
-        renameAutocomplete.noDebounce = true;
-        renameAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isTrue(element.$.renameDialog.disabled);
-
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.isTrue(renameStub.called);
-
-        return renameStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('rename fails', () => {
-      renameStub.returns(Promise.resolve({ok: false}));
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        assert.isFalse(queryStub.called);
-        // Setup _focused manually - in headless mode Chrome sometimes don't
-        // setup focus. flush and/or flushAsynchronousOperations don't help
-        renameAutocomplete._focused = true;
-        renameAutocomplete.noDebounce = true;
-        renameAutocomplete.text = 'src/test.cpp';
-        assert.isTrue(queryStub.called);
-        assert.isTrue(element.$.renameDialog.disabled);
-
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.isTrue(renameStub.called);
-
-        return renameStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      MockInteractions.tap(element.shadowRoot.querySelector('#rename'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(element.$.renameDialog.disabled);
-        element.$.renameDialog.querySelector('gr-autocomplete').text =
-            'src/test.cpp';
-        element.$.renameDialog.querySelector(inputSelector).bindValue =
-            'src/test.newPath';
-        assert.isFalse(element.$.renameDialog.disabled);
-        MockInteractions.tap(element.$.renameDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-        assert.equal(element._newPath, 'src/test.newPath');
-      });
-    });
-  });
-
-  suite('restore button CUJ', () => {
-    let navStub;
-    let restoreStub;
-
-    setup(() => {
-      navStub = sinon.stub(GerritNav, 'navigateToChange');
-      restoreStub = stubRestApi(
-          'restoreFileInChangeEdit');
-    });
-
-    test('restore hidden by default', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('#restore').classList.contains('invisible'));
-    });
-
-    test('restore', () => {
-      restoreStub.returns(Promise.resolve({ok: true}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.equal(element._path, '');
-          assert.isTrue(navStub.called);
-          assert.isTrue(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('restore fails', () => {
-      restoreStub.returns(Promise.resolve({ok: false}));
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button[primary]'));
-        flush();
-
-        assert.isTrue(restoreStub.called);
-        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
-        return restoreStub.lastCall.returnValue.then(() => {
-          assert.isFalse(navStub.called);
-          assert.isFalse(closeDialogSpy.called);
-        });
-      });
-    });
-
-    test('cancel', () => {
-      element._path = 'src/test.cpp';
-      MockInteractions.tap(element.shadowRoot.querySelector('#restore'));
-      return showDialogSpy.lastCall.returnValue.then(() => {
-        MockInteractions.tap(element.$.restoreDialog.shadowRoot
-            .querySelector('gr-button'));
-        assert.isFalse(navStub.called);
-        assert.isTrue(closeDialogSpy.called);
-        assert.equal(element._path, 'src/test.cpp');
-      });
-    });
-  });
-
-  suite('save file upload', () => {
-    let navStub;
-    let fileStub;
-
-    setup(() => {
-      navStub = sinon.stub(GerritNav, 'navigateToChange');
-      fileStub = stubRestApi('saveFileUploadChangeEdit');
-    });
-
-    test('_handleUploadConfirm', () => {
-      fileStub.returns(Promise.resolve({ok: true}));
-
-      element.change = {
-        _number: '1',
-        project: 'project',
-        revisions: {
-          abcd: {_number: 1},
-          efgh: {_number: 2},
-        },
-        current_revision: 'efgh',
-      };
-
-      element._handleUploadConfirm('test.php', 'base64').then(() => {
-        assert.equal(
-            navStub.lastCall.args,
-            '/c/project/+/1');
-      });
-    });
-  });
-
-  test('openOpenDialog', done => {
-    element.openOpenDialog('test/path.cpp')
-        .then(() => {
-          assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
-          assert.equal(
-              element.$.openDialog.querySelector('gr-autocomplete').text,
-              'test/path.cpp');
-          done();
-        });
-  });
-
-  test('_getDialogFromEvent', () => {
-    const spy = sinon.spy(element, '_getDialogFromEvent');
-    element.addEventListener('tap', element._getDialogFromEvent);
-
-    MockInteractions.tap(element.$.openDialog);
-    flush();
-    assert.equal(spy.lastCall.returnValue.id, 'openDialog');
-
-    MockInteractions.tap(element.$.deleteDialog);
-    flush();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-
-    MockInteractions.tap(
-        element.$.deleteDialog.querySelector('gr-autocomplete'));
-    flush();
-    assert.equal(spy.lastCall.returnValue.id, 'deleteDialog');
-
-    MockInteractions.tap(element);
-    flush();
-    assert.notOk(spy.lastCall.returnValue);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
new file mode 100644
index 0000000..6198f17
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-controls/gr-edit-controls_test.ts
@@ -0,0 +1,432 @@
+/**
+ * @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';
+import './gr-edit-controls';
+import {GrEditControls} from './gr-edit-controls';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {stubRestApi} from '../../../test/test-utils';
+import {createChange, createRevision} from '../../../test/test-data-generators';
+import {GrAutocomplete} from '../../shared/gr-autocomplete/gr-autocomplete';
+import {CommitId, NumericChangeId, PatchSetNum} from '../../../types/common';
+import {RepoName} from '../../../api/rest-api';
+import {queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-edit-controls');
+
+suite('gr-edit-controls tests', () => {
+  let element: GrEditControls;
+
+  let showDialogSpy: sinon.SinonSpy;
+  let closeDialogSpy: sinon.SinonSpy;
+  let hideDialogStub: sinon.SinonStub;
+  let queryStub: sinon.SinonStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    element.change = createChange();
+    showDialogSpy = sinon.spy(element, '_showDialog');
+    closeDialogSpy = sinon.spy(element, '_closeDialog');
+    hideDialogStub = sinon.stub(element, '_hideAllDialogs');
+    queryStub = stubRestApi('queryChangeFiles').returns(Promise.resolve([]));
+    flush();
+  });
+
+  test('all actions exist', () => {
+    // We take 1 away from the total found, due to an extra button being
+    // added for the file uploads (browse).
+    assert.equal(
+      element.root!.querySelectorAll('gr-button').length - 1,
+      element._actions.length
+    );
+  });
+
+  suite('edit button CUJ', () => {
+    let editDiffStub: sinon.SinonStub;
+    let navStub: sinon.SinonStub;
+    let openAutoComplete: GrAutocomplete;
+
+    setup(() => {
+      editDiffStub = sinon.stub(GerritNav, 'getEditUrlForDiff');
+      navStub = sinon.stub(GerritNav, 'navigateToRelativeUrl');
+      openAutoComplete =
+        element.$.openDialog!.querySelector('gr-autocomplete')!;
+    });
+
+    test('_isValidPath', () => {
+      assert.isFalse(element._isValidPath(''));
+      assert.isFalse(element._isValidPath('test/'));
+      assert.isFalse(element._isValidPath('/'));
+      assert.isTrue(element._isValidPath('test/path.cpp'));
+      assert.isTrue(element._isValidPath('test.js'));
+    });
+
+    test('open', async () => {
+      assert.isFalse(hideDialogStub.called);
+      MockInteractions.tap(queryAndAssert(element, '#open'));
+      element.patchNum = 1 as PatchSetNum;
+      await showDialogSpy.lastCall.returnValue;
+      assert.isTrue(hideDialogStub.called);
+      assert.isTrue(element.$.openDialog.disabled);
+      assert.isFalse(queryStub.called);
+      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // setup focus. flush and/or flushAsynchronousOperations don't help
+      openAutoComplete._focused = true;
+      openAutoComplete.noDebounce = true;
+      openAutoComplete.text = 'src/test.cpp';
+      await flush();
+      assert.isTrue(queryStub.called);
+      assert.isFalse(element.$.openDialog.disabled);
+      MockInteractions.tap(
+        queryAndAssert(element.$.openDialog, 'gr-button[primary]')
+      );
+      assert.isTrue(editDiffStub.called);
+      assert.isTrue(navStub.called);
+      assert.deepEqual(editDiffStub.lastCall.args, [
+        element.change,
+        'src/test.cpp',
+        element.patchNum,
+      ]);
+      assert.isTrue(closeDialogSpy.called);
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(queryAndAssert(element, '#open'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.openDialog.disabled);
+        openAutoComplete.noDebounce = true;
+        openAutoComplete.text = 'src/test.cpp';
+        assert.isFalse(element.$.openDialog.disabled);
+        MockInteractions.tap(queryAndAssert(element.$.openDialog, 'gr-button'));
+        assert.isFalse(editDiffStub.called);
+        assert.isFalse(navStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, '');
+      });
+    });
+  });
+
+  suite('delete button CUJ', () => {
+    let eventStub: sinon.SinonStub;
+    let deleteStub: sinon.SinonStub;
+    let deleteAutocomplete: GrAutocomplete;
+
+    setup(() => {
+      eventStub = sinon.stub(element, 'dispatchEvent');
+      deleteStub = stubRestApi('deleteFileInChangeEdit');
+      deleteAutocomplete =
+        element.$.deleteDialog!.querySelector('gr-autocomplete')!;
+    });
+
+    test('delete', async () => {
+      deleteStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      await showDialogSpy.lastCall.returnValue;
+      assert.isTrue(element.$.deleteDialog.disabled);
+      assert.isFalse(queryStub.called);
+      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // setup focus. flush and/or flushAsynchronousOperations don't help
+      deleteAutocomplete._focused = true;
+      deleteAutocomplete.noDebounce = true;
+      deleteAutocomplete.text = 'src/test.cpp';
+      await flush();
+      assert.isTrue(queryStub.called);
+      assert.isFalse(element.$.deleteDialog.disabled);
+      MockInteractions.tap(
+        queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+      );
+      await flush();
+
+      assert.isTrue(deleteStub.called);
+      await deleteStub.lastCall.returnValue;
+      assert.equal(element._path, '');
+      assert.equal(eventStub.firstCall.args[0].type, 'reload');
+      assert.isTrue(closeDialogSpy.called);
+    });
+
+    test('delete fails', async () => {
+      deleteStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      await showDialogSpy.lastCall.returnValue;
+      assert.isTrue(element.$.deleteDialog.disabled);
+      assert.isFalse(queryStub.called);
+      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // setup focus. flush and/or flushAsynchronousOperations don't help
+      deleteAutocomplete._focused = true;
+      deleteAutocomplete.noDebounce = true;
+      deleteAutocomplete.text = 'src/test.cpp';
+      await flush();
+      assert.isTrue(queryStub.called);
+      assert.isFalse(element.$.deleteDialog.disabled);
+      MockInteractions.tap(
+        queryAndAssert(element.$.deleteDialog, 'gr-button[primary]')
+      );
+      await flush();
+
+      assert.isTrue(deleteStub.called);
+
+      await deleteStub.lastCall.returnValue;
+      assert.isFalse(eventStub.called);
+      assert.isFalse(closeDialogSpy.called);
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(queryAndAssert(element, '#delete'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.deleteDialog.disabled);
+        element.$.deleteDialog!.querySelector('gr-autocomplete')!.text =
+          'src/test.cpp';
+        assert.isFalse(element.$.deleteDialog.disabled);
+        MockInteractions.tap(
+          queryAndAssert(element.$.deleteDialog, 'gr-button')
+        );
+        assert.isFalse(eventStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, '');
+      });
+    });
+  });
+
+  suite('rename button CUJ', () => {
+    let eventStub: sinon.SinonStub;
+    let renameStub: sinon.SinonStub;
+    let renameAutocomplete: GrAutocomplete;
+
+    setup(() => {
+      eventStub = sinon.stub(element, 'dispatchEvent');
+      renameStub = stubRestApi('renameFileInChangeEdit');
+      renameAutocomplete =
+        element.$.renameDialog!.querySelector('gr-autocomplete')!;
+    });
+
+    test('rename', async () => {
+      renameStub.returns(Promise.resolve({ok: true}));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      await showDialogSpy.lastCall.returnValue;
+      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isFalse(queryStub.called);
+      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // setup focus. flush and/or flushAsynchronousOperations don't help
+      renameAutocomplete._focused = true;
+      renameAutocomplete.noDebounce = true;
+      renameAutocomplete.text = 'src/test.cpp';
+      await flush();
+      assert.isTrue(queryStub.called);
+      assert.isTrue(element.$.renameDialog.disabled);
+
+      element.$.newPathIronInput.bindValue = 'src/test.newPath';
+      await flush();
+
+      assert.isFalse(element.$.renameDialog.disabled);
+      MockInteractions.tap(
+        queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+      );
+      await flush();
+      assert.isTrue(renameStub.called);
+
+      await renameStub.lastCall.returnValue;
+      assert.equal(element._path, '');
+      assert.equal(eventStub.firstCall.args[0].type, 'reload');
+      assert.isTrue(closeDialogSpy.called);
+    });
+
+    test('rename fails', async () => {
+      renameStub.returns(Promise.resolve({ok: false}));
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      await showDialogSpy.lastCall.returnValue;
+      assert.isTrue(element.$.renameDialog.disabled);
+      assert.isFalse(queryStub.called);
+      // Setup _focused manually - in headless mode Chrome sometimes don't
+      // setup focus. flush and/or flushAsynchronousOperations don't help
+      renameAutocomplete._focused = true;
+      renameAutocomplete.noDebounce = true;
+      renameAutocomplete.text = 'src/test.cpp';
+      await flush();
+      assert.isTrue(queryStub.called);
+      assert.isTrue(element.$.renameDialog.disabled);
+
+      element.$.newPathIronInput.bindValue = 'src/test.newPath';
+      await flush();
+
+      assert.isFalse(element.$.renameDialog.disabled);
+      MockInteractions.tap(
+        queryAndAssert(element.$.renameDialog, 'gr-button[primary]')
+      );
+      await flush();
+
+      assert.isTrue(renameStub.called);
+
+      await renameStub.lastCall.returnValue;
+      assert.isFalse(eventStub.called);
+      assert.isFalse(closeDialogSpy.called);
+    });
+
+    test('cancel', () => {
+      MockInteractions.tap(queryAndAssert(element, '#rename'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(element.$.renameDialog.disabled);
+        element.$.renameDialog!.querySelector('gr-autocomplete')!.text =
+          'src/test.cpp';
+        element.$.newPathIronInput.bindValue = 'src/test.newPath';
+        assert.isFalse(element.$.renameDialog.disabled);
+        MockInteractions.tap(
+          queryAndAssert(element.$.renameDialog, 'gr-button')
+        );
+        assert.isFalse(eventStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, '');
+        assert.equal(element._newPath, '');
+      });
+    });
+  });
+
+  suite('restore button CUJ', () => {
+    let eventStub: sinon.SinonStub;
+    let restoreStub: sinon.SinonStub;
+
+    setup(() => {
+      eventStub = sinon.stub(element, 'dispatchEvent');
+      restoreStub = stubRestApi('restoreFileInChangeEdit');
+    });
+
+    test('restore hidden by default', () => {
+      assert.isTrue(
+        queryAndAssert(element, '#restore').classList!.contains('invisible')!
+      );
+    });
+
+    test('restore', () => {
+      restoreStub.returns(Promise.resolve({ok: true}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+        );
+        flush();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.equal(element._path, '');
+          assert.equal(eventStub.firstCall.args[0].type, 'reload');
+          assert.isTrue(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('restore fails', () => {
+      restoreStub.returns(Promise.resolve({ok: false}));
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button[primary]')
+        );
+        flush();
+
+        assert.isTrue(restoreStub.called);
+        assert.equal(restoreStub.lastCall.args[1], 'src/test.cpp');
+        return restoreStub.lastCall.returnValue.then(() => {
+          assert.isFalse(eventStub.called);
+          assert.isFalse(closeDialogSpy.called);
+        });
+      });
+    });
+
+    test('cancel', () => {
+      element._path = 'src/test.cpp';
+      MockInteractions.tap(queryAndAssert(element, '#restore'));
+      return showDialogSpy.lastCall.returnValue.then(() => {
+        MockInteractions.tap(
+          queryAndAssert(element.$.restoreDialog, 'gr-button')
+        );
+        assert.isFalse(eventStub.called);
+        assert.isTrue(closeDialogSpy.called);
+        assert.equal(element._path, '');
+      });
+    });
+  });
+
+  suite('save file upload', () => {
+    let navStub: sinon.SinonStub;
+    let fileStub: sinon.SinonStub;
+
+    setup(() => {
+      navStub = sinon.stub(GerritNav, 'navigateToChange');
+      fileStub = stubRestApi('saveFileUploadChangeEdit');
+    });
+
+    test('_handleUploadConfirm', () => {
+      fileStub.returns(Promise.resolve({ok: true}));
+
+      element.change = {
+        ...createChange(),
+        _number: 1 as NumericChangeId,
+        project: 'project' as RepoName,
+        revisions: {
+          abcd: {
+            ...createRevision(1),
+            _number: 1 as PatchSetNum,
+          },
+          efgh: {
+            ...createRevision(2),
+            _number: 2 as PatchSetNum,
+          },
+        },
+        current_revision: 'efgh' as CommitId,
+      };
+
+      element._handleUploadConfirm('test.php', 'base64').then(() => {
+        assert.isTrue(navStub.calledWithExactly(1 as NumericChangeId));
+      });
+    });
+  });
+
+  test('openOpenDialog', async () => {
+    await element.openOpenDialog('test/path.cpp');
+    assert.isFalse(element.$.openDialog.hasAttribute('hidden'));
+    assert.equal(
+      element.$.openDialog!.querySelector('gr-autocomplete')!.text,
+      'test/path.cpp'
+    );
+  });
+
+  test('_getDialogFromEvent', () => {
+    const spy = sinon.spy(element, '_getDialogFromEvent');
+    element.addEventListener('tap', element._getDialogFromEvent);
+
+    MockInteractions.tap(element.$.openDialog);
+    flush();
+    assert.equal(spy!.lastCall!.returnValue!.id, 'openDialog');
+
+    MockInteractions.tap(element.$.deleteDialog);
+    flush();
+    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
+
+    MockInteractions.tap(
+      element.$.deleteDialog!.querySelector('gr-autocomplete')!
+    );
+    flush();
+    assert.equal(spy!.lastCall!.returnValue!.id, 'deleteDialog');
+
+    MockInteractions.tap(element);
+    flush();
+    assert.notOk(spy!.lastCall!.returnValue);
+  });
+});
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
index d2e1d64..1b854b4 100644
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls.ts
@@ -14,13 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../shared/gr-button/gr-button';
+
 import '../../shared/gr-dropdown/gr-dropdown';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-edit-file-controls_html';
 import {GrEditConstants} from '../gr-edit-constants';
-import {customElement, property} from '@polymer/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 interface EditAction {
   label: string;
@@ -28,11 +27,7 @@
 }
 
 @customElement('gr-edit-file-controls')
-class GrEditFileControls extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrEditFileControls extends LitElement {
   /**
    * Fired when an action in the overflow menu is tapped.
    *
@@ -45,8 +40,53 @@
   @property({type: Array})
   _allFileActions = Object.values(GrEditConstants.Actions);
 
-  @property({type: Array, computed: '_computeFileActions(_allFileActions)'})
-  _fileActions?: EditAction[];
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          align-items: center;
+          display: flex;
+          justify-content: flex-end;
+        }
+        gr-dropdown {
+          --gr-dropdown-item-color: var(--link-color);
+          --gr-button-padding: var(--spacing-xs) var(--spacing-s);
+        }
+        #actions {
+          margin-right: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
+    const customStyle = html`
+      <style>
+        gr-dropdown {
+          --gr-dropdown-item: {
+            background-color: transparent;
+            border: none;
+            text-transform: uppercase;
+          }
+        }
+      </style>
+    `;
+    const fileActions = this._computeFileActions(this._allFileActions);
+    return html`${customStyle}
+      <gr-dropdown
+        id="actions"
+        .items=${fileActions}
+        down-arrow=""
+        vertical-offset="20"
+        @tap-item="${this._handleActionTap}"
+        link=""
+        >Actions</gr-dropdown
+      >`;
+  }
 
   _handleActionTap(e: CustomEvent) {
     e.preventDefault();
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts
deleted file mode 100644
index c6a6de7..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_html.ts
+++ /dev/null
@@ -1,53 +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">
-    :host {
-      align-items: center;
-      display: flex;
-      justify-content: flex-end;
-    }
-    #actions {
-      margin-right: var(--spacing-l);
-    }
-    gr-button,
-    gr-dropdown {
-      --gr-button: {
-        height: 1.8em;
-      }
-    }
-    gr-dropdown {
-      --gr-dropdown-item: {
-        background-color: transparent;
-        border: none;
-        color: var(--link-color);
-        text-transform: uppercase;
-      }
-    }
-  </style>
-  <gr-dropdown
-    id="actions"
-    items="[[_fileActions]]"
-    down-arrow=""
-    vertical-offset="20"
-    on-tap-item="_handleActionTap"
-    link=""
-    >Actions</gr-dropdown
-  >
-`;
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
deleted file mode 100644
index 180a3a4..0000000
--- a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.js
+++ /dev/null
@@ -1,92 +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 '../gr-edit-constants.js';
-import './gr-edit-file-controls.js';
-import {GrEditConstants} from '../gr-edit-constants.js';
-
-const basicFixture = fixtureFromElement('gr-edit-file-controls');
-
-suite('gr-edit-file-controls tests', () => {
-  let element;
-
-  let fileActionHandler;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    fileActionHandler = sinon.stub();
-    element.addEventListener('file-action-tap', fileActionHandler);
-  });
-
-  test('open tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="open"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.OPEN.id, path: 'foo'});
-  });
-
-  test('delete tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="delete"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.DELETE.id, path: 'foo'});
-  });
-
-  test('restore tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="restore"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RESTORE.id, path: 'foo'});
-  });
-
-  test('rename tap emits event', () => {
-    const actions = element.$.actions;
-    element.filePath = 'foo';
-    actions._open();
-    flush();
-
-    MockInteractions.tap(actions.shadowRoot
-        .querySelector('li [data-id="rename"]'));
-    assert.isTrue(fileActionHandler.called);
-    assert.deepEqual(fileActionHandler.lastCall.args[0].detail,
-        {action: GrEditConstants.Actions.RENAME.id, path: 'foo'});
-  });
-
-  test('computed properties', () => {
-    assert.equal(element._allFileActions.length, 4);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
new file mode 100644
index 0000000..049c187
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-edit-file-controls/gr-edit-file-controls_test.ts
@@ -0,0 +1,103 @@
+/**
+ * @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';
+import './gr-edit-file-controls';
+import {GrEditFileControls} from './gr-edit-file-controls';
+import {GrEditConstants} from '../gr-edit-constants';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrDropdown} from '../../shared/gr-dropdown/gr-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-edit-file-controls');
+
+suite('gr-edit-file-controls tests', () => {
+  let element: GrEditFileControls;
+
+  let fileActionHandler: sinon.SinonStub;
+
+  setup(async () => {
+    element = basicFixture.instantiate();
+    fileActionHandler = sinon.stub();
+    element.addEventListener('file-action-tap', fileActionHandler);
+    await flush();
+  });
+
+  test('open tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="open"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.OPEN.id,
+      path: 'foo',
+    });
+  });
+
+  test('delete tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="delete"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.DELETE.id,
+      path: 'foo',
+    });
+  });
+
+  test('restore tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="restore"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RESTORE.id,
+      path: 'foo',
+    });
+  });
+
+  test('rename tap emits event', () => {
+    const actions = queryAndAssert<GrDropdown>(element, '#actions');
+    element.filePath = 'foo';
+    actions._open();
+    flush();
+
+    const row = queryAndAssert(actions, 'li [data-id="rename"]');
+    MockInteractions.tap(row);
+    assert.isTrue(fileActionHandler.called);
+    assert.deepEqual(fileActionHandler.lastCall.args[0].detail, {
+      action: GrEditConstants.Actions.RENAME.id,
+      path: 'foo',
+    });
+  });
+
+  test('computed properties', () => {
+    assert.equal(element._allFileActions.length, 4);
+  });
+});
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 553409f..24ebd67 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
@@ -22,13 +22,12 @@
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-editor-view_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   GerritNav,
   GenerateUrlEditViewParameters,
 } from '../../core/gr-navigation/gr-navigation';
 import {computeTruncatedPath} from '../../../utils/path-list-util';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, observe, property} from '@polymer/decorators';
 import {
   ChangeInfo,
   PatchSetNum,
@@ -44,6 +43,10 @@
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {changeIsMerged, changeIsAbandoned} from '../../../utils/change-util';
+import {GrButton} from '../../shared/gr-button/gr-button';
+import {GrDefaultEditor} from '../gr-default-editor/gr-default-editor';
+import {GrEndpointDecorator} from '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
+import {addShortcut, Modifier} from '../../../utils/dom-util';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const SAVING_MESSAGE = 'Saving changes...';
@@ -54,8 +57,19 @@
 
 const STORAGE_DEBOUNCE_INTERVAL_MS = 100;
 
+// Used within the tests
+export interface GrEditorView {
+  $: {
+    close: GrButton;
+    editorEndpoint: GrEndpointDecorator;
+    file: GrDefaultEditor;
+    publish: GrButton;
+    save: GrButton;
+  };
+}
+
 @customElement('gr-editor-view')
-export class GrEditorView extends KeyboardShortcutMixin(PolymerElement) {
+export class GrEditorView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -94,7 +108,7 @@
   _content?: string;
 
   @property({type: String})
-  _newContent?: string;
+  _newContent = '';
 
   @property({type: Boolean})
   _saving = false;
@@ -118,15 +132,13 @@
 
   private readonly storage = appContext.storageService;
 
-  private storeTask?: DelayedTask;
+  private readonly reporting = appContext.reportingService;
 
-  reporting = appContext.reportingService;
+  // Tests use this so needs to be non private
+  storeTask?: DelayedTask;
 
-  get keyBindings() {
-    return {
-      'ctrl+s meta+s': '_handleSaveShortcut',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   constructor() {
     super();
@@ -135,17 +147,27 @@
     });
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._getEditPrefs().then(prefs => {
       this._prefs = prefs;
     });
+    this.cleanups.push(
+      addShortcut(this, {key: 's', modifiers: [Modifier.CTRL_KEY]}, e =>
+        this._handleSaveShortcut(e)
+      )
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: 's', modifiers: [Modifier.META_KEY]}, e =>
+        this._handleSaveShortcut(e)
+      )
+    );
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.storeTask?.cancel();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
@@ -196,8 +218,8 @@
   }
 
   _editChange(value?: ChangeInfo | null) {
-    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
     if (!value) return;
+    if (!changeIsMerged(value) && !changeIsAbandoned(value)) return;
     fireAlert(
       this,
       'Change edits cannot be created if change is merged or abandoned. Redirected to non edit mode.'
@@ -205,6 +227,15 @@
     GerritNav.navigateToChange(value);
   }
 
+  @observe('_change', '_type')
+  _editType(change?: ChangeInfo | null, type?: string) {
+    if (!change || !type || !type.startsWith('image/')) return;
+
+    // Prevent editing binary files
+    fireAlert(this, 'You cannot edit binary files within the inline editor.');
+    GerritNav.navigateToChange(change);
+  }
+
   _handlePathChanged(e: CustomEvent<string>) {
     // TODO(TS) could be cleaned up, it was added for type requirements
     if (this._changeNum === undefined || !this._path) {
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 fc6a9d9..b577db3 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
@@ -28,8 +28,7 @@
       top: 0;
       z-index: 1;
     }
-    header,
-    .subHeader {
+    header {
       align-items: center;
       display: flex;
       flex-wrap: wrap;
@@ -41,14 +40,14 @@
       font-size: var(--font-size-h3);
       font-weight: var(--font-weight-h3);
       line-height: var(--line-height-h3);
-      --label-style: {
-        text-overflow: initial;
-        white-space: initial;
-        word-break: break-all;
-      }
-      --input-style: {
-        margin-top: var(--spacing-l);
-      }
+    }
+    header gr-editable-label::part(label) {
+      text-overflow: initial;
+      white-space: initial;
+      word-break: break-all;
+    }
+    header gr-editable-label::part(input-container) {
+      margin-top: var(--spacing-l);
     }
     .textareaWrapper {
       border: 1px solid var(--border-color);
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
deleted file mode 100644
index 8ae9827..0000000
--- a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.js
+++ /dev/null
@@ -1,428 +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 './gr-editor-view.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.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');
-
-suite('gr-editor-view tests', () => {
-  let element;
-
-  let savePathStub;
-  let saveFileStub;
-  let changeDetailStub;
-  let navigateStub;
-  const mockParams = {
-    changeNum: '42',
-    path: 'foo/bar.baz',
-    patchNum: 'edit',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    savePathStub = stubRestApi('renameFileInChangeEdit');
-    saveFileStub = stubRestApi('saveChangeEdit');
-    changeDetailStub = stubRestApi(
-        'getDiffChangeDetail');
-    navigateStub = sinon.stub(element, '_viewEditInChangeView');
-  });
-
-  suite('_paramsChanged', () => {
-    test('incorrect view returns immediately', () => {
-      element._paramsChanged(
-          {...mockParams, view: GerritNav.View.DIFF});
-      assert.notOk(element._changeNum);
-    });
-
-    test('good params proceed', () => {
-      changeDetailStub.returns(Promise.resolve({}));
-      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
-        element._content = 'text';
-        element._newContent = 'text';
-        element._type = 'application/octet-stream';
-      });
-
-      const promises = element._paramsChanged(
-          {...mockParams, view: GerritNav.View.EDIT});
-
-      flush();
-      assert.equal(element._changeNum, mockParams.changeNum);
-      assert.equal(element._path, mockParams.path);
-      assert.deepEqual(changeDetailStub.lastCall.args[0],
-          mockParams.changeNum);
-      assert.deepEqual(fileStub.lastCall.args,
-          [mockParams.changeNum, mockParams.path, mockParams.patchNum]);
-
-      return promises.then(() => {
-        assert.equal(element._content, 'text');
-        assert.equal(element._newContent, 'text');
-        assert.equal(element._type, 'application/octet-stream');
-      });
-    });
-  });
-
-  test('edit file path', () => {
-    element._changeNum = mockParams.changeNum;
-    element._path = mockParams.path;
-    savePathStub.onFirstCall().returns(Promise.resolve({}));
-    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
-
-    // Calling with the same path should not navigate.
-    return element._handlePathChanged({detail: mockParams.path}).then(() => {
-      assert.isFalse(savePathStub.called);
-      // !ok response
-      element._handlePathChanged({detail: 'newPath'}).then(() => {
-        assert.isTrue(savePathStub.called);
-        assert.isFalse(navigateStub.called);
-        // ok response
-        element._handlePathChanged({detail: 'newPath'}).then(() => {
-          assert.isTrue(navigateStub.called);
-          assert.isTrue(element._successfulSave);
-        });
-      });
-    });
-  });
-
-  test('reacts to content-change event', () => {
-    const storeStub = sinon.spy(element.storage, 'setEditableContentItem');
-    element._newContent = 'test';
-    element.$.editorEndpoint.dispatchEvent(new CustomEvent('content-change', {
-      bubbles: true, composed: true,
-      detail: {value: 'new content value'},
-    }));
-    element.storeTask.flush();
-    flush();
-
-    assert.equal(element._newContent, 'new content value');
-    assert.isTrue(storeStub.called);
-    assert.equal(storeStub.lastCall.args[1], 'new content value');
-  });
-
-  suite('edit file content', () => {
-    const originalText = 'file text';
-    const newText = 'file text changed';
-
-    setup(() => {
-      element._changeNum = mockParams.changeNum;
-      element._path = mockParams.path;
-      element._content = originalText;
-      element._newContent = originalText;
-      flush();
-    });
-
-    test('initial load', () => {
-      assert.equal(element.$.file.fileContent, originalText);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-    });
-
-    test('file modification and save, !ok response', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const eraseStub = sinon.stub(element.storage,
-          'eraseEditableContentItem');
-      const alertStub = sinon.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: false}));
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-      assert.isFalse(element._saving);
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isTrue(eraseStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
-        assert.deepEqual(saveFileStub.lastCall.args,
-            [mockParams.changeNum, mockParams.path, newText]);
-        assert.isFalse(navigateStub.called);
-        assert.isFalse(element.$.save.hasAttribute('disabled'));
-        assert.notEqual(element._content, element._newContent);
-      });
-    });
-
-    test('file modification and save', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
-      saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.save);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
-        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
-        assert.isTrue(navigateStub.called);
-      });
-    });
-
-    test('file modification and publish', () => {
-      const saveSpy = sinon.spy(element, '_saveEdit');
-      const alertStub = sinon.stub(element, '_showAlert');
-      const changeActionsStub =
-        stubRestApi('executeChangeAction');
-      saveFileStub.returns(Promise.resolve({ok: true}));
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element._saving);
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.publish);
-      assert.isTrue(saveSpy.called);
-      assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
-      assert.isTrue(element._saving);
-      assert.isTrue(element.$.save.hasAttribute('disabled'));
-
-      return saveSpy.lastCall.returnValue.then(() => {
-        assert.isTrue(saveFileStub.called);
-        assert.isFalse(element._saving);
-
-        assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
-        assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
-
-        assert.isTrue(element.$.save.hasAttribute('disabled'));
-        assert.equal(element._content, element._newContent);
-        assert.isTrue(element._successfulSave);
-        assert.isFalse(navigateStub.called);
-
-        const args = changeActionsStub.lastCall.args;
-        assert.equal(args[0], '42');
-        assert.equal(args[1], HttpMethod.POST);
-        assert.equal(args[2], '/edit:publish');
-      });
-    });
-
-    test('file modification and close', () => {
-      const closeSpy = sinon.spy(element, '_handleCloseTap');
-      element._newContent = newText;
-      flush();
-
-      assert.isFalse(element.$.save.hasAttribute('disabled'));
-
-      MockInteractions.tap(element.$.close);
-      assert.isTrue(closeSpy.called);
-      assert.isFalse(saveFileStub.called);
-      assert.isTrue(navigateStub.called);
-    });
-  });
-
-  suite('_getFileData', () => {
-    setup(() => {
-      element._newContent = 'initial';
-      element._content = 'initial';
-      element._type = 'initial';
-      sinon.stub(element.storage, 'getEditableContentItem').returns(null);
-    });
-
-    test('res.ok', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'new content',
-          }));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, 'new content');
-        assert.equal(element._content, 'new content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('!res.ok', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({}));
-
-      // Ensure no data is set with a bad response.
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-
-    test('content is undefined', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('content and type is undefined', () => {
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-          }));
-
-      return element._getFileData('1', 'test/path', 'edit').then(() => {
-        assert.equal(element._newContent, '');
-        assert.equal(element._content, '');
-        assert.equal(element._type, '');
-      });
-    });
-  });
-
-  test('_showAlert', done => {
-    element.addEventListener('show-alert', e => {
-      assert.deepEqual(e.detail, {message: 'test message'});
-      assert.isTrue(e.bubbles);
-      done();
-    });
-
-    element._showAlert('test message');
-  });
-
-  test('_viewEditInChangeView', () => {
-    element._change = {};
-    navigateStub.restore();
-    const navStub = sinon.stub(GerritNav, 'navigateToChange');
-    element._patchNum = EditPatchSetNum;
-    element._viewEditInChangeView();
-    assert.equal(navStub.lastCall.args[1], undefined);
-    assert.equal(navStub.lastCall.args[3], true);
-  });
-
-  suite('keyboard shortcuts', () => {
-    // Used as the spy on the handler for each entry in keyBindings.
-    let handleSpy;
-
-    suite('_handleSaveShortcut', () => {
-      let saveStub;
-      setup(() => {
-        handleSpy = sinon.spy(element, '_handleSaveShortcut');
-        saveStub = sinon.stub(element, '_saveEdit');
-      });
-
-      test('save enabled', () => {
-        element._content = '';
-        element._newContent = '_test';
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isTrue(saveStub.calledOnce);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.equal(saveStub.callCount, 2);
-      });
-
-      test('save disabled', () => {
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
-        flush();
-
-        assert.isTrue(handleSpy.calledOnce);
-        assert.isFalse(saveStub.called);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
-        flush();
-
-        assert.equal(handleSpy.callCount, 2);
-        assert.isFalse(saveStub.called);
-      });
-    });
-  });
-
-  suite('gr-storage caching', () => {
-    test('local edit exists', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'old content',
-          }));
-
-      const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flush();
-
-        assert.isTrue(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'old content');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('local edit exists, is same as remote edit', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'pending edit'});
-      stubRestApi('getFileContent')
-          .returns(Promise.resolve({
-            ok: true,
-            type: 'text/javascript',
-            content: 'pending edit',
-          }));
-
-      const alertStub = sinon.stub();
-      element.addEventListener('show-alert', alertStub);
-
-      return element._getFileData(1, 'test', 1).then(() => {
-        flush();
-
-        assert.isFalse(alertStub.called);
-        assert.equal(element._newContent, 'pending edit');
-        assert.equal(element._content, 'pending edit');
-        assert.equal(element._type, 'text/javascript');
-      });
-    });
-
-    test('storage key computation', () => {
-      element._changeNum = 1;
-      element._patchNum = 1;
-      element._path = 'test';
-      assert.equal(element.storageKey, 'c1_ps1_test');
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
new file mode 100644
index 0000000..f591ab2
--- /dev/null
+++ b/polygerrit-ui/app/elements/edit/gr-editor-view/gr-editor-view_test.ts
@@ -0,0 +1,480 @@
+/**
+ * @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';
+import {GrEditorView} from './gr-editor-view';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {HttpMethod} from '../../../constants/constants';
+import {mockPromise, stubRestApi, stubStorage} from '../../../test/test-utils';
+import {
+  EditPatchSetNum,
+  NumericChangeId,
+  PatchSetNum,
+} from '../../../types/common';
+import {
+  createChangeViewChange,
+  createGenerateUrlEditViewParameters,
+} from '../../../test/test-data-generators';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-editor-view');
+
+suite('gr-editor-view tests', () => {
+  let element: GrEditorView;
+
+  let savePathStub: sinon.SinonStub;
+  let saveFileStub: sinon.SinonStub;
+  let changeDetailStub: sinon.SinonStub;
+  let navigateStub: sinon.SinonStub;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    savePathStub = stubRestApi('renameFileInChangeEdit');
+    saveFileStub = stubRestApi('saveChangeEdit');
+    changeDetailStub = stubRestApi('getDiffChangeDetail');
+    navigateStub = sinon.stub(element, '_viewEditInChangeView');
+  });
+
+  suite('_paramsChanged', () => {
+    test('good params proceed', () => {
+      changeDetailStub.returns(Promise.resolve({}));
+      const fileStub = sinon.stub(element, '_getFileData').callsFake(() => {
+        element._content = 'text';
+        element._newContent = 'text';
+        element._type = 'application/octet-stream';
+        return Promise.resolve();
+      });
+
+      const promises = element._paramsChanged({
+        ...createGenerateUrlEditViewParameters(),
+      });
+
+      flush();
+      const changeNum = 42 as NumericChangeId;
+      assert.equal(element._changeNum, changeNum);
+      assert.equal(element._path, 'foo/bar.baz');
+      assert.deepEqual(changeDetailStub.lastCall.args[0], changeNum);
+      assert.deepEqual(fileStub.lastCall.args, [
+        changeNum,
+        'foo/bar.baz',
+        EditPatchSetNum as PatchSetNum,
+      ]);
+
+      return promises?.then(() => {
+        assert.equal(element._content, 'text');
+        assert.equal(element._newContent, 'text');
+        assert.equal(element._type, 'application/octet-stream');
+      });
+    });
+  });
+
+  test('edit file path', () => {
+    element._changeNum = 42 as NumericChangeId;
+    element._path = 'foo/bar.baz';
+    savePathStub.onFirstCall().returns(Promise.resolve({}));
+    savePathStub.onSecondCall().returns(Promise.resolve({ok: true}));
+
+    // Calling with the same path should not navigate.
+    return element
+      ._handlePathChanged(new CustomEvent('change', {detail: 'foo/bar.baz'}))
+      .then(() => {
+        assert.isFalse(savePathStub.called);
+        // !ok response
+        element
+          ._handlePathChanged(new CustomEvent('change', {detail: 'newPath'}))
+          .then(() => {
+            assert.isTrue(savePathStub.called);
+            assert.isFalse(navigateStub.called);
+            // ok response
+            element
+              ._handlePathChanged(
+                new CustomEvent('change', {detail: 'newPath'})
+              )
+              .then(() => {
+                assert.isTrue(navigateStub.called);
+                assert.isTrue(element._successfulSave);
+              });
+          });
+      });
+  });
+
+  test('reacts to content-change event', () => {
+    const storageStub = stubStorage('setEditableContentItem');
+    element._newContent = 'test';
+    element.$.editorEndpoint.dispatchEvent(
+      new CustomEvent('content-change', {
+        bubbles: true,
+        composed: true,
+        detail: {value: 'new content value'},
+      })
+    );
+    element.storeTask?.flush();
+    flush();
+
+    assert.equal(element._newContent, 'new content value');
+    assert.isTrue(storageStub.called);
+    assert.equal(storageStub.lastCall.args[1], 'new content value');
+  });
+
+  suite('edit file content', () => {
+    const originalText = 'file text';
+    const newText = 'file text changed';
+
+    setup(() => {
+      element._changeNum = 42 as NumericChangeId;
+      element._path = 'foo/bar.baz';
+      element._content = originalText;
+      element._newContent = originalText;
+      flush();
+    });
+
+    test('initial load', () => {
+      assert.equal(element.$.file.fileContent, originalText);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+    });
+
+    test('file modification and save, !ok response', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const eraseStub = stubStorage('eraseEditableContentItem');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: false}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+      assert.isFalse(element._saving);
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isTrue(eraseStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'Failed to save changes');
+        assert.deepEqual(saveFileStub.lastCall.args, [
+          42 as NumericChangeId,
+          'foo/bar.baz',
+          newText,
+        ]);
+        assert.isFalse(navigateStub.called);
+        assert.isFalse(element.$.save.hasAttribute('disabled'));
+        assert.notEqual(element._content, element._newContent);
+      });
+    });
+
+    test('file modification and save', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.save);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.lastCall.args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+        assert.equal(alertStub.lastCall.args[0], 'All changes saved');
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isTrue(navigateStub.called);
+      });
+    });
+
+    test('file modification and publish', () => {
+      const saveSpy = sinon.spy(element, '_saveEdit');
+      const alertStub = sinon.stub(element, '_showAlert');
+      const changeActionsStub = stubRestApi('executeChangeAction');
+      saveFileStub.returns(Promise.resolve({ok: true}));
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element._saving);
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.publish);
+      assert.isTrue(saveSpy.called);
+      assert.equal(alertStub.getCall(0).args[0], 'Saving changes...');
+      assert.isTrue(element._saving);
+      assert.isTrue(element.$.save.hasAttribute('disabled'));
+
+      return saveSpy.lastCall.returnValue.then(() => {
+        assert.isTrue(saveFileStub.called);
+        assert.isFalse(element._saving);
+
+        assert.equal(alertStub.getCall(1).args[0], 'All changes saved');
+        assert.equal(alertStub.getCall(2).args[0], 'Publishing edit...');
+
+        assert.isTrue(element.$.save.hasAttribute('disabled'));
+        assert.equal(element._content, element._newContent);
+        assert.isTrue(element._successfulSave);
+        assert.isFalse(navigateStub.called);
+
+        const args = changeActionsStub.lastCall.args;
+        assert.equal(args[0], 42 as NumericChangeId);
+        assert.equal(args[1], HttpMethod.POST);
+        assert.equal(args[2], '/edit:publish');
+      });
+    });
+
+    test('file modification and close', () => {
+      const closeSpy = sinon.spy(element, '_handleCloseTap');
+      element._newContent = newText;
+      flush();
+
+      assert.isFalse(element.$.save.hasAttribute('disabled'));
+
+      MockInteractions.tap(element.$.close);
+      assert.isTrue(closeSpy.called);
+      assert.isFalse(saveFileStub.called);
+      assert.isTrue(navigateStub.called);
+    });
+  });
+
+  suite('_getFileData', () => {
+    setup(() => {
+      element._newContent = 'initial';
+      element._content = 'initial';
+      element._type = 'initial';
+      stubStorage('getEditableContentItem').returns(null);
+    });
+
+    test('res.ok', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ok: true,
+          type: 'text/javascript',
+          content: 'new content',
+        })
+      );
+
+      // Ensure no data is set with a bad response.
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, 'new content');
+          assert.equal(element._content, 'new content');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('!res.ok', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve(new Response(null, {status: 500}))
+      );
+
+      // Ensure no data is set with a bad response.
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, '');
+          assert.equal(element._content, '');
+          assert.equal(element._type, '');
+        });
+    });
+
+    test('content is undefined', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ...new Response(),
+          ok: true,
+          type: 'text/javascript' as ResponseType,
+        })
+      );
+
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, '');
+          assert.equal(element._content, '');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('content and type is undefined', () => {
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({...new Response(), ok: true})
+      );
+
+      return element
+        ._getFileData(
+          1 as NumericChangeId,
+          'test/path',
+          EditPatchSetNum as PatchSetNum
+        )
+        .then(() => {
+          assert.equal(element._newContent, '');
+          assert.equal(element._content, '');
+          assert.equal(element._type, '');
+        });
+    });
+  });
+
+  test('_showAlert', async () => {
+    const promise = mockPromise();
+    element.addEventListener('show-alert', e => {
+      assert.deepEqual(e.detail, {message: 'test message'});
+      assert.isTrue(e.bubbles);
+      promise.resolve();
+    });
+
+    element._showAlert('test message');
+    await promise;
+  });
+
+  test('_viewEditInChangeView', () => {
+    element._change = createChangeViewChange();
+    navigateStub.restore();
+    const navStub = sinon.stub(GerritNav, 'navigateToChange');
+    element._patchNum = EditPatchSetNum;
+    element._viewEditInChangeView();
+    assert.equal(navStub.lastCall.args[1], undefined);
+    assert.equal(navStub.lastCall.args[3], true);
+  });
+
+  suite('keyboard shortcuts', () => {
+    // Used as the spy on the handler for each entry in keyBindings.
+    let handleSpy: sinon.SinonSpy;
+
+    suite('_handleSaveShortcut', () => {
+      let saveStub: sinon.SinonStub;
+      setup(() => {
+        handleSpy = sinon.spy(element, '_handleSaveShortcut');
+        saveStub = sinon.stub(element, '_saveEdit');
+      });
+
+      test('save enabled', () => {
+        element._content = '';
+        element._newContent = '_test';
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flush();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isTrue(saveStub.calledOnce);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flush();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.equal(saveStub.callCount, 2);
+      });
+
+      test('save disabled', () => {
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'ctrl', 's');
+        flush();
+
+        assert.isTrue(handleSpy.calledOnce);
+        assert.isFalse(saveStub.called);
+
+        MockInteractions.pressAndReleaseKeyOn(element, 83, 'meta', 's');
+        flush();
+
+        assert.equal(handleSpy.callCount, 2);
+        assert.isFalse(saveStub.called);
+      });
+    });
+  });
+
+  suite('gr-storage caching', () => {
+    test('local edit exists', () => {
+      stubStorage('getEditableContentItem').returns({
+        message: 'pending edit',
+        updated: 0,
+      });
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ok: true,
+          type: 'text/javascript',
+          content: 'old content',
+        })
+      );
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element
+        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
+        .then(() => {
+          flush();
+
+          assert.isTrue(alertStub.called);
+          assert.equal(element._newContent, 'pending edit');
+          assert.equal(element._content, 'old content');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('local edit exists, is same as remote edit', () => {
+      stubStorage('getEditableContentItem').returns({
+        message: 'pending edit',
+        updated: 0,
+      });
+      stubRestApi('getFileContent').returns(
+        Promise.resolve({
+          ok: true,
+          type: 'text/javascript',
+          content: 'pending edit',
+        })
+      );
+
+      const alertStub = sinon.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      return element
+        ._getFileData(1 as NumericChangeId, 'test', 1 as PatchSetNum)
+        .then(() => {
+          flush();
+
+          assert.isFalse(alertStub.called);
+          assert.equal(element._newContent, 'pending edit');
+          assert.equal(element._content, 'pending edit');
+          assert.equal(element._type, 'text/javascript');
+        });
+    });
+
+    test('storage key computation', () => {
+      element._changeNum = 1 as NumericChangeId;
+      element._patchNum = 1 as PatchSetNum;
+      element._path = 'test';
+      assert.equal(element.storageKey, 'c1_ps1_test');
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index e96f6e4..d88eeaf 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -43,7 +43,7 @@
 import {
   KeyboardShortcutMixin,
   Shortcut,
-  SPECIAL_SHORTCUT,
+  ShortcutListener,
 } from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {GerritNav} from './core/gr-navigation/gr-navigation';
 import {appContext} from '../services/app-context';
@@ -55,7 +55,10 @@
   ElementPropertyDeepChange,
   ServerInfo,
 } from '../types/common';
-import {GrErrorManager} from './core/gr-error-manager/gr-error-manager';
+import {
+  constructServerErrorMsg,
+  GrErrorManager,
+} from './core/gr-error-manager/gr-error-manager';
 import {GrOverlay} from './shared/gr-overlay/gr-overlay';
 import {GrRegistrationDialog} from './settings/gr-registration-dialog/gr-registration-dialog';
 import {
@@ -66,20 +69,19 @@
 import {GrMainHeader} from './core/gr-main-header/gr-main-header';
 import {GrSettingsView} from './settings/gr-settings-view/gr-settings-view';
 import {
-  CustomKeyboardEvent,
+  DialogChangeEventDetail,
+  EventType,
   LocationChangeEvent,
   PageErrorEventDetail,
   RpcLogEvent,
-  ShortcutTriggeredEvent,
   TitleChangeEventDetail,
-  DialogChangeEventDetail,
-  EventType,
 } from '../types/events';
 import {ViewState} from '../types/types';
 import {GerritView} from '../services/router/router-model';
-import {windowLocationReload} from '../utils/dom-util';
 import {LifeCycle} from '../constants/reporting';
 import {fireIronAnnounce} from '../utils/event-util';
+import {assertIsDefined} from '../utils/common-util';
+import {listen} from '../services/shortcuts/shortcuts-service';
 
 interface ErrorInfo {
   text: string;
@@ -96,9 +98,16 @@
   };
 }
 
+type DomIf = PolymerElement & {
+  restamp: boolean;
+};
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = KeyboardShortcutMixin(PolymerElement);
+
 // TODO(TS): implement AppElement interface from gr-app-types.ts
 @customElement('gr-app-element')
-export class GrAppElement extends KeyboardShortcutMixin(PolymerElement) {
+export class GrAppElement extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -112,9 +121,6 @@
   @property({type: Object})
   params?: AppElementParams;
 
-  @property({type: Object})
-  keyEventTarget = document.body;
-
   @property({type: Object, observer: '_accountChanged'})
   _account?: AccountDetailInfo;
 
@@ -206,15 +212,19 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  keyboardShortcuts() {
-    return {
-      [Shortcut.OPEN_SHORTCUT_HELP_DIALOG]: '_showKeyboardShortcuts',
-      [Shortcut.GO_TO_USER_DASHBOARD]: '_goToUserDashboard',
-      [Shortcut.GO_TO_OPENED_CHANGES]: '_goToOpenedChanges',
-      [Shortcut.GO_TO_MERGED_CHANGES]: '_goToMergedChanges',
-      [Shortcut.GO_TO_ABANDONED_CHANGES]: '_goToAbandonedChanges',
-      [Shortcut.GO_TO_WATCHED_CHANGES]: '_goToWatchedChanges',
-    };
+  override keyboardShortcuts(): ShortcutListener[] {
+    return [
+      listen(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, _ =>
+        this._showKeyboardShortcuts()
+      ),
+      listen(Shortcut.GO_TO_USER_DASHBOARD, _ => this._goToUserDashboard()),
+      listen(Shortcut.GO_TO_OPENED_CHANGES, _ => this._goToOpenedChanges()),
+      listen(Shortcut.GO_TO_MERGED_CHANGES, _ => this._goToMergedChanges()),
+      listen(Shortcut.GO_TO_ABANDONED_CHANGES, _ =>
+        this._goToAbandonedChanges()
+      ),
+      listen(Shortcut.GO_TO_WATCHED_CHANGES, _ => this._goToWatchedChanges()),
+    ];
   }
 
   constructor() {
@@ -223,7 +233,6 @@
     // model changes and updates the config model, but at the moment the service
     // is not called from anywhere.
     appContext.configService;
-    this._bindKeyboardShortcuts();
     document.addEventListener(EventType.PAGE_ERROR, e => {
       this._handlePageError(e);
     });
@@ -233,21 +242,19 @@
     this.addEventListener(EventType.DIALOG_CHANGE, e => {
       this._handleDialogChange(e as CustomEvent<DialogChangeEventDetail>);
     });
-    this.addEventListener('location-change', e =>
+    this.addEventListener(EventType.LOCATION_CHANGE, e =>
       this._handleLocationChange(e)
     );
-    document.addEventListener('gr-rpc-log', e => this._handleRpcLog(e));
-    this.addEventListener('shortcut-triggered', e =>
-      this._handleShortcutTriggered(e)
+    this.addEventListener(EventType.RECREATE_CHANGE_VIEW, () =>
+      this.handleRecreateView(GerritView.CHANGE)
     );
-    // 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', () => windowLocationReload());
+    this.addEventListener(EventType.RECREATE_DIFF_VIEW, () =>
+      this.handleRecreateView(GerritView.DIFF)
+    );
+    document.addEventListener(EventType.GR_RPC_LOG, e => this._handleRpcLog(e));
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._updateLoginUrl();
     this.reporting.appStarted();
@@ -290,7 +297,6 @@
         showDownloadDialog: false,
         diffMode: null,
         numFilesShown: null,
-        scrollTop: 0,
       },
       changeListView: {
         query: null,
@@ -301,157 +307,6 @@
     };
   }
 
-  _bindKeyboardShortcuts() {
-    this.bindShortcut(
-      Shortcut.SEND_REPLY,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'ctrl+enter',
-      'meta+enter'
-    );
-    this.bindShortcut(Shortcut.EMOJI_DROPDOWN, SPECIAL_SHORTCUT.DOC_ONLY, ':');
-
-    this.bindShortcut(Shortcut.OPEN_SHORTCUT_HELP_DIALOG, '?');
-    this.bindShortcut(
-      Shortcut.GO_TO_USER_DASHBOARD,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'i'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_OPENED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'o'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_MERGED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'm'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_ABANDONED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'a'
-    );
-    this.bindShortcut(
-      Shortcut.GO_TO_WATCHED_CHANGES,
-      SPECIAL_SHORTCUT.GO_KEY,
-      'w'
-    );
-
-    this.bindShortcut(Shortcut.CURSOR_NEXT_CHANGE, 'j');
-    this.bindShortcut(Shortcut.CURSOR_PREV_CHANGE, 'k');
-    this.bindShortcut(Shortcut.OPEN_CHANGE, 'o');
-    this.bindShortcut(Shortcut.NEXT_PAGE, 'n', ']');
-    this.bindShortcut(Shortcut.PREV_PAGE, 'p', '[');
-    this.bindShortcut(Shortcut.TOGGLE_CHANGE_REVIEWED, 'r:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_CHANGE_STAR, 's:keydown');
-    this.bindShortcut(Shortcut.REFRESH_CHANGE_LIST, 'shift+r:keyup');
-    this.bindShortcut(Shortcut.EDIT_TOPIC, 't');
-
-    this.bindShortcut(Shortcut.OPEN_REPLY_DIALOG, 'a:keyup');
-    this.bindShortcut(Shortcut.OPEN_DOWNLOAD_DIALOG, 'd:keyup');
-    this.bindShortcut(Shortcut.EXPAND_ALL_MESSAGES, 'x');
-    this.bindShortcut(Shortcut.COLLAPSE_ALL_MESSAGES, 'z');
-    this.bindShortcut(Shortcut.REFRESH_CHANGE, 'shift+r:keyup');
-    this.bindShortcut(Shortcut.UP_TO_DASHBOARD, 'u');
-    this.bindShortcut(Shortcut.UP_TO_CHANGE, 'u');
-    this.bindShortcut(Shortcut.TOGGLE_DIFF_MODE, 'm:keyup');
-    this.bindShortcut(
-      Shortcut.DIFF_AGAINST_BASE,
-      SPECIAL_SHORTCUT.V_KEY,
-      'down',
-      's'
-    );
-    // this keyboard shortcut is used in toast _displayDiffAgainstLatestToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-      Shortcut.DIFF_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'up',
-      'w'
-    );
-    // this keyboard shortcut is used in toast _displayDiffBaseAgainstLeftToast
-    // in gr-diff-view. Any updates here should be reflected there
-    this.bindShortcut(
-      Shortcut.DIFF_BASE_AGAINST_LEFT,
-      SPECIAL_SHORTCUT.V_KEY,
-      'left',
-      'a'
-    );
-    this.bindShortcut(
-      Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'right',
-      'd'
-    );
-    this.bindShortcut(
-      Shortcut.DIFF_BASE_AGAINST_LATEST,
-      SPECIAL_SHORTCUT.V_KEY,
-      'b'
-    );
-
-    this.bindShortcut(Shortcut.NEXT_LINE, 'j', 'down');
-    this.bindShortcut(Shortcut.PREV_LINE, 'k', 'up');
-    if (this._isCursorManagerSupportMoveToVisibleLine()) {
-      this.bindShortcut(Shortcut.VISIBLE_LINE, '.');
-    }
-    this.bindShortcut(Shortcut.NEXT_CHUNK, 'n');
-    this.bindShortcut(Shortcut.PREV_CHUNK, 'p');
-    this.bindShortcut(Shortcut.TOGGLE_ALL_DIFF_CONTEXT, 'shift+x');
-    this.bindShortcut(Shortcut.NEXT_COMMENT_THREAD, 'shift+n');
-    this.bindShortcut(Shortcut.PREV_COMMENT_THREAD, 'shift+p');
-    this.bindShortcut(
-      Shortcut.EXPAND_ALL_COMMENT_THREADS,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'e'
-    );
-    this.bindShortcut(
-      Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-      SPECIAL_SHORTCUT.DOC_ONLY,
-      'shift+e'
-    );
-    this.bindShortcut(Shortcut.LEFT_PANE, 'shift+left');
-    this.bindShortcut(Shortcut.RIGHT_PANE, 'shift+right');
-    this.bindShortcut(Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-    this.bindShortcut(Shortcut.NEW_COMMENT, 'c');
-    this.bindShortcut(
-      Shortcut.SAVE_COMMENT,
-      'ctrl+enter',
-      'meta+enter',
-      'ctrl+s',
-      'meta+s'
-    );
-    this.bindShortcut(Shortcut.OPEN_DIFF_PREFS, ',');
-    this.bindShortcut(Shortcut.TOGGLE_DIFF_REVIEWED, 'r:keyup');
-
-    this.bindShortcut(Shortcut.NEXT_FILE, ']');
-    this.bindShortcut(Shortcut.PREV_FILE, '[');
-    this.bindShortcut(Shortcut.NEXT_FILE_WITH_COMMENTS, 'shift+j');
-    this.bindShortcut(Shortcut.PREV_FILE_WITH_COMMENTS, 'shift+k');
-    this.bindShortcut(Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-    this.bindShortcut(Shortcut.CURSOR_PREV_FILE, 'k', 'up');
-    this.bindShortcut(Shortcut.OPEN_FILE, 'o', 'enter');
-    this.bindShortcut(Shortcut.TOGGLE_FILE_REVIEWED, 'r:keyup');
-    this.bindShortcut(Shortcut.NEXT_UNREVIEWED_FILE, 'shift+m');
-    this.bindShortcut(Shortcut.TOGGLE_ALL_INLINE_DIFFS, 'shift+i:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_INLINE_DIFF, 'i:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_BLAME, 'b:keyup');
-    this.bindShortcut(Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS, 'h');
-    this.bindShortcut(Shortcut.OPEN_FILE_LIST, 'f');
-
-    this.bindShortcut(Shortcut.OPEN_FIRST_FILE, ']');
-    this.bindShortcut(Shortcut.OPEN_LAST_FILE, '[');
-
-    this.bindShortcut(Shortcut.SEARCH, '/');
-  }
-
-  _isCursorManagerSupportMoveToVisibleLine() {
-    // This method is a copy-paste from the
-    // method _isIntersectionObserverSupported of gr-cursor-manager.js
-    // It is better share this method with gr-cursor-manager,
-    // but doing it require a lot if changes instead of 1-line copied code
-    return 'IntersectionObserver' in window;
-  }
-
   _accountChanged(account?: AccountDetailInfo) {
     if (!account) return;
 
@@ -463,35 +318,58 @@
       (this._account && this._account._account_id) || null;
   }
 
-  @observe('params.view')
-  _viewChanged(view?: GerritView) {
+  /**
+   * Throws away the view and re-creates it. The view itself fires an event, if
+   * it wants to be re-created.
+   */
+  private handleRecreateView(view: GerritView.DIFF | GerritView.CHANGE) {
+    const isDiff = view === GerritView.DIFF;
+    const domId = isDiff ? '#dom-if-diff-view' : '#dom-if-change-view';
+    const domIf = this.root!.querySelector(domId) as DomIf;
+    assertIsDefined(domIf, '<dom-if> for the view');
+    // The rendering of DomIf is debounced, so just changing _show...View and
+    // restamp properties back and forth won't work. That is why we are using
+    // timeouts.
+    // The first timeout is needed, because the _viewChanged() observer also
+    // affects _show...View and would change _show...View=false directly back to
+    // _show...View=true.
+    setTimeout(() => {
+      this._showChangeView = false;
+      this._showDiffView = false;
+      domIf.restamp = true;
+      setTimeout(() => {
+        this._showChangeView = this.params?.view === GerritView.CHANGE;
+        this._showDiffView = this.params?.view === GerritView.DIFF;
+        domIf.restamp = false;
+      }, 1);
+    }, 1);
+  }
+
+  @observe('params.*')
+  _viewChanged() {
+    const view = this.params?.view;
     this.$.errorView.classList.remove('show');
-    this.set('_showChangeListView', view === GerritView.SEARCH);
-    this.set('_showDashboardView', view === GerritView.DASHBOARD);
-    this.set('_showChangeView', view === GerritView.CHANGE);
-    this.set('_showDiffView', view === GerritView.DIFF);
-    this.set('_showSettingsView', view === GerritView.SETTINGS);
+    this._showChangeListView = view === GerritView.SEARCH;
+    this._showDashboardView = view === GerritView.DASHBOARD;
+    this._showChangeView = view === GerritView.CHANGE;
+    this._showDiffView = view === GerritView.DIFF;
+    this._showSettingsView = view === GerritView.SETTINGS;
     // _showAdminView must be in sync with the gr-admin-view AdminViewParams type
-    this.set(
-      '_showAdminView',
+    this._showAdminView =
       view === GerritView.ADMIN ||
-        view === GerritView.GROUP ||
-        view === GerritView.REPO
-    );
-    this.set('_showCLAView', view === GerritView.AGREEMENTS);
-    this.set('_showEditorView', view === GerritView.EDIT);
+      view === GerritView.GROUP ||
+      view === GerritView.REPO;
+    this._showCLAView = view === GerritView.AGREEMENTS;
+    this._showEditorView = view === GerritView.EDIT;
     const isPluginScreen = view === GerritView.PLUGIN_SCREEN;
-    this.set('_showPluginScreen', false);
+    this._showPluginScreen = false;
     // Navigation within plugin screens does not restamp gr-endpoint-decorator
     // because _showPluginScreen value does not change. To force restamp,
     // change _showPluginScreen value between true and false.
     if (isPluginScreen) {
-      setTimeout(() => this.set('_showPluginScreen', true), 1);
+      setTimeout(() => (this._showPluginScreen = true), 1);
     }
-    this.set(
-      '_showDocumentationSearch',
-      view === GerritView.DOCUMENTATION_SEARCH
-    );
+    this._showDocumentationSearch = view === GerritView.DOCUMENTATION_SEARCH;
     if (
       this.params &&
       isAppElementJustRegisteredParams(this.params) &&
@@ -515,23 +393,6 @@
     fireIronAnnounce(this, ' ');
   }
 
-  _handleShortcutTriggered(event: ShortcutTriggeredEvent) {
-    const {event: e, goKey, vKey} = event.detail;
-    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
-    let key = `${((e as unknown) as KeyboardEvent).key}:${e.type}`;
-    if (goKey) key = 'g+' + key;
-    if (vKey) key = 'v+' + key;
-    if (e.shiftKey) key = 'shift+' + key;
-    if (e.ctrlKey) key = 'ctrl+' + key;
-    if (e.metaKey) key = 'meta+' + key;
-    if (e.altKey) key = 'alt+' + key;
-    const path = event.composedPath();
-    this.reporting.reportInteraction('shortcut-triggered', {
-      key,
-      from: (path && path[0] && (path[0] as Element).nodeName) ?? 'unknown',
-    });
-  }
-
   _handlePageError(e: CustomEvent<PageErrorEventDetail>) {
     const props = [
       '_showChangeListView',
@@ -557,7 +418,15 @@
       err.emoji = 'o_O';
       if (response) {
         response.text().then(text => {
-          err.moreInfo = text;
+          const trace =
+            response.headers && response.headers.get('X-Gerrit-Trace');
+          const {status, statusText} = response;
+          err.moreInfo = constructServerErrorMsg({
+            status,
+            statusText,
+            errorText: text,
+            trace,
+          });
           this._lastError = err;
         });
       }
@@ -572,7 +441,7 @@
     if (pathname.startsWith('/c/') && Number(hash) > 0) {
       pathname += '@' + hash;
     }
-    this.set('_path', pathname);
+    this._path = pathname;
   }
 
   _updateLoginUrl() {
@@ -607,7 +476,7 @@
     const params = paramsRecord.base;
     const viewsToCheck = [GerritView.SEARCH, GerritView.DASHBOARD];
     if (params?.view && viewsToCheck.includes(params.view)) {
-      this.set('_lastSearchPage', location.pathname);
+      this._lastSearchPage = location.pathname;
     }
   }
 
@@ -633,7 +502,7 @@
     (this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay).open();
   }
 
-  _showKeyboardShortcuts(e: CustomKeyboardEvent) {
+  _showKeyboardShortcuts() {
     // same shortcut should close the dialog if pressed again
     // when dialog is open
     this.loadKeyboardShortcutsDialog = true;
@@ -646,18 +515,15 @@
       keyboardShortcuts.cancel();
       return;
     }
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
     keyboardShortcuts.open();
     this._footerHeaderAriaHidden = true;
     this._mainAriaHidden = true;
   }
 
   _handleKeyboardShortcutDialogClose() {
-    (this.shadowRoot!.querySelector(
-      '#keyboardShortcuts'
-    ) as GrOverlay).cancel();
+    (
+      this.shadowRoot!.querySelector('#keyboardShortcuts') as GrOverlay
+    ).cancel();
   }
 
   onOverlayCanceled() {
@@ -668,9 +534,9 @@
   _handleAccountDetailUpdate() {
     this.$.mainHeader.reload();
     if (this.params?.view === GerritView.SETTINGS) {
-      (this.shadowRoot!.querySelector(
-        'gr-settings-view'
-      ) as GrSettingsView).reloadAccountDetail();
+      (
+        this.shadowRoot!.querySelector('gr-settings-view') as GrSettingsView
+      ).reloadAccountDetail();
     }
   }
 
@@ -678,9 +544,9 @@
     // The registration dialog is visible only if this.params is
     // instanceof AppElementJustRegisteredParams
     (this.params as AppElementJustRegisteredParams).justRegistered = false;
-    (this.shadowRoot!.querySelector(
-      '#registrationOverlay'
-    ) as GrOverlay).close();
+    (
+      this.shadowRoot!.querySelector('#registrationOverlay') as GrOverlay
+    ).close();
   }
 
   _goToOpenedChanges() {
diff --git a/polygerrit-ui/app/elements/gr-app-element_html.ts b/polygerrit-ui/app/elements/gr-app-element_html.ts
index 81a9988..a1e6ac9 100644
--- a/polygerrit-ui/app/elements/gr-app-element_html.ts
+++ b/polygerrit-ui/app/elements/gr-app-element_html.ts
@@ -131,7 +131,9 @@
         view-state="{{_viewState.dashboardView}}"
       ></gr-dashboard-view>
     </template>
-    <template is="dom-if" if="[[_showChangeView]]" restamp="true">
+    <!-- Note that the change view does not have restamp="true" set, because we
+         want to re-use it as long as the change number does not change. -->
+    <template id="dom-if-change-view" is="dom-if" if="[[_showChangeView]]">
       <gr-change-view
         params="[[params]]"
         view-state="{{_viewState.changeView}}"
@@ -141,7 +143,9 @@
     <template is="dom-if" if="[[_showEditorView]]" restamp="true">
       <gr-editor-view params="[[params]]"></gr-editor-view>
     </template>
-    <template is="dom-if" if="[[_showDiffView]]" restamp="true">
+    <!-- Note that the diff view does not have restamp="true" set, because we
+         want to re-use it as long as the change number does not change. -->
+    <template id="dom-if-diff-view" is="dom-if" if="[[_showDiffView]]">
       <gr-diff-view
         params="[[params]]"
         change-view-state="{{_viewState.changeView}}"
diff --git a/polygerrit-ui/app/elements/gr-app-entry-point.ts b/polygerrit-ui/app/elements/gr-app-entry-point.ts
new file mode 100644
index 0000000..b1f9621
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-entry-point.ts
@@ -0,0 +1,23 @@
+/**
+ * @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.
+ */
+
+// DO NOT EXPORT ANYTHING FROM THIS FILE!
+// The rollupjs re-exports everything from the entry-point file. So, we
+// can't use gr-app.ts as an entry point, because it has some exports.
+// This file is used as an entry point for the whole application; as a result,
+// the generated bundle doesn't contain any exports.
+import './gr-app';
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 85a6d49..de749df 100644
--- a/polygerrit-ui/app/elements/gr-app-global-var-init.ts
+++ b/polygerrit-ui/app/elements/gr-app-global-var-init.ts
@@ -24,15 +24,12 @@
 
 import {GrAnnotation} from './diff/gr-diff-highlight/gr-annotation';
 import {page} from '../utils/page-wrapper-utils';
-import {appContext} from '../services/app-context';
 import {GrPluginActionContext} from './shared/gr-js-api-interface/gr-plugin-action-context';
-import {GerritNav} from './core/gr-navigation/gr-navigation';
+import {initGerritPluginApi} from './shared/gr-js-api-interface/gr-gerrit';
 
 export function initGlobalVariables() {
   window.GrAnnotation = GrAnnotation;
   window.page = page;
   window.GrPluginActionContext = GrPluginActionContext;
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.Nav = GerritNav;
-  window.Gerrit.Auth = appContext.authService;
+  initGerritPluginApi();
 }
diff --git a/polygerrit-ui/app/elements/gr-app-types.ts b/polygerrit-ui/app/elements/gr-app-types.ts
index 2ae85f7..6c8bdb9 100644
--- a/polygerrit-ui/app/elements/gr-app-types.ts
+++ b/polygerrit-ui/app/elements/gr-app-types.ts
@@ -52,20 +52,21 @@
   groupId: GroupId;
 }
 
-export interface AppElementAdminParams {
+export interface ListViewParams {
+  filter?: string | null;
+  offset?: number | string;
+}
+
+export interface AppElementAdminParams extends ListViewParams {
   view: GerritView.ADMIN;
   adminView: string;
-  offset?: string | number;
-  filter?: string | null;
   openCreateModal?: boolean;
 }
 
-export interface AppElementRepoParams {
+export interface AppElementRepoParams extends ListViewParams {
   view: GerritView.REPO;
   detail?: RepoDetailView;
   repo: RepoName;
-  offset?: string | number;
-  filter?: string | null;
 }
 
 export interface AppElementDocSearchParams {
@@ -106,6 +107,16 @@
   leftSide?: boolean;
   commentLink?: boolean;
 }
+
+export interface AppElementDiffEditViewParam {
+  view: GerritView.EDIT;
+  changeNum: NumericChangeId;
+  project: RepoName;
+  path: string;
+  patchNum: RevisionPatchSetNum;
+  lineNum?: number;
+}
+
 export interface AppElementChangeViewParams {
   view: GerritView.CHANGE;
   changeNum: NumericChangeId;
@@ -114,6 +125,7 @@
   patchNum?: RevisionPatchSetNum;
   basePatchNum?: BasePatchSetNum;
   queryMap?: Map<string, string> | URLSearchParams;
+  commentId?: UrlEncodedCommentId;
 }
 
 export interface AppElementJustRegisteredParams {
@@ -138,6 +150,7 @@
   | AppElementSettingsParam
   | AppElementAgreementParam
   | AppElementDiffViewParam
+  | AppElementDiffEditViewParam
   | AppElementJustRegisteredParams;
 
 export function isAppElementJustRegisteredParams(
diff --git a/polygerrit-ui/app/elements/gr-app.ts b/polygerrit-ui/app/elements/gr-app.ts
index 2d3289d..463fab9 100644
--- a/polygerrit-ui/app/elements/gr-app.ts
+++ b/polygerrit-ui/app/elements/gr-app.ts
@@ -45,7 +45,7 @@
 installPolymerResin(safeTypesBridge);
 
 @customElement('gr-app')
-class GrApp extends PolymerElement {
+export class GrApp extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
diff --git a/polygerrit-ui/app/elements/gr-app_test.js b/polygerrit-ui/app/elements/gr-app_test.js
index 8178c89..5a3b1f2 100644
--- a/polygerrit-ui/app/elements/gr-app_test.js
+++ b/polygerrit-ui/app/elements/gr-app_test.js
@@ -28,7 +28,7 @@
   let element;
   let configStub;
 
-  setup(done => {
+  setup(async () => {
     sinon.stub(appContext.reportingService, 'appStarted');
     stub('gr-account-dropdown', '_getTopContent');
     stub('gr-router', 'start');
@@ -45,7 +45,7 @@
     stubRestApi('probePath').returns(Promise.resolve(42));
 
     element = basicFixture.instantiate();
-    flush(done);
+    await flush();
   });
 
   const appElement = () => element.$['app-element'];
diff --git a/polygerrit-ui/app/elements/lit/gr-lit-element.ts b/polygerrit-ui/app/elements/lit/gr-lit-element.ts
deleted file mode 100644
index 9ebadd5..0000000
--- a/polygerrit-ui/app/elements/lit/gr-lit-element.ts
+++ /dev/null
@@ -1,52 +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 {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
deleted file mode 100644
index 9b7b0e2..0000000
--- a/polygerrit-ui/app/elements/lit/gr-lit-element_test.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 '../../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/lit/subscription-controller.ts b/polygerrit-ui/app/elements/lit/subscription-controller.ts
new file mode 100644
index 0000000..ab4ed64
--- /dev/null
+++ b/polygerrit-ui/app/elements/lit/subscription-controller.ts
@@ -0,0 +1,48 @@
+/**
+ * @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 {ReactiveController, ReactiveControllerHost} from 'lit';
+import {Observable, Subscription} from 'rxjs';
+
+/**
+ * Enables components to simply hook up a property with an Observable like so:
+ *
+ * subscribe(this, obs$, x => (this.prop = x));
+ */
+export function subscribe<T>(
+  host: ReactiveControllerHost,
+  obs$: Observable<T>,
+  setProp: (t: T) => void
+) {
+  host.addController(new SubscriptionController(obs$, setProp));
+}
+
+export class SubscriptionController<T> implements ReactiveController {
+  private sub?: Subscription;
+
+  constructor(
+    private readonly obs$: Observable<T>,
+    private readonly setProp: (t: T) => void
+  ) {}
+
+  hostConnected() {
+    this.sub = this.obs$.subscribe(this.setProp);
+  }
+
+  hostDisconnected() {
+    this.sub?.unsubscribe();
+  }
+}
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 ab2ce6a..c3d9e4d 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
@@ -97,7 +97,8 @@
   }
 
   /**
-   * Sets value and dispatches event to force notify.
+   * Sets value of property (not attribute!) and dispatches event to force
+   * notify.
    */
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   set(name: string, value: any) {
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
index 39d3c8b..087779e 100644
--- 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
@@ -19,6 +19,8 @@
   ChecksApiConfig,
   ChecksProvider,
   ChecksPluginApi,
+  CheckResult,
+  CheckRun,
 } from '../../../api/checks';
 import {appContext} from '../../../services/app-context';
 
@@ -54,6 +56,13 @@
     this.checksService.reload(this.plugin.getPluginName());
   }
 
+  updateResult(run: CheckRun, result: CheckResult) {
+    if (result.externalId === undefined) {
+      throw new Error('ChecksApi.updateResult() was called without externalId');
+    }
+    this.checksService.updateResult(this.plugin.getPluginName(), run, result);
+  }
+
   register(provider: ChecksProvider, config?: ChecksApiConfig): void {
     this.reporting.trackApi(this.plugin, 'checks', 'register');
     if (this.state === State.REGISTERED)
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 3e8f0a4..6b8c4f0 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
@@ -16,10 +16,10 @@
  */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {PluginApi} from '../../../api/plugin';
-import {HookApi, HookCallback} from '../../../api/hook';
+import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
 
 export class GrDomHooksManager {
-  private hooks: Record<string, GrDomHook>;
+  private hooks: Record<string, GrDomHook<PluginElement>>;
 
   private plugin: PluginApi;
 
@@ -41,23 +41,33 @@
     }
   }
 
-  getDomHook(endpointName: string, moduleName?: string) {
+  getDomHook<T extends PluginElement>(
+    endpointName: string,
+    moduleName?: string
+  ): HookApi<T> {
     const hookName = this._getHookName(endpointName, moduleName);
     if (!this.hooks[hookName]) {
-      this.hooks[hookName] = new GrDomHook(hookName, moduleName);
+      this.hooks[hookName] = new GrDomHook<T>(
+        hookName,
+        moduleName
+      ) as unknown as GrDomHook<PluginElement>;
     }
-    return this.hooks[hookName];
+    return this.hooks[hookName] as unknown as GrDomHook<T>;
   }
 }
 
-export class GrDomHook implements HookApi {
+export class GrDomHook<T extends PluginElement> implements HookApi<T> {
   private instances: HTMLElement[] = [];
 
-  private attachCallbacks: HookCallback[] = [];
+  private attachCallbacks: HookCallback<T>[] = [];
 
-  private detachCallbacks: HookCallback[] = [];
+  private detachCallbacks: HookCallback<T>[] = [];
 
-  private moduleName: string;
+  /**
+   * The name of the (custom) element that is going to be created. Matches the T
+   * type parameter.
+   */
+  private readonly moduleName: string;
 
   private lastAttachedPromise: Promise<HTMLElement> | null = null;
 
@@ -87,7 +97,7 @@
     customElements.define(HookPlaceholder.is, HookPlaceholder);
   }
 
-  handleInstanceDetached(instance: HTMLElement) {
+  handleInstanceDetached(instance: T) {
     const index = this.instances.indexOf(instance);
     if (index !== -1) {
       this.instances.splice(index, 1);
@@ -95,7 +105,7 @@
     this.detachCallbacks.forEach(callback => callback(instance));
   }
 
-  handleInstanceAttached(instance: HTMLElement) {
+  handleInstanceAttached(instance: T) {
     this.instances.push(instance);
     this.attachCallbacks.forEach(callback => callback(instance));
   }
@@ -109,7 +119,7 @@
       return Promise.resolve(this.instances.slice(-1)[0]);
     }
     if (!this.lastAttachedPromise) {
-      let resolve: HookCallback;
+      let resolve: HookCallback<T>;
       const promise = new Promise<HTMLElement>(r => {
         resolve = r;
         this.attachCallbacks.push(resolve);
@@ -137,7 +147,7 @@
    * Install a new callback to invoke when a new instance of DOM hook element
    * is attached.
    */
-  onAttached(callback: HookCallback) {
+  onAttached(callback: HookCallback<T>) {
     this.attachCallbacks.push(callback);
     return this;
   }
@@ -147,7 +157,7 @@
    * is detached.
    *
    */
-  onDetached(callback: HookCallback) {
+  onDetached(callback: HookCallback<T>) {
     this.detachCallbacks.push(callback);
     return this;
   }
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 0e8cbcb..00fa77d 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
@@ -23,7 +23,7 @@
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {customElement, property} from '@polymer/decorators';
 import {PluginApi} from '../../../api/plugin';
-import {HookApi} from '../../../api/hook';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 const INIT_PROPERTIES_TIMEOUT_MS = 10000;
 
@@ -33,11 +33,20 @@
     return htmlTemplate;
   }
 
+  /**
+   * If set, then this endpoint only invokes callbacks registered by the target
+   * plugin. For example this is used for the `check-result-expanded` endpoint.
+   * In that case Gerrit knows which plugin has provided the check result, and
+   * only that plugin has an interest to hook into the endpoint.
+   */
+  @property({type: String})
+  targetPlugin?: string;
+
   @property({type: String})
   name!: string;
 
   @property({type: Object})
-  _domHooks = new Map<HTMLElement, HookApi>();
+  _domHooks = new Map<PluginElement, HookApi<PluginElement>>();
 
   @property({type: Object})
   _initializedPlugins = new Map<string, boolean>();
@@ -49,8 +58,7 @@
    */
   _endpointCallBack: (info: ModuleInfo) => void = () => {};
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     for (const [el, domHook] of this._domHooks) {
       domHook.handleInstanceDetached(el);
     }
@@ -63,7 +71,7 @@
     plugin: PluginApi,
     slot?: string
   ): Promise<HTMLElement> {
-    const el = document.createElement(name);
+    const el = document.createElement(name) as PluginElement;
     return this._initProperties(
       el,
       plugin,
@@ -105,33 +113,31 @@
   }
 
   _initProperties(
-    htmlEl: HTMLElement,
+    el: PluginElement,
     plugin: PluginApi,
     content?: Element | null
   ) {
-    const el = htmlEl as HTMLElement & {
-      plugin?: PluginApi;
-      content?: Element;
-    };
     el.plugin = plugin;
     // The content is (only?) used in ChangeReplyPluginApi.
     // Maybe it would be better for the consumer side to figure out the content
     // with something like el.getRootNode().host, etc.
+    // Also note that the content element could easily end up being an instance
+    // of <gr-endpoint-param>.
     if (content) {
-      el.content = content;
+      el.content = content as HTMLElement;
     }
     const expectProperties = this._getEndpointParams().map(paramEl => {
       const helper = plugin.attributeHelper(paramEl);
       // TODO: this should be replaced by accessing the property directly
       const paramName = paramEl.getAttribute('name');
       if (!paramName) throw Error('plugin endpoint parameter missing a name');
-      return helper
-        .get('value')
-        .then(() =>
-          helper.bind('value', value =>
-            plugin.attributeHelper(el).set(paramName, value)
-          )
-        );
+      return helper.get('value').then(() =>
+        helper.bind('value', value =>
+          // Note that despite the naming this sets the property, not the
+          // attribute. :-)
+          plugin.attributeHelper(el).set(paramName, value)
+        )
+      );
     });
     let timeoutId: number;
     const timeout = new Promise(
@@ -160,6 +166,9 @@
 
   _initModule({moduleName, plugin, type, domHook, slot}: ModuleInfo) {
     const name = plugin.getPluginName() + '.' + moduleName;
+    if (this.targetPlugin) {
+      if (this.targetPlugin !== plugin.getPluginName()) return;
+    }
     if (this._initializedPlugins.get(name)) {
       return;
     }
@@ -184,20 +193,18 @@
     });
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
+    if (!this.name) return;
     this._endpointCallBack = (info: ModuleInfo) => this._initModule(info);
     getPluginEndpoints().onNewEndpoint(this.name, this._endpointCallBack);
-    if (this.name) {
-      getPluginLoader()
-        .awaitPluginsLoaded()
-        .then(() =>
-          getPluginEndpoints()
-            .getDetails(this.name)
-            .forEach(this._initModule, this)
-        );
-    }
+    getPluginLoader()
+      .awaitPluginsLoaded()
+      .then(() =>
+        getPluginEndpoints()
+          .getDetails(this.name)
+          .forEach(this._initModule, this)
+      );
   }
 }
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
index 8138ff0..1be5e82 100644
--- a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.js
@@ -51,7 +51,7 @@
   let decorationHookWithSlot;
   let replacementHook;
 
-  setup(done => {
+  setup(async () => {
     resetPlugins();
     container = basicFixture.instantiate();
     pluginApi.install(p => plugin = p, '0.1',
@@ -68,7 +68,7 @@
         'second', 'other-module', {replace: true});
     // Mimic all plugins loaded.
     getPluginLoader().loadPlugins([]);
-    flush(done);
+    await flush();
   });
 
   teardown(() => {
@@ -135,58 +135,52 @@
         });
   });
 
-  test('late registration', done => {
+  test('late registration', async () => {
     plugin.registerCustomComponent('banana', 'noob-noob');
-    flush(() => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const module = Array.from(element.root.children).find(
-          element => element.nodeName === 'NOOB-NOOB');
-      assert.isOk(module);
-      done();
-    });
+    await flush();
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const module = Array.from(element.root.children).find(
+        element => element.nodeName === 'NOOB-NOOB');
+    assert.isOk(module);
   });
 
-  test('two modules', done => {
+  test('two modules', async () => {
     plugin.registerCustomComponent('banana', 'mod-one');
     plugin.registerCustomComponent('banana', 'mod-two');
-    flush(() => {
-      const element =
-          container.querySelector('gr-endpoint-decorator[name="banana"]');
-      const module1 = Array.from(element.root.children).find(
-          element => element.nodeName === 'MOD-ONE');
-      assert.isOk(module1);
-      const module2 = Array.from(element.root.children).find(
-          element => element.nodeName === 'MOD-TWO');
-      assert.isOk(module2);
-      done();
-    });
+    await flush();
+    const element =
+        container.querySelector('gr-endpoint-decorator[name="banana"]');
+    const module1 = Array.from(element.root.children).find(
+        element => element.nodeName === 'MOD-ONE');
+    assert.isOk(module1);
+    const module2 = Array.from(element.root.children).find(
+        element => element.nodeName === 'MOD-TWO');
+    assert.isOk(module2);
   });
 
-  test('late param setup', done => {
+  test('late param setup', async () => {
     const element =
         container.querySelector('gr-endpoint-decorator[name="banana"]');
     const param = element.querySelector('gr-endpoint-param');
     param['value'] = undefined;
     plugin.registerCustomComponent('banana', 'noob-noob');
-    flush(() => {
-      let module = Array.from(element.root.children).find(
-          element => element.nodeName === 'NOOB-NOOB');
-      // Module waits for param to be defined.
-      assert.isNotOk(module);
-      const value = {abc: 'def'};
-      param.value = value;
-      flush(() => {
-        module = Array.from(element.root.children).find(
-            element => element.nodeName === 'NOOB-NOOB');
-        assert.isOk(module);
-        assert.strictEqual(module['someParam'], value);
-        done();
-      });
-    });
+    await flush();
+    let module = Array.from(element.root.children).find(
+        element => element.nodeName === 'NOOB-NOOB');
+    // Module waits for param to be defined.
+    assert.isNotOk(module);
+    const value = {abc: 'def'};
+    param.value = value;
+
+    await flush();
+    module = Array.from(element.root.children).find(
+        element => element.nodeName === 'NOOB-NOOB');
+    assert.isOk(module);
+    assert.strictEqual(module['someParam'], value);
   });
 
-  test('param is bound', done => {
+  test('param is bound', async () => {
     const element =
         container.querySelector('gr-endpoint-decorator[name="banana"]');
     const param = element.querySelector('gr-endpoint-param');
@@ -194,13 +188,11 @@
     const value2 = {def: 'abc'};
     param.value = value1;
     plugin.registerCustomComponent('banana', 'noob-noob');
-    flush(() => {
-      const module = Array.from(element.root.children).find(
-          element => element.nodeName === 'NOOB-NOOB');
-      assert.strictEqual(module['someParam'], value1);
-      param.value = value2;
-      assert.strictEqual(module['someParam'], value2);
-      done();
-    });
+    await flush();
+    const module = Array.from(element.root.children).find(
+        element => element.nodeName === 'NOOB-NOOB');
+    assert.strictEqual(module['someParam'], value1);
+    param.value = value2;
+    assert.strictEqual(module['someParam'], value2);
   });
 });
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 0c6dd4d..0c36cd5 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
@@ -57,7 +57,7 @@
         try {
           mayContinue = callback(e);
         } catch (exception) {
-          console.warn(`Plugin error handing event: ${exception}`);
+          this.reporting.error(exception);
         }
         if (mayContinue === false) {
           e.stopImmediatePropagation();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
index 6291e64..4e3d657 100644
--- a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.js
@@ -19,6 +19,7 @@
 import {addListener} from '@polymer/polymer/lib/utils/gestures.js';
 import {Polymer} from '@polymer/polymer/lib/legacy/polymer-fn.js';
 import {_testOnly_initGerritPluginApi} from '../../shared/gr-js-api-interface/gr-gerrit.js';
+import {mockPromise} from '../../../test/test-utils.js';
 
 Polymer({
   is: 'gr-event-helper-some-element',
@@ -47,11 +48,13 @@
     instance = plugin.eventHelper(element);
   });
 
-  test('onTap()', done => {
+  test('onTap()', async () => {
+    const promise = mockPromise();
     instance.onTap(() => {
-      done();
+      promise.resolve();
     });
     MockInteractions.tap(element);
+    await promise;
   });
 
   test('onTap() cancel', () => {
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 007e4c2..88964bf 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
@@ -22,7 +22,7 @@
 import {customElement, property} from '@polymer/decorators';
 
 @customElement('gr-external-style')
-class GrExternalStyle extends PolymerElement {
+export class GrExternalStyle extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -58,14 +58,12 @@
     }
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._importAndApply();
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     getPluginLoader()
       .awaitPluginsLoaded()
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 ac493a2..7fee4a0 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,22 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {getPluginLoader} from '../../shared/gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
 
 @customElement('gr-plugin-host')
-class GrPluginHost extends PolymerElement {
-  @property({type: Object, observer: '_configChanged'})
+export class GrPluginHost extends LitElement {
+  @property({type: Object})
   config?: ServerInfo;
 
   _configChanged(config: ServerInfo) {
     const plugins = config.plugin;
     const jsPlugins = (plugins && plugins.js_resource_paths) || [];
-    const shouldLoadTheme =
-      !!config.default_theme &&
-      !getPluginLoader().isPluginPreloaded('preloaded:gerrit-theme');
+    const shouldLoadTheme = !!config.default_theme;
     // config.default_theme is defined when shouldLoadTheme is true
     const themeToLoad: string[] = shouldLoadTheme
       ? [config.default_theme!]
@@ -38,6 +36,12 @@
     const pluginsPending = themeToLoad.concat(jsPlugins);
     getPluginLoader().loadPlugins(pluginsPending);
   }
+
+  override updated(changedProperties: PropertyValues<GrPluginHost>) {
+    if (changedProperties.has('config') && this.config) {
+      this._configChanged(this.config);
+    }
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
index 3d35aa4..f555103 100644
--- a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.js
@@ -30,20 +30,21 @@
     sinon.stub(document.body, 'appendChild');
   });
 
-  test('load plugins should be called', () => {
+  test('load plugins should be called', async () => {
     sinon.stub(getPluginLoader(), 'loadPlugins');
     element.config = {
       plugin: {
         js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
       },
     };
+    await flush();
     assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
     assert.isTrue(getPluginLoader().loadPlugins.calledWith([
       'plugins/42', 'plugins/foo/bar', 'plugins/baz',
     ]));
   });
 
-  test('theme plugins should be loaded if enabled', () => {
+  test('theme plugins should be loaded if enabled', async () => {
     sinon.stub(getPluginLoader(), 'loadPlugins');
     element.config = {
       default_theme: 'gerrit-theme.js',
@@ -51,23 +52,11 @@
         js_resource_paths: ['plugins/42', 'plugins/foo/bar', 'plugins/baz'],
       },
     };
+    await flush();
     assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
     assert.isTrue(getPluginLoader().loadPlugins.calledWith([
       'gerrit-theme.js', 'plugins/42', 'plugins/foo/bar', 'plugins/baz',
     ]));
   });
-
-  test('skip theme if preloaded', () => {
-    sinon.stub(getPluginLoader(), 'isPluginPreloaded')
-        .withArgs('preloaded:gerrit-theme')
-        .returns(true);
-    sinon.stub(getPluginLoader(), 'loadPlugins');
-    element.config = {
-      default_theme: '/oof',
-      plugin: {},
-    };
-    assert.isTrue(getPluginLoader().loadPlugins.calledOnce);
-    assert.isTrue(getPluginLoader().loadPlugins.calledWith([]));
-  });
 });
 
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
similarity index 64%
rename from polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js
rename to polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
index 6ce77b1..342cf83 100644
--- a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.js
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.ts
@@ -15,34 +15,34 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-plugin-popup.js';
+import '../../../test/common-test-setup-karma';
+import './gr-plugin-popup';
+import {GrPluginPopup} from './gr-plugin-popup';
 
 const basicFixture = fixtureFromElement('gr-plugin-popup');
 
 suite('gr-plugin-popup tests', () => {
-  let element;
+  let element: GrPluginPopup;
+  let overlayOpen: sinon.SinonStub;
+  let overlayClose: sinon.SinonStub;
 
   setup(() => {
     element = basicFixture.instantiate();
-    stub('gr-overlay', 'open').callsFake(() => Promise.resolve());
-    stub('gr-overlay', 'close');
+    overlayOpen = stub('gr-overlay', 'open').callsFake(() => Promise.resolve());
+    overlayClose = stub('gr-overlay', 'close');
   });
 
   test('exists', () => {
     assert.isOk(element);
   });
 
-  test('open uses open() from gr-overlay', done => {
-    element.open().then(() => {
-      assert.isTrue(element.$.overlay.open.called);
-      done();
-    });
+  test('open uses open() from gr-overlay', async () => {
+    await element.open();
+    assert.isTrue(overlayOpen.called);
   });
 
   test('close uses close() from gr-overlay', () => {
     element.close();
-    assert.isTrue(element.$.overlay.close.called);
+    assert.isTrue(overlayClose.called);
   });
 });
-
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 13d18b5..45a93bf 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
@@ -48,7 +48,12 @@
   _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;
+  }
+
+  appendContent(el: HTMLElement) {
+    if (!this.popup) throw new Error('popup element not (yet) available');
+    this.popup.appendChild(el);
   }
 
   /**
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 8a7788f..2889333 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
@@ -56,27 +56,23 @@
       instance = new GrPopupInterface(plugin);
     });
 
-    test('open', done => {
-      instance.open().then(api => {
-        assert.strictEqual(api, instance);
-        const manual = document.createElement('div');
-        manual.id = 'foobar';
-        manual.innerHTML = 'manual content';
-        api._getElement().appendChild(manual);
-        flush();
-        assert.equal(
-            container.querySelector('#foobar').textContent, 'manual content');
-        done();
-      });
+    test('open', async () => {
+      const api = await instance.open();
+      assert.strictEqual(api, instance);
+      const manual = document.createElement('div');
+      manual.id = 'foobar';
+      manual.innerHTML = 'manual content';
+      api._getElement().appendChild(manual);
+      await flush();
+      assert.equal(
+          container.querySelector('#foobar').textContent, 'manual content');
     });
 
-    test('close', done => {
-      instance.open().then(api => {
-        assert.isTrue(api._getElement().node.opened);
-        api.close();
-        assert.isFalse(api._getElement().node.opened);
-        done();
-      });
+    test('close', async () => {
+      const api = await instance.open();
+      assert.isTrue(api._getElement().node.opened);
+      api.close();
+      assert.isFalse(api._getElement().node.opened);
     });
   });
 
@@ -85,21 +81,16 @@
       instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
     });
 
-    test('open', done => {
-      instance.open().then(api => {
-        assert.isNotNull(
-            container.querySelector('gr-user-test-popup'));
-        done();
-      });
+    test('open', async () => {
+      await instance.open();
+      assert.isNotNull(container.querySelector('gr-user-test-popup'));
     });
 
-    test('close', done => {
-      instance.open().then(api => {
-        assert.isTrue(api._getElement().node.opened);
-        api.close();
-        assert.isFalse(api._getElement().node.opened);
-        done();
-      });
+    test('close', async () => {
+      const api = await instance.open();
+      assert.isTrue(api._getElement().node.opened);
+      api.close();
+      assert.isFalse(api._getElement().node.opened);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
index 7d82ff4..6580ad6 100644
--- a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.ts
@@ -14,6 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+/* eslint-disable lit/no-legacy-template-syntax,lit/prefer-static-styles */
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 import {customElement, property} from '@polymer/decorators';
@@ -30,7 +31,7 @@
   logoUrl = '';
 
   @property({type: String})
-  title = '';
+  override title = '';
 
   static get template() {
     return html`
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 d278d22..bd6835c 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
@@ -22,7 +22,7 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-info_html';
 import {customElement, property, observe} from '@polymer/decorators';
-import {AccountInfo, ServerInfo} from '../../../types/common';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {EditableAccountField} from '../../../constants/constants';
 import {appContext} from '../../../services/app-context';
 import {fireEvent} from '../../../utils/event-util';
@@ -81,7 +81,7 @@
   _saving = false;
 
   @property({type: Object})
-  _account?: AccountInfo;
+  _account?: AccountDetailInfo;
 
   @property({type: Object})
   _serverConfig?: ServerInfo;
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 6ca484d..51259c8 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
@@ -35,7 +35,7 @@
     <section>
       <span class="title"></span>
       <span class="value">
-        <gr-avatar account="[[_account]]" image-size="120"></gr-avatar>
+        <gr-avatar account="[[_account]]" imageSize="120"></gr-avatar>
       </span>
     </section>
     <section class$="[[_hideAvatarChangeUrl(_avatarChangeUrl)]]">
@@ -56,7 +56,7 @@
       <span class="title">Registered</span>
       <span class="value">
         <gr-date-formatter
-          has-tooltip=""
+          withTooltip
           date-str="[[_account.registered_on]]"
         ></gr-date-formatter>
       </span>
@@ -71,7 +71,6 @@
           id="usernameIronInput"
         >
           <input
-            is="iron-input"
             id="usernameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
@@ -89,7 +88,6 @@
           id="nameIronInput"
         >
           <input
-            is="iron-input"
             id="nameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
@@ -105,7 +103,6 @@
           bind-value="{{_account.display_name}}"
         >
           <input
-            is="iron-input"
             id="displayNameInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
@@ -121,7 +118,6 @@
           bind-value="{{_account.status}}"
         >
           <input
-            is="iron-input"
             id="statusInput"
             disabled="[[_saving]]"
             on-keydown="_handleKeydown"
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
index 6aa7f7c..f1813a4 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.ts
@@ -21,12 +21,13 @@
 import {GrAccountInfo} from './gr-account-info';
 import {AccountDetailInfo, ServerInfo} from '../../../types/common';
 import {
+  createAccountDetailWithId,
   createAccountWithIdNameAndEmail,
   createPreferences,
   createServerInfo,
 } from '../../../test/test-data-generators';
 import {IronInputElement} from '@polymer/iron-input';
-import {SinonStubbedMember} from 'sinon/pkg/sinon-esm';
+import {SinonStubbedMember} from 'sinon';
 import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api';
 
 const basicFixture = fixtureFromElement('gr-account-info');
@@ -156,7 +157,7 @@
       statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
     });
 
-    test('name', done => {
+    test('name', async () => {
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
@@ -166,18 +167,15 @@
       assert.isFalse(statusChangedSpy.called);
       assert.isTrue(element.hasUnsavedChanges);
 
-      element.save().then(() => {
-        assert.isFalse(usernameStub.called);
-        assert.isTrue(nameStub.called);
-        assert.isFalse(statusStub.called);
-        nameStub.lastCall.returnValue.then(() => {
-          assert.equal(nameStub.lastCall.args[0], 'new name');
-          done();
-        });
-      });
+      await element.save();
+      assert.isFalse(usernameStub.called);
+      assert.isTrue(nameStub.called);
+      assert.isFalse(statusStub.called);
+      await nameStub.lastCall.returnValue;
+      assert.equal(nameStub.lastCall.args[0], 'new name');
     });
 
-    test('username', done => {
+    test('username', async () => {
       element.set('_account.username', '');
       element._hasUsernameChange = false;
       assert.isTrue(element.usernameMutable);
@@ -188,18 +186,15 @@
       assert.isFalse(statusChangedSpy.called);
       assert.isTrue(element.hasUnsavedChanges);
 
-      element.save().then(() => {
-        assert.isTrue(usernameStub.called);
-        assert.isFalse(nameStub.called);
-        assert.isFalse(statusStub.called);
-        usernameStub.lastCall.returnValue.then(() => {
-          assert.equal(usernameStub.lastCall.args[0], 'new username');
-          done();
-        });
-      });
+      await element.save();
+      assert.isTrue(usernameStub.called);
+      assert.isFalse(nameStub.called);
+      assert.isFalse(statusStub.called);
+      await usernameStub.lastCall.returnValue;
+      assert.equal(usernameStub.lastCall.args[0], 'new username');
     });
 
-    test('status', done => {
+    test('status', async () => {
       assert.isFalse(element.hasUnsavedChanges);
 
       element.set('_account.status', 'new status');
@@ -208,15 +203,12 @@
       assert.isTrue(statusChangedSpy.called);
       assert.isTrue(element.hasUnsavedChanges);
 
-      element.save().then(() => {
-        assert.isFalse(usernameStub.called);
-        assert.isTrue(statusStub.called);
-        assert.isFalse(nameStub.called);
-        statusStub.lastCall.returnValue.then(() => {
-          assert.equal(statusStub.lastCall.args[0], 'new status');
-          done();
-        });
-      });
+      await element.save();
+      assert.isFalse(usernameStub.called);
+      assert.isTrue(statusStub.called);
+      assert.isFalse(nameStub.called);
+      await statusStub.lastCall.returnValue;
+      assert.equal(statusStub.lastCall.args[0], 'new status');
     });
   });
 
@@ -238,7 +230,7 @@
       stubRestApi('setAccountUsername').returns(Promise.resolve());
     });
 
-    test('set name and status', done => {
+    test('set name and status', async () => {
       assert.isTrue(element.nameMutable);
       assert.isFalse(element.hasUnsavedChanges);
 
@@ -252,16 +244,13 @@
 
       assert.isTrue(element.hasUnsavedChanges);
 
-      element.save().then(() => {
-        assert.isTrue(statusStub.called);
-        assert.isTrue(nameStub.called);
+      await element.save();
+      assert.isTrue(statusStub.called);
+      assert.isTrue(nameStub.called);
 
-        assert.equal(nameStub.lastCall.args[0], 'new name');
+      assert.equal(nameStub.lastCall.args[0], 'new name');
 
-        assert.equal(statusStub.lastCall.args[0], 'new status');
-
-        done();
-      });
+      assert.equal(statusStub.lastCall.args[0], 'new status');
     });
   });
 
@@ -276,7 +265,7 @@
       statusStub = stubRestApi('setAccountStatus').returns(Promise.resolve());
     });
 
-    test('read full name but set status', done => {
+    test('read full name but set status', async () => {
       const section = element.$.nameSection;
       const displaySpan = section.querySelectorAll('.value')[0];
       const inputSpan = section.querySelectorAll('.value')[1];
@@ -295,18 +284,15 @@
 
       assert.isTrue(element.hasUnsavedChanges);
 
-      element.save().then(() => {
-        assert.isTrue(statusStub.called);
-        statusStub.lastCall.returnValue.then(() => {
-          assert.equal(statusStub.lastCall.args[0], 'new status');
-          done();
-        });
-      });
+      await element.save();
+      assert.isTrue(statusStub.called);
+      await statusStub.lastCall.returnValue;
+      assert.equal(statusStub.lastCall.args[0], 'new status');
     });
   });
 
   test('_usernameChanged compares usernames with loose equality', () => {
-    element._account = {};
+    element._account = createAccountDetailWithId();
     element._username = '';
     element._hasUsernameChange = false;
     element._loading = false;
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 c1cff72..a972db3 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
@@ -15,28 +15,22 @@
  * limitations under the License.
  */
 
-import '../../../styles/gr-form-styles';
-import '../../../styles/shared-styles';
-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 {ContributorAgreementInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 @customElement('gr-agreements-list')
-export class GrAgreementsList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAgreementsList extends LitElement {
   @property({type: Array})
   _agreements?: ContributorAgreementInfo[];
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.loadData();
   }
@@ -47,6 +41,55 @@
     });
   }
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        #agreements .nameColumn {
+          min-width: 15em;
+          width: auto;
+        }
+        #agreements .descriptionColumn {
+          width: auto;
+        }
+      `,
+      formStyles,
+    ];
+  }
+
+  renderAgreement(agreement: ContributorAgreementInfo) {
+    if (!agreement) return;
+    return html`
+      <tr>
+        <td class="nameColumn">
+          <a href="${this.getUrlBase(agreement.url)}" rel="external">
+            ${agreement.name}
+          </a>
+        </td>
+        <td class="descriptionColumn">${agreement.description}</td>
+      </tr>
+    `;
+  }
+
+  override render() {
+    return html` <div class="gr-form-styles">
+      <table id="agreements">
+        <thead>
+          <tr>
+            <th class="nameColumn">Name</th>
+            <th class="descriptionColumn">Description</th>
+          </tr>
+        </thead>
+        <tbody>
+          ${(this._agreements ?? []).map(agreement =>
+            this.renderAgreement(agreement)
+          )}
+        </tbody>
+      </table>
+      <a href="${this.getUrl()}">New Contributor Agreement</a>
+    </div>`;
+  }
+
   getUrl() {
     return `${getBaseUrl()}/settings/new-agreement`;
   }
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
deleted file mode 100644
index 1afac0d..0000000
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_html.ts
+++ /dev/null
@@ -1,55 +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">
-    #agreements .nameColumn {
-      min-width: 15em;
-      width: auto;
-    }
-    #agreements .descriptionColumn {
-      width: auto;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <table id="agreements">
-      <thead>
-        <tr>
-          <th class="nameColumn">Name</th>
-          <th class="descriptionColumn">Description</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_agreements]]">
-          <tr>
-            <td class="nameColumn">
-              <a href$="[[getUrlBase(item.url)]]" rel="external">
-                [[item.name]]
-              </a>
-            </td>
-            <td class="descriptionColumn">[[item.description]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-    <a href$="[[getUrl()]]">New Contributor Agreement</a>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
index f08976f..f3eeae8 100644
--- a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.ts
@@ -16,7 +16,7 @@
  */
 import '../../../test/common-test-setup-karma';
 import './gr-agreements-list';
-import {stubRestApi} from '../../../test/test-utils';
+import {queryAll, stubRestApi} from '../../../test/test-utils';
 import {GrAgreementsList} from './gr-agreements-list';
 import {ContributorAgreementInfo} from '../../../types/common';
 
@@ -25,7 +25,7 @@
 suite('gr-agreements-list tests', () => {
   let element: GrAgreementsList;
 
-  setup(done => {
+  setup(async () => {
     const agreements: ContributorAgreementInfo[] = [
       {
         url: 'some url',
@@ -38,17 +38,16 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => {
-      flush(done);
-    });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
-    const rows = element.root?.querySelectorAll('tbody tr') ?? [];
+    const rows = queryAll<HTMLTableRowElement>(element, 'tbody tr') ?? [];
     assert.equal(rows.length, 1);
 
     const nameCells = Array.from(rows).map(row =>
-      row.querySelectorAll('td')[0].textContent?.trim()
+      queryAll<HTMLTableElement>(row, 'td')[0].textContent?.trim()
     );
 
     assert.equal(nameCells[0], 'Agreements 1');
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 d13b2a9..96b1ded 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
@@ -15,19 +15,18 @@
  * limitations under the License.
  */
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 import '../../../styles/shared-styles';
 import '../../../styles/gr-form-styles';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 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, observe} from '@polymer/decorators';
 import {ServerInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
 
 @customElement('gr-change-table-editor')
-export class GrChangeTableEditor extends ChangeTableMixin(PolymerElement) {
+export class GrChangeTableEditor extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -44,34 +43,60 @@
   @property({type: Array})
   defaultColumns: string[] = [];
 
-  flagsService = appContext.flagsService;
+  private readonly flagsService = appContext.flagsService;
 
   @observe('serverConfig')
   _configChanged(config: ServerInfo) {
-    this.defaultColumns = this.getEnabledColumns(
-      this.columnNames,
-      config,
-      this.flagsService.enabledExperiments
+    this.defaultColumns = columnNames.filter(col =>
+      this._isColumnEnabled(col, config, this.flagsService.enabledExperiments)
     );
     if (!this.displayedColumns) return;
     this.displayedColumns = this.displayedColumns.filter(column =>
-      this.isColumnEnabled(column, config, this.flagsService.enabledExperiments)
+      this._isColumnEnabled(
+        column,
+        config,
+        this.flagsService.enabledExperiments
+      )
     );
   }
 
   /**
+   * Is the column disabled by a server config or experiment? For example the
+   * assignee feature might be disabled and thus the corresponding column is
+   * also disabled.
+   *
+   */
+  _isColumnEnabled(column: string, config: ServerInfo, experiments: string[]) {
+    if (!config || !config.change) return true;
+    if (column === 'Assignee') return !!config.change.enable_assignee;
+    if (column === 'Comments') return experiments.includes('comments-column');
+    return true;
+  }
+
+  /**
    * Get the list of enabled column names from whichever checkboxes are
    * checked (excluding the number checkbox).
    */
   _getDisplayedColumns() {
     if (this.root === null) return [];
-    return (Array.from(
-      this.root.querySelectorAll('.checkboxContainer input:not([name=number])')
-    ) as HTMLInputElement[])
+    return (
+      Array.from(
+        this.root.querySelectorAll(
+          '.checkboxContainer input:not([name=number])'
+        )
+      ) as HTMLInputElement[]
+    )
       .filter(checkbox => checkbox.checked)
       .map(checkbox => checkbox.name);
   }
 
+  _computeIsColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
+    if (!columnsToDisplay || !columnToCheck) {
+      return false;
+    }
+    return !columnsToDisplay.includes(columnToCheck);
+  }
+
   /**
    * Handle a click on a checkbox container and relay the click to the checkbox it
    * contains.
@@ -90,8 +115,9 @@
    * accordingly.
    */
   _handleNumberCheckboxClick(e: MouseEvent) {
-    this.showNumber = ((dom(e) as EventApi)
-      .rootTarget as HTMLInputElement).checked;
+    this.showNumber = (
+      (dom(e) as EventApi).rootTarget as HTMLInputElement
+    ).checked;
   }
 
   /**
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 a05ec73..e756a20 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
@@ -74,7 +74,7 @@
                 type="checkbox"
                 name="[[item]]"
                 on-click="_handleTargetClick"
-                checked$="[[!isColumnHidden(item, displayedColumns)]]"
+                checked$="[[!_computeIsColumnHidden(item, displayedColumns)]]"
               />
             </td>
           </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
index 4f61972..4f8d0a0 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.ts
@@ -117,7 +117,7 @@
 
   test('_getDisplayedColumns', () => {
     const enabledColumns = columns.filter(column =>
-      element.isColumnEnabled(column, element.serverConfig!, [])
+      element._isColumnEnabled(column, element.serverConfig!, [])
     );
     assert.deepEqual(element._getDisplayedColumns(), enabledColumns);
     const input = queryAndAssert<HTMLInputElement>(
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 b0b7ab8..5b757e6 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
@@ -16,6 +16,7 @@
  */
 
 import '@polymer/iron-input/iron-input';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/shared-styles';
 import '../../shared/gr-button/gr-button';
@@ -66,8 +67,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.loadData();
 
@@ -168,7 +168,7 @@
 
   _hideAgreements(
     item: ContributorAgreementInfo,
-    groups: GroupInfo[],
+    groups?: GroupInfo[],
     signedAgreements?: ContributorAgreementInfo[]
   ) {
     return this._disableAgreements(item, groups, signedAgreements)
@@ -176,18 +176,18 @@
       : 'hide';
   }
 
-  _disableAgreementsText(text: string) {
-    return text.toLowerCase() === 'i agree' ? false : true;
+  _disableAgreementsText(text?: string) {
+    return text?.toLowerCase() === 'i agree' ? false : true;
   }
 
   // This checks for auto_verify_group,
   // if specified it returns 'hideAgreementsTextBox' which
   // then hides the text box and submit button.
   _computeHideAgreementClass(
-    name: string,
+    name?: string,
     contributorAgreements?: ContributorAgreementInfo[]
   ) {
-    if (!contributorAgreements) return '';
+    if (!name || !contributorAgreements) return '';
     return contributorAgreements.some(
       (contributorAgreement: ContributorAgreementInfo) =>
         name === contributorAgreement.name &&
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 887382e..ce95ccb 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     h1 {
       margin-bottom: var(--spacing-m);
@@ -104,12 +107,7 @@
           bind-value="{{_agreementsText}}"
           placeholder="Enter 'I agree' here"
         >
-          <input
-            id="input-agreements"
-            is="iron-input"
-            bind-value="{{_agreementsText}}"
-            placeholder="Enter 'I agree' here"
-          />
+          <input id="input-agreements" placeholder="Enter 'I agree' here" />
         </iron-input>
         <gr-button
           on-click="_handleSaveAgreements"
diff --git a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
index 9f54cd1..979f859 100644
--- a/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-cla-view/gr-cla-view_test.ts
@@ -48,7 +48,7 @@
       options: {
         visible_to_all: true,
       },
-      group_id: '20',
+      group_id: 20,
       owner: 'CLA Accepted - Individual',
       owner_id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0',
       created_on: '2017-07-31 15:11:04.000000000',
@@ -66,7 +66,7 @@
       options: {
         visible_to_all: false,
       },
-      group_id: '21',
+      group_id: 21,
       owner: 'CLA Accepted - Individual2',
       owner_id: 'bc53f2738ef8ad0b3a4f53846ff59b05822caecb',
       created_on: '2017-07-31 15:25:42.000000000',
@@ -120,21 +120,20 @@
     {
       options: {visible_to_all: true},
       id: 'e9aaddc47f305be7661ad4db9b66f9b707bd19a0' as GroupId,
-      group_id: '3',
+      group_id: 3,
       name: 'CLA Accepted - Individual' as GroupName,
     },
   ];
 
-  setup(done => {
+  setup(async () => {
     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);
-    });
+    await element.loadData();
+    await flush();
   });
 
   test('renders as expected with signed agreement', () => {
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 6be82e0..7cfd1b3 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
@@ -26,6 +26,9 @@
 
 export interface GrEditPreferences {
   $: {
+    editTabWidth: HTMLInputElement;
+    editColumns: HTMLInputElement;
+    editIndentUnit: HTMLInputElement;
     editSyntaxHighlighting: HTMLInputElement;
     showAutoCloseBrackets: HTMLInputElement;
     showIndentWithTabs: HTMLInputElement;
@@ -59,6 +62,21 @@
     this.hasUnsavedChanges = true;
   }
 
+  _handleEditTabWidthChanged() {
+    this.set('editPrefs.tab_size', Number(this.$.editTabWidth.value));
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditLineLengthChanged() {
+    this.set('editPrefs.line_length', Number(this.$.editColumns.value));
+    this._handleEditPrefsChanged();
+  }
+
+  _handleEditIndentUnitChanged() {
+    this.set('editPrefs.indent_unit', Number(this.$.editIndentUnit.value));
+    this._handleEditPrefsChanged();
+  }
+
   _handleEditSyntaxHighlightingChanged() {
     this.set(
       'editPrefs.syntax_highlighting',
@@ -110,6 +128,16 @@
       this.hasUnsavedChanges = false;
     });
   }
+
+  /**
+   * bind-value has type string so we have to convert
+   * anything inputed to string.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToString(key?: number) {
+    return key !== undefined ? String(key) : '';
+  }
 }
 
 declare global {
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 a61d99b..abd925f 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
@@ -28,23 +28,11 @@
       <label for="editTabWidth" class="title">Tab width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.tab_size}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.tab_size)]]"
+          on-change="_handleEditTabWidthChanged"
         >
-          <input
-            is="iron-input"
-            id="editTabWidth"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.tab_size}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="editTabWidth" type="number" />
         </iron-input>
       </span>
     </section>
@@ -52,47 +40,23 @@
       <label for="editColumns" class="title">Columns</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.line_length}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.line_length)]]"
+          on-change="_handleEditLineLengthChanged"
         >
-          <input
-            id="editColumns"
-            is="iron-input"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.line_length}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="editColumns" type="number" />
         </iron-input>
       </span>
     </section>
     <section>
-      <label for="indentUnit" class="title">Indent unit</label>
+      <label for="editIndentUnit" class="title">Indent unit</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{editPrefs.indent_unit}}"
-          on-keypress="_handleEditPrefsChanged"
-          on-change="_handleEditPrefsChanged"
+          bind-value="[[_convertToString(editPrefs.indent_unit)]]"
+          on-change="_handleEditIndentUnitChanged"
         >
-          <input
-            is="iron-input"
-            id="indentUnit"
-            type="number"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{editPrefs.indent_unit}}"
-            on-keypress="_handleEditPrefsChanged"
-            on-change="_handleEditPrefsChanged"
-          />
+          <input id="indentUnit" type="number" />
         </iron-input>
       </span>
     </section>
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 74c6ac0..7f25a86 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
@@ -105,6 +105,10 @@
       }
     }
   }
+
+  _checkPreferred(preferred?: boolean) {
+    return preferred ?? false;
+  }
 }
 
 declare global {
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 0591e4b..666afb7 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
@@ -71,12 +71,10 @@
                 checked$="[[item.preferred]]"
               >
                 <input
-                  is="iron-input"
                   class="preferredRadio"
                   type="radio"
                   on-change="_handlePreferredChange"
                   name="preferred"
-                  value="[[item.email]]"
                   checked$="[[item.preferred]]"
                 />
               </iron-input>
@@ -85,7 +83,7 @@
               <gr-button
                 data-index$="[[index]]"
                 on-click="_handleDeleteButton"
-                disabled="[[item.preferred]]"
+                disabled="[[_checkPreferred(item.preferred)]]"
                 class="remove-button"
                 >Delete</gr-button
               >
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 69ac702..f4641c2 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
@@ -31,14 +31,6 @@
       padding: var(--spacing-xxl);
       width: 50em;
     }
-    .publicKey {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      overflow-x: scroll;
-      overflow-wrap: break-word;
-      width: 30em;
-    }
     .closeButton {
       bottom: 2em;
       position: absolute;
@@ -78,9 +70,9 @@
               </td>
               <td>
                 <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy GPG public key to clipboard"
-                  hide-input=""
+                  hasTooltip=""
+                  buttonTitle="Copy GPG public key to clipboard"
+                  hideInput=""
                   text="[[key.key]]"
                 >
                 </gr-copy-clipboard>
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 8820748..ba736a2 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,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-gpg-editor.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-gpg-editor');
 
@@ -25,7 +25,7 @@
   let element;
   let keys;
 
-  setup(done => {
+  setup(async () => {
     const fingerprint1 = '0192 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
     const fingerprint2 = '0196 723D 42D1 0C5B 32A6  E1E0 9350 9E4B AFC8 A49B';
     keys = {
@@ -55,7 +55,8 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
@@ -70,7 +71,7 @@
     assert.equal(cells[0].textContent, 'AED9B59C');
   });
 
-  test('remove key', done => {
+  test('remove key', async () => {
     const lastKey = keys[Object.keys(keys)[1]];
 
     const saveStub = stubRestApi('deleteAccountGPGKey')
@@ -91,13 +92,11 @@
     assert.isTrue(element.hasUnsavedChanges);
     assert.isFalse(saveStub.called);
 
-    element.save().then(() => {
-      assert.isTrue(saveStub.called);
-      assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
-      assert.equal(element._keysToRemove.length, 0);
-      assert.isFalse(element.hasUnsavedChanges);
-      done();
-    });
+    await element.save();
+    assert.isTrue(saveStub.called);
+    assert.equal(saveStub.lastCall.args[0], Object.keys(keys)[1]);
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('show key', () => {
@@ -113,7 +112,7 @@
     assert.isTrue(openSpy.called);
   });
 
-  test('add key', done => {
+  test('add key', async () => {
     const newKeyString =
         '-----BEGIN PGP PUBLIC KEY BLOCK-----' +
         '\nVersion: BCPG v1.52\n\t<key 3>';
@@ -138,11 +137,12 @@
     assert.isFalse(element.$.addButton.disabled);
     assert.isFalse(element.$.newKey.disabled);
 
+    const promise = mockPromise();
     element._handleAddKey().then(() => {
       assert.isTrue(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
       assert.equal(element._keys.length, 2);
-      done();
+      promise.resolve();
     });
 
     assert.isTrue(element.$.addButton.disabled);
@@ -150,9 +150,10 @@
 
     assert.isTrue(addStub.called);
     assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    await promise;
   });
 
-  test('add invalid key', done => {
+  test('add invalid key', async () => {
     const newKeyString = 'not even close to valid';
 
     const addStub = stubRestApi(
@@ -164,11 +165,12 @@
     assert.isFalse(element.$.addButton.disabled);
     assert.isFalse(element.$.newKey.disabled);
 
+    const promise = mockPromise();
     element._handleAddKey().then(() => {
       assert.isFalse(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
       assert.equal(element._keys.length, 2);
-      done();
+      promise.resolve();
     });
 
     assert.isTrue(element.$.addButton.disabled);
@@ -176,6 +178,7 @@
 
     assert.isTrue(addStub.called);
     assert.deepEqual(addStub.lastCall.args[0], {add: [newKeyString]});
+    await promise;
   });
 });
 
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 a367969..8f1706d 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
@@ -14,14 +14,14 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import '../../../styles/gr-form-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-group-list_html';
+
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {GroupInfo, GroupId} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, state} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,13 +29,9 @@
   }
 }
 @customElement('gr-group-list')
-export class GrGroupList extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Array})
-  _groups: GroupInfo[] = [];
+export class GrGroupList extends LitElement {
+  @state()
+  protected _groups: GroupInfo[] = [];
 
   private readonly restApiService = appContext.restApiService;
 
@@ -48,8 +44,54 @@
     });
   }
 
-  _computeVisibleToAll(group: GroupInfo) {
-    return group.options && group.options.visible_to_all ? 'Yes' : 'No';
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        #groups .nameColumn {
+          min-width: 11em;
+          width: auto;
+        }
+        .descriptionHeader {
+          min-width: 21.5em;
+        }
+        .visibleCell {
+          text-align: center;
+          width: 6em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <div class="gr-form-styles">
+      <table id="groups">
+        <thead>
+          <tr>
+            <th class="nameHeader">Name</th>
+            <th class="descriptionHeader">Description</th>
+            <th class="visibleCell">Visible to all</th>
+          </tr>
+        </thead>
+        <tbody>
+          ${(this._groups ?? []).map(group => {
+            const href = this._computeGroupPath(group);
+            return html`
+              <tr>
+                <td class="nameColumn">
+                  <a href="${href}"> ${group.name} </a>
+                </td>
+                <td>${group.description}</td>
+                <td class="visibleCell">
+                  ${group?.options?.visible_to_all ? 'Yes' : 'No'}
+                </td>
+              </tr>
+            `;
+          })}
+        </tbody>
+      </table>
+    </div>`;
   }
 
   _computeGroupPath(group: GroupInfo) {
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
deleted file mode 100644
index 5f98a82..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_html.ts
+++ /dev/null
@@ -1,58 +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">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-form-styles">
-    #groups .nameColumn {
-      min-width: 11em;
-      width: auto;
-    }
-    .descriptionHeader {
-      min-width: 21.5em;
-    }
-    .visibleCell {
-      text-align: center;
-      width: 6em;
-    }
-  </style>
-  <div class="gr-form-styles">
-    <table id="groups">
-      <thead>
-        <tr>
-          <th class="nameHeader">Name</th>
-          <th class="descriptionHeader">Description</th>
-          <th class="visibleCell">Visible to all</th>
-        </tr>
-      </thead>
-      <tbody>
-        <template is="dom-repeat" items="[[_groups]]">
-          <tr>
-            <td class="nameColumn">
-              <a href$="[[_computeGroupPath(item)]]"> [[item.name]] </a>
-            </td>
-            <td>[[item.description]]</td>
-            <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
-          </tr>
-        </template>
-      </tbody>
-    </table>
-  </div>
-`;
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
deleted file mode 100644
index e048103..0000000
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.js
+++ /dev/null
@@ -1,103 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-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');
-
-suite('gr-group-list tests', () => {
-  let element;
-  let groups;
-
-  setup(done => {
-    groups = [{
-      url: 'some url',
-      options: {},
-      description: 'Group 1 description',
-      group_id: 1,
-      owner: 'Administrators',
-      owner_id: '123',
-      id: 'abc',
-      name: 'Group 1',
-    }, {
-      options: {visible_to_all: true},
-      id: '456',
-      name: 'Group 2',
-    }, {
-      options: {},
-      id: '789',
-      name: 'Group 3',
-    }];
-
-    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
-
-    element = basicFixture.instantiate();
-
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('renders', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 3);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td a')[0].textContent.trim()
-    );
-
-    assert.equal(nameCells[0], 'Group 1');
-    assert.equal(nameCells[1], 'Group 2');
-    assert.equal(nameCells[2], 'Group 3');
-  });
-
-  test('_computeVisibleToAll', () => {
-    assert.equal(element._computeVisibleToAll(groups[0]), 'No');
-    assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
-  });
-
-  test('_computeGroupPath', () => {
-    let urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
-        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    let group = {
-      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
-    };
-    assert.equal(element._computeGroupPath(group),
-        '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
-
-    group = {
-      name: 'admin',
-    };
-    assert.isUndefined(element._computeGroupPath(group));
-
-    urlStub.restore();
-
-    urlStub = sinon.stub(GerritNav, 'getUrlForGroup').callsFake(
-        () => '/admin/groups/user/test');
-
-    group = {
-      id: 'user%2Ftest',
-    };
-    assert.equal(element._computeGroupPath(group),
-        '/admin/groups/user/test');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
new file mode 100644
index 0000000..a1534af
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.ts
@@ -0,0 +1,107 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-group-list';
+import {GrGroupList} from './gr-group-list';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {GroupId, GroupInfo, GroupName} from '../../../types/common';
+import {queryAll, stubRestApi} from '../../../test/test-utils';
+
+const basicFixture = fixtureFromElement('gr-group-list');
+
+suite('gr-group-list tests', () => {
+  let element: GrGroupList;
+  let groups: GroupInfo[];
+
+  setup(async () => {
+    groups = [
+      {
+        url: 'some url',
+        options: {
+          visible_to_all: false,
+        },
+        description: 'Group 1 description',
+        group_id: 1,
+        owner: 'Administrators',
+        owner_id: '123',
+        id: 'abc' as GroupId,
+        name: 'Group 1' as GroupName,
+      },
+      {
+        options: {visible_to_all: true},
+        id: '456' as GroupId,
+        name: 'Group 2' as GroupName,
+      },
+      {
+        options: {visible_to_all: false},
+        id: '789' as GroupId,
+        name: 'Group 3' as GroupName,
+      },
+    ];
+
+    stubRestApi('getAccountGroups').returns(Promise.resolve(groups));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', async () => {
+    await flush();
+
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 3);
+
+    const nameCells = rows.map(row =>
+      queryAll(row, 'td a')[0].textContent!.trim()
+    );
+
+    assert.equal(nameCells[0], 'Group 1');
+    assert.equal(nameCells[1], 'Group 2');
+    assert.equal(nameCells[2], 'Group 3');
+  });
+
+  test('_computeGroupPath', () => {
+    let urlStub = sinon
+      .stub(GerritNav, 'getUrlForGroup')
+      .callsFake(
+        () => '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
+      );
+
+    let group = {
+      id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5' as GroupId,
+    };
+    assert.equal(
+      element._computeGroupPath(group),
+      '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5'
+    );
+
+    urlStub.restore();
+
+    urlStub = sinon
+      .stub(GerritNav, 'getUrlForGroup')
+      .callsFake(() => '/admin/groups/user/test');
+
+    group = {
+      id: 'user%2Ftest' as GroupId,
+    };
+    assert.equal(element._computeGroupPath(group), '/admin/groups/user/test');
+  });
+});
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 610ea30..59f6a39 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
@@ -14,16 +14,15 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-form-styles';
 import '../../shared/gr-button/gr-button';
 import '../../shared/gr-copy-clipboard/gr-copy-clipboard';
 import '../../shared/gr-overlay/gr-overlay';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-http-password_html';
-import {property, customElement} from '@polymer/decorators';
 import {GrOverlay} from '../../shared/gr-overlay/gr-overlay';
 import {appContext} from '../../../services/app-context';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, query} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -31,17 +30,10 @@
   }
 }
 
-export interface GrHttpPassword {
-  $: {
-    generatedPasswordOverlay: GrOverlay;
-  };
-}
-
 @customElement('gr-http-password')
-export class GrHttpPassword extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrHttpPassword extends LitElement {
+  @query('#generatedPasswordOverlay')
+  generatedPasswordOverlay?: GrOverlay;
 
   @property({type: String})
   _username?: string;
@@ -54,8 +46,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.loadData();
   }
@@ -84,16 +75,100 @@
     return Promise.all(promises);
   }
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      formStyles,
+      css`
+        .password {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+        }
+        #generatedPasswordOverlay {
+          padding: var(--spacing-xxl);
+          width: 50em;
+        }
+        #generatedPasswordDisplay {
+          margin: var(--spacing-l) 0;
+        }
+        #generatedPasswordDisplay .title {
+          width: unset;
+        }
+        #generatedPasswordDisplay .value {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+        }
+        #passwordWarning {
+          font-style: italic;
+          text-align: center;
+        }
+        .closeButton {
+          bottom: 2em;
+          position: absolute;
+          right: 2em;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <div class="gr-form-styles">
+        <div ?hidden=${!!this._passwordUrl}>
+          <section>
+            <span class="title">Username</span>
+            <span class="value">${this._username ?? ''}</span>
+          </section>
+          <gr-button id="generateButton" @click=${this._handleGenerateTap}
+            >Generate new password</gr-button
+          >
+        </div>
+        <span ?hidden=${!this._passwordUrl}>
+          <a href="${this._passwordUrl!}" target="_blank" rel="noopener">
+            Obtain password</a
+          >
+          (opens in a new tab)
+        </span>
+      </div>
+      <gr-overlay
+        id="generatedPasswordOverlay"
+        @iron-overlay-closed=${this._generatedPasswordOverlayClosed}
+        with-backdrop
+      >
+        <div class="gr-form-styles">
+          <section id="generatedPasswordDisplay">
+            <span class="title">New Password:</span>
+            <span class="value">${this._generatedPassword}</span>
+            <gr-copy-clipboard
+              hasTooltip=""
+              buttonTitle="Copy password to clipboard"
+              hideInput=""
+              .text="${this._generatedPassword}"
+            >
+            </gr-copy-clipboard>
+          </section>
+          <section id="passwordWarning">
+            This password will not be displayed again.<br />
+            If you lose it, you will need to generate a new one.
+          </section>
+          <gr-button link="" class="closeButton" @click=${this._closeOverlay}
+            >Close</gr-button
+          >
+        </div>
+      </gr-overlay>`;
+  }
+
   _handleGenerateTap() {
     this._generatedPassword = 'Generating...';
-    this.$.generatedPasswordOverlay.open();
+    this.generatedPasswordOverlay?.open();
     this.restApiService.generateAccountHttpPassword().then(newPassword => {
       this._generatedPassword = newPassword;
     });
   }
 
   _closeOverlay() {
-    this.$.generatedPasswordOverlay.close();
+    this.generatedPasswordOverlay?.close();
   }
 
   _generatedPasswordOverlayClosed() {
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
deleted file mode 100644
index 549fc93..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_html.ts
+++ /dev/null
@@ -1,97 +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">
-    .password {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    #generatedPasswordOverlay {
-      padding: var(--spacing-xxl);
-      width: 50em;
-    }
-    #generatedPasswordDisplay {
-      margin: var(--spacing-l) 0;
-    }
-    #generatedPasswordDisplay .title {
-      width: unset;
-    }
-    #generatedPasswordDisplay .value {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-    }
-    #passwordWarning {
-      font-style: italic;
-      text-align: center;
-    }
-    .closeButton {
-      bottom: 2em;
-      position: absolute;
-      right: 2em;
-    }
-  </style>
-  <style include="gr-form-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="gr-form-styles">
-    <div hidden$="[[_passwordUrl]]">
-      <section>
-        <span class="title">Username</span>
-        <span class="value">[[_username]]</span>
-      </section>
-      <gr-button id="generateButton" on-click="_handleGenerateTap"
-        >Generate new password</gr-button
-      >
-    </div>
-    <span hidden$="[[!_passwordUrl]]">
-      <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
-        Obtain password</a
-      >
-      (opens in a new tab)
-    </span>
-  </div>
-  <gr-overlay
-    id="generatedPasswordOverlay"
-    on-iron-overlay-closed="_generatedPasswordOverlayClosed"
-    with-backdrop=""
-  >
-    <div class="gr-form-styles">
-      <section id="generatedPasswordDisplay">
-        <span class="title">New Password:</span>
-        <span class="value">[[_generatedPassword]]</span>
-        <gr-copy-clipboard
-          has-tooltip=""
-          button-title="Copy password to clipboard"
-          hide-input=""
-          text="[[_generatedPassword]]"
-        >
-        </gr-copy-clipboard>
-      </section>
-      <section id="passwordWarning">
-        This password will not be displayed again.<br />
-        If you lose it, you will need to generate a new one.
-      </section>
-      <gr-button link="" class="closeButton" on-click="_closeOverlay"
-        >Close</gr-button
-      >
-    </div>
-  </gr-overlay>
-`;
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
deleted file mode 100644
index e403b4f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.js
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-http-password.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-http-password');
-
-suite('gr-http-password tests', () => {
-  let element;
-  let account;
-  let config;
-
-  setup(done => {
-    account = {username: 'user name'};
-    config = {auth: {}};
-
-    stubRestApi('getAccount').returns(Promise.resolve(account));
-    stubRestApi('getConfig').returns(Promise.resolve(config));
-
-    element = basicFixture.instantiate();
-    element.loadData().then(() => { flush(done); });
-  });
-
-  test('generate password', () => {
-    const button = element.$.generateButton;
-    const nextPassword = 'the new password';
-    let generateResolve;
-    const generateStub = stubRestApi(
-        'generateAccountHttpPassword')
-        .callsFake(() => new Promise(resolve => {
-          generateResolve = resolve;
-        }));
-
-    assert.isNotOk(element._generatedPassword);
-
-    MockInteractions.tap(button);
-
-    assert.isTrue(generateStub.called);
-    assert.equal(element._generatedPassword, 'Generating...');
-
-    generateResolve(nextPassword);
-
-    generateStub.lastCall.returnValue.then(() => {
-      assert.equal(element._generatedPassword, nextPassword);
-    });
-  });
-
-  test('without http_password_url', () => {
-    assert.isNull(element._passwordUrl);
-  });
-
-  test('with http_password_url', done => {
-    config.auth.http_password_url = 'http://example.com/';
-    element.loadData().then(() => {
-      assert.isNotNull(element._passwordUrl);
-      assert.equal(element._passwordUrl, config.auth.http_password_url);
-      done();
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
new file mode 100644
index 0000000..eab8d2e
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.ts
@@ -0,0 +1,84 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-http-password';
+import {GrHttpPassword} from './gr-http-password';
+import {stubRestApi} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createAccountDetailWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {queryAndAssert} from '../../../test/test-utils';
+import {GrButton} from '../../shared/gr-button/gr-button';
+
+const basicFixture = fixtureFromElement('gr-http-password');
+
+suite('gr-http-password tests', () => {
+  let element: GrHttpPassword;
+  let account: AccountDetailInfo;
+  let config: ServerInfo;
+
+  setup(async () => {
+    account = {...createAccountDetailWithId(), username: 'user name'};
+    config = createServerInfo();
+
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+
+    element = basicFixture.instantiate();
+    await element.loadData();
+    await flush();
+  });
+
+  test('generate password', () => {
+    const button = queryAndAssert<GrButton>(element, '#generateButton');
+    const nextPassword = 'the new password';
+    let generateResolve: (value: string | PromiseLike<string>) => void;
+    const generateStub = stubRestApi('generateAccountHttpPassword').callsFake(
+      () =>
+        new Promise(resolve => {
+          generateResolve = resolve;
+        })
+    );
+
+    assert.isNotOk(element._generatedPassword);
+
+    MockInteractions.tap(button);
+
+    assert.isTrue(generateStub.called);
+    assert.equal(element._generatedPassword, 'Generating...');
+
+    generateStub.lastCall.returnValue.then(() => {
+      generateResolve(nextPassword);
+      assert.equal(element._generatedPassword, nextPassword);
+    });
+  });
+
+  test('without http_password_url', () => {
+    assert.isNull(element._passwordUrl);
+  });
+
+  test('with http_password_url', async () => {
+    config.auth.http_password_url = 'http://example.com/';
+    await element.loadData();
+    assert.isNotNull(element._passwordUrl);
+    assert.equal(element._passwordUrl, config.auth.http_password_url);
+  });
+});
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 ef5d72f..a30840c 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
@@ -98,7 +98,7 @@
       on-confirm="_handleDeleteItemConfirm"
       on-cancel="_handleConfirmDialogCancel"
       item="[[_idName]]"
-      item-type="id"
+      itemTypeName="ID"
     ></gr-confirm-delete-item-dialog>
   </gr-overlay>
 `;
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
deleted file mode 100644
index 867473f..0000000
--- a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.js
+++ /dev/null
@@ -1,168 +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 './gr-identities.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-identities');
-
-suite('gr-identities tests', () => {
-  let element;
-
-  const ids = [
-    {
-      identity: 'username:john',
-      email_address: 'john.doe@example.com',
-      trusted: true,
-    }, {
-      identity: 'gerrit:gerrit',
-      email_address: 'gerrit@example.com',
-    }, {
-      identity: 'mailto:gerrit2@example.com',
-      email_address: 'gerrit2@example.com',
-      trusted: true,
-      can_delete: true,
-    },
-  ];
-
-  setup(async () => {
-    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
-
-    element = basicFixture.instantiate();
-    await element.loadData();
-    await flush();
-  });
-
-  test('renders', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[2].textContent
-    );
-
-    assert.equal(nameCells[0].trim(), 'gerrit:gerrit');
-    assert.equal(nameCells[1].trim(), '');
-  });
-
-  test('renders email', () => {
-    const rows = Array.from(
-        element.root.querySelectorAll('tbody tr'));
-
-    assert.equal(rows.length, 2);
-
-    const nameCells = rows.map(row =>
-      row.querySelectorAll('td')[1].textContent
-    );
-
-    assert.equal(nameCells[0], 'gerrit@example.com');
-    assert.equal(nameCells[1], 'gerrit2@example.com');
-  });
-
-  test('_computeIdentity', () => {
-    assert.equal(
-        element._computeIdentity(ids[0].identity), 'username:john');
-    assert.equal(element._computeIdentity(ids[2].identity), '');
-  });
-
-  test('filterIdentities', () => {
-    assert.isFalse(element.filterIdentities(ids[0]));
-
-    assert.isTrue(element.filterIdentities(ids[1]));
-  });
-
-  test('delete id', done => {
-    element._idName = 'mailto:gerrit2@example.com';
-    const loadDataStub = sinon.stub(element, 'loadData');
-    element._handleDeleteItemConfirm().then(() => {
-      assert.isTrue(loadDataStub.called);
-      done();
-    });
-  });
-
-  test('_handleDeleteItem opens modal', () => {
-    const deleteBtn =
-        element.root.querySelector('.deleteButton');
-    const deleteItem = sinon.stub(element, '_handleDeleteItem');
-    MockInteractions.tap(deleteBtn);
-    assert.isTrue(deleteItem.called);
-  });
-
-  test('_computeShowLinkAnotherIdentity', () => {
-    let serverConfig;
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OpenID',
-      },
-    };
-    assert.isTrue(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP_LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP',
-      },
-    };
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-
-    serverConfig = {};
-    assert.isFalse(element._computeShowLinkAnotherIdentity(serverConfig));
-  });
-
-  test('_showLinkAnotherIdentity', () => {
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-
-    assert.isTrue(element._showLinkAnotherIdentity);
-
-    element.serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-
-    assert.isFalse(element._showLinkAnotherIdentity);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
new file mode 100644
index 0000000..9d8dcc5
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-identities/gr-identities_test.ts
@@ -0,0 +1,146 @@
+/**
+ * @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';
+import './gr-identities';
+import {GrIdentities} from './gr-identities';
+import {stubRestApi} from '../../../test/test-utils';
+import {ServerInfo} from '../../../types/common';
+import {createServerInfo} from '../../../test/test-data-generators';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-identities');
+
+suite('gr-identities tests', () => {
+  let element: GrIdentities;
+
+  const ids = [
+    {
+      identity: 'username:john',
+      email_address: 'john.doe@example.com',
+      trusted: true,
+    },
+    {
+      identity: 'gerrit:gerrit',
+      email_address: 'gerrit@example.com',
+    },
+    {
+      identity: 'mailto:gerrit2@example.com',
+      email_address: 'gerrit2@example.com',
+      trusted: true,
+      can_delete: true,
+    },
+  ];
+
+  setup(async () => {
+    stubRestApi('getExternalIds').returns(Promise.resolve(ids));
+
+    element = basicFixture.instantiate();
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row => queryAll(row, 'td')[2].textContent);
+
+    assert.equal(nameCells[0]!.trim(), 'gerrit:gerrit');
+    assert.equal(nameCells[1]!.trim(), '');
+  });
+
+  test('renders email', () => {
+    const rows = Array.from(queryAll(element, 'tbody tr'));
+
+    assert.equal(rows.length, 2);
+
+    const nameCells = rows.map(row => queryAll(row, 'td')[1]!.textContent);
+
+    assert.equal(nameCells[0]!, 'gerrit@example.com');
+    assert.equal(nameCells[1]!, 'gerrit2@example.com');
+  });
+
+  test('_computeIdentity', () => {
+    assert.equal(element._computeIdentity(ids[0].identity), 'username:john');
+    assert.equal(element._computeIdentity(ids[2].identity), '');
+  });
+
+  test('filterIdentities', () => {
+    assert.isFalse(element.filterIdentities(ids[0]));
+
+    assert.isTrue(element.filterIdentities(ids[1]));
+  });
+
+  test('delete id', async () => {
+    element._idName = 'mailto:gerrit2@example.com';
+    const loadDataStub = sinon.stub(element, 'loadData');
+    await element._handleDeleteItemConfirm();
+    assert.isTrue(loadDataStub.called);
+  });
+
+  test('_handleDeleteItem opens modal', () => {
+    const deleteBtn = queryAndAssert(element, '.deleteButton');
+    const deleteItem = sinon.stub(element, '_handleDeleteItem');
+    MockInteractions.tap(deleteBtn);
+    assert.isTrue(deleteItem.called);
+  });
+
+  test('_computeShowLinkAnotherIdentity', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+    };
+
+    config.auth.git_basic_auth_policy = 'OAUTH';
+    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'OpenID';
+    assert.isTrue(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'LDAP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    config.auth.git_basic_auth_policy = 'HTTP';
+    assert.isFalse(element._computeShowLinkAnotherIdentity(config));
+
+    assert.isFalse(element._computeShowLinkAnotherIdentity(undefined));
+  });
+
+  test('_showLinkAnotherIdentity', () => {
+    let config: ServerInfo = {
+      ...createServerInfo(),
+    };
+    config.auth.git_basic_auth_policy = 'OAUTH';
+
+    element.serverConfig = config;
+
+    assert.isTrue(element._showLinkAnotherIdentity);
+
+    config = {
+      ...createServerInfo(),
+    };
+    config.auth.git_basic_auth_policy = 'LDAP';
+    element.serverConfig = config;
+
+    assert.isFalse(element._showLinkAnotherIdentity);
+  });
+});
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 0ff5e98..c392a13 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
@@ -16,7 +16,6 @@
  */
 import '@polymer/iron-input/iron-input';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-date-formatter/gr-date-formatter';
 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-menu-editor/gr-menu-editor_test.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
index 19852d9..1ca4852 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.js
@@ -46,7 +46,7 @@
     MockInteractions.tap(button);
   }
 
-  setup(done => {
+  setup(async () => {
     element = basicFixture.instantiate();
     menu = [
       {url: '/first/url', name: 'first name', target: '_blank'},
@@ -55,7 +55,7 @@
     ];
     element.set('menuItems', menu);
     flush$0();
-    flush(done);
+    await flush();
   });
 
   test('renders', () => {
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 3763fbf..aa8f62b 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
@@ -85,8 +85,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._ensureAttribute('role', 'dialog');
   }
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 4b86709..6f270f5 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
@@ -75,12 +75,7 @@
         <span class="title">Full Name</span>
         <span class="value">
           <iron-input bind-value="{{_account.name}}">
-            <input
-              is="iron-input"
-              id="name"
-              bind-value="{{_account.name}}"
-              disabled="[[_saving]]"
-            />
+            <input id="name" disabled="[[_saving]]" />
           </iron-input>
         </span>
       </section>
@@ -92,12 +87,7 @@
           >
           <span hidden$="[[!_usernameMutable]]" class="value">
             <iron-input bind-value="{{_username}}">
-              <input
-                is="iron-input"
-                id="username"
-                bind-value="{{_username}}"
-                disabled="[[_saving]]"
-              />
+              <input id="username" disabled="[[_saving]]" />
             </iron-input>
           </span>
         </section>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
index 6c21e58..3c09d5e 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.ts
@@ -15,6 +15,7 @@
  * limitations under the License.
  */
 import '../../../test/common-test-setup-karma';
+import './gr-registration-dialog';
 import {GrRegistrationDialog} from './gr-registration-dialog';
 import {queryAndAssert, stubRestApi} from '../../../test/test-utils';
 import {AccountDetailInfo, Timestamp} from '../../../types/common';
@@ -93,38 +94,34 @@
     return promise;
   }
 
-  test('fires the close event on close', done => {
-    close().then(done);
+  test('fires the close event on close', async () => {
+    await close();
   });
 
-  test('fires the close event on save', done => {
-    close(() =>
+  test('fires the close event on save', async () => {
+    await close(() =>
       MockInteractions.tap(queryAndAssert(element, '#saveButton'))
-    ).then(done);
+    );
   });
 
-  test('saves account details', done => {
-    flush(() => {
-      element.$.name.value = 'new name';
+  test('saves account details', async () => {
+    await flush();
+    element.$.name.value = 'new name';
 
-      element.set('_account.username', '');
-      element._hasUsernameChange = false;
-      assert.isTrue(element._usernameMutable);
+    element.set('_account.username', '');
+    element._hasUsernameChange = false;
+    assert.isTrue(element._usernameMutable);
 
-      element.set('_username', 'new username');
+    element.set('_username', 'new username');
 
-      // Nothing should be committed yet.
-      assert.equal(account.name, 'name');
-      assert.isNotOk(account.username);
+    // Nothing should be committed yet.
+    assert.equal(account.name, 'name');
+    assert.isNotOk(account.username);
 
-      // Save and verify new values are committed.
-      save()
-        .then(() => {
-          assert.equal(account.name, 'new name');
-          assert.equal(account.username, 'new username');
-        })
-        .then(done);
-    });
+    // Save and verify new values are committed.
+    await save();
+    assert.equal(account.name, 'new name');
+    assert.equal(account.username, 'new username');
   });
 
   test('save btn disabled', () => {
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
index 7540960..64409d5 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item.ts
@@ -14,9 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-item_html';
-import {property, customElement} from '@polymer/decorators';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {formStyles} from '../../../styles/gr-form-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -25,14 +25,27 @@
 }
 
 @customElement('gr-settings-item')
-class GrSettingsItem extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSettingsItem extends LitElement {
   @property({type: String})
   anchor?: string;
 
   @property({type: String})
-  title = '';
+  override title = '';
+
+  static override get styles() {
+    return [
+      formStyles,
+      css`
+        :host {
+          display: block;
+          margin-bottom: var(--spacing-xxl);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const anchor = this.anchor ?? '';
+    return html`<h2 id="${anchor}" class="heading-2">${this.title}</h2>`;
+  }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts
deleted file mode 100644
index 786abc0..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-item_html.ts
+++ /dev/null
@@ -1,28 +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>
-    :host {
-      display: block;
-      margin-bottom: var(--spacing-xxl);
-    }
-  </style>
-  <h2 id="[[anchor]]" class="heading-2">[[title]]</h2>
-  <slot></slot>
-`;
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
index 8a6c084..9e4ea0a 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item.ts
@@ -14,10 +14,10 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/gr-page-nav-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-settings-menu-item_html';
-import {property, customElement} from '@polymer/decorators';
+import {pageNavStyles} from '../../../styles/gr-page-nav-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -26,14 +26,21 @@
 }
 
 @customElement('gr-settings-menu-item')
-class GrSettingsMenuItem extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrSettingsMenuItem extends LitElement {
   @property({type: String})
   href?: string;
 
   @property({type: String})
-  title = '';
+  override title = '';
+
+  static override get styles() {
+    return [sharedStyles, pageNavStyles];
+  }
+
+  override render() {
+    const href = this.href ?? '';
+    return html` <div class="navStyles">
+      <li><a href="${href}">${this.title}</a></li>
+    </div>`;
+  }
 }
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts
deleted file mode 100644
index fc3edcd..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-menu-item_html.ts
+++ /dev/null
@@ -1,29 +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">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="gr-page-nav-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <div class="navStyles">
-    <li><a href$="[[href]]">[[title]]</a></li>
-  </div>
-`;
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 eee850e..127ac69 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
@@ -16,18 +16,15 @@
  */
 import '@polymer/iron-input/iron-input';
 import '@polymer/paper-toggle-button/paper-toggle-button';
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-form-styles';
 import '../../../styles/gr-menu-page-styles';
 import '../../../styles/gr-page-nav-styles';
 import '../../../styles/shared-styles';
-import {
-  applyTheme as applyDarkTheme,
-  removeTheme as removeDarkTheme,
-} from '../../../styles/themes/dark-theme';
 import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
 import '../gr-change-table-editor/gr-change-table-editor';
 import '../../shared/gr-button/gr-button';
-import '../../shared/gr-date-formatter/gr-date-formatter';
+import {GrButton} from '../../shared/gr-button/gr-button';
 import '../../shared/gr-diff-preferences/gr-diff-preferences';
 import '../../shared/gr-page-nav/gr-page-nav';
 import '../../shared/gr-select/gr-select';
@@ -45,7 +42,6 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-settings-view_html';
 import {getDocsBaseUrl} from '../../../utils/url-util';
-import {ChangeTableMixin} from '../../../mixins/gr-change-table-mixin/gr-change-table-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {AppElementParams} from '../../gr-app-types';
 import {GrAccountInfo} from '../gr-account-info/gr-account-info';
@@ -62,10 +58,19 @@
 import {GrSshEditor} from '../gr-ssh-editor/gr-ssh-editor';
 import {GrGpgEditor} from '../gr-gpg-editor/gr-gpg-editor';
 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';
+import {
+  DateFormat,
+  DefaultBase,
+  DiffViewMode,
+  EmailFormat,
+  EmailStrategy,
+  TimeFormat,
+} from '../../../constants/constants';
+import {columnNames} from '../../change-list/gr-change-list/gr-change-list';
+import {windowLocationReload} from '../../../utils/dom-util';
 
 const PREFS_SECTION_FIELDS: Array<keyof PreferencesInput> = [
   'changes_per_page',
@@ -74,6 +79,8 @@
   'email_strategy',
   'diff_view',
   'publish_comments_on_push',
+  'disable_keyboard_shortcuts',
+  'disable_token_highlighting',
   'work_in_progress_by_default',
   'default_base_for_merges',
   'signed_off_by',
@@ -112,12 +119,23 @@
     workInProgressByDefault: HTMLInputElement;
     showSizeBarsInFileList: HTMLInputElement;
     publishCommentsOnPush: HTMLInputElement;
+    disableKeyboardShortcuts: HTMLInputElement;
+    disableTokenHighlighting: HTMLInputElement;
     relativeDateInChangeTable: HTMLInputElement;
+    changesPerPageSelect: HTMLInputElement;
+    dateTimeFormatSelect: HTMLInputElement;
+    timeFormatSelect: HTMLInputElement;
+    emailNotificationsSelect: HTMLInputElement;
+    emailFormatSelect: HTMLInputElement;
+    defaultBaseForMergesSelect: HTMLInputElement;
+    diffViewSelect: HTMLInputElement;
+    menu: HTMLFieldSetElement;
+    resetButton: GrButton;
   };
 }
 
 @customElement('gr-settings-view')
-export class GrSettingsView extends ChangeTableMixin(PolymerElement) {
+export class GrSettingsView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -162,10 +180,10 @@
   _prefsChanged = false;
 
   @property({type: Boolean})
-  _diffPrefsChanged?: boolean;
+  _diffPrefsChanged = false;
 
   @property({type: Boolean})
-  _editPrefsChanged?: boolean;
+  _editPrefsChanged = false;
 
   @property({type: Boolean})
   _menuChanged = false;
@@ -195,7 +213,7 @@
   _docsBaseUrl?: string | null;
 
   @property({type: Boolean})
-  _emailsChanged?: boolean;
+  _emailsChanged = false;
 
   @property({type: Boolean})
   _showNumber?: boolean;
@@ -207,8 +225,7 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     // Polymer 2: anchor tag won't work on shadow DOM
     // we need to manually calling scrollIntoView when hash changed
@@ -226,6 +243,7 @@
       this.$.diffPrefs.loadData(),
     ];
 
+    // TODO(dhruvsri): move this to the service
     promises.push(
       this.restApiService.getPreferences().then(prefs => {
         if (!prefs) {
@@ -237,8 +255,10 @@
         this._localMenu = this._cloneMenu(prefs.my);
         this._localChangeTableColumns =
           prefs.change_table.length === 0
-            ? this.columnNames
-            : this.renameProjectToRepoColumn(prefs.change_table);
+            ? columnNames
+            : prefs.change_table.map(column =>
+                column === 'Project' ? 'Repo' : column
+              );
       })
     );
 
@@ -296,7 +316,7 @@
     });
   }
 
-  disconnectedCallback() {
+  override disconnectedCallback() {
     window.removeEventListener('location-change', this.handleLocationChange);
     super.disconnectedCallback();
   }
@@ -378,6 +398,20 @@
     );
   }
 
+  _handleDisableKeyboardShortcutsChanged() {
+    this.set(
+      '_localPrefs.disable_keyboard_shortcuts',
+      this.$.disableKeyboardShortcuts.checked
+    );
+  }
+
+  _handleDisableTokenHighlightingChanged() {
+    this.set(
+      '_localPrefs.disable_token_highlighting',
+      this.$.disableTokenHighlighting.checked
+    );
+  }
+
   _handleWorkInProgressByDefault() {
     this.set(
       '_localPrefs.work_in_progress_by_default',
@@ -452,7 +486,7 @@
     this.$.emailEditor.save();
   }
 
-  _handleNewEmailKeydown(e: CustomKeyboardEvent) {
+  _handleNewEmailKeydown(e: KeyboardEvent) {
     if (e.keyCode === 13) {
       // Enter
       e.stopPropagation();
@@ -485,7 +519,7 @@
     });
   }
 
-  _getFilterDocsLink(docsBaseUrl?: string) {
+  _getFilterDocsLink(docsBaseUrl?: string | null) {
     let base = docsBaseUrl;
     if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
       base = GERRIT_DOCS_BASE_URL;
@@ -500,13 +534,14 @@
   _handleToggleDark() {
     if (this._isDark) {
       window.localStorage.removeItem('dark-theme');
-      removeDarkTheme();
     } else {
       window.localStorage.setItem('dark-theme', 'true');
-      applyDarkTheme();
     }
-    this._isDark = !!window.localStorage.getItem('dark-theme');
-    fireAlert(this, `Theme changed to ${this._isDark ? 'dark' : 'light'}.`);
+    this.reloadPage();
+  }
+
+  reloadPage() {
+    windowLocationReload();
   }
 
   _showHttpAuth(config?: ServerInfo) {
@@ -525,6 +560,65 @@
   _onTapDarkToggle(e: Event) {
     e.preventDefault();
   }
+
+  _handleChangesPerPage() {
+    this.set(
+      '_localPrefs.changes_per_page',
+      Number(this.$.changesPerPageSelect.value)
+    );
+  }
+
+  _handleDateFormat() {
+    this.set('_localPrefs.date_format', this.$.dateTimeFormatSelect.value);
+  }
+
+  _handleTimeFormat() {
+    this.set('_localPrefs.time_format', this.$.timeFormatSelect.value);
+  }
+
+  _handleEmailStrategy() {
+    this.set(
+      '_localPrefs.email_strategy',
+      this.$.emailNotificationsSelect.value
+    );
+  }
+
+  _handleEmailFormat() {
+    this.set('_localPrefs.email_format', this.$.emailFormatSelect.value);
+  }
+
+  _handleDefaultBaseForMerges() {
+    this.set(
+      '_localPrefs.default_base_for_merges',
+      this.$.defaultBaseForMergesSelect.value
+    );
+  }
+
+  _handleDiffView() {
+    this.set(
+      '_localPrefs.diff_view',
+      this.$.diffViewSelect.value as DiffViewMode
+    );
+  }
+
+  /**
+   * bind-value has type string so we have to convert anything inputed
+   * to string.
+   *
+   * This is so typescript template checker doesn't fail.
+   */
+  _convertToString(
+    key?:
+      | DateFormat
+      | DefaultBase
+      | DiffViewMode
+      | EmailFormat
+      | EmailStrategy
+      | TimeFormat
+      | number
+  ) {
+    return key !== undefined ? String(key) : '';
+  }
 }
 
 declare global {
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 6b45e1c..81e4fc4 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-font-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       color: var(--primary-text-color);
@@ -97,6 +100,7 @@
     </gr-page-nav>
     <div class="main gr-form-styles">
       <h1 class="heading-1">User Settings</h1>
+      <h2 id="Theme">Theme</h2>
       <section class="darkToggle">
         <div class="toggle">
           <paper-toggle-button
@@ -105,13 +109,10 @@
             on-change="_handleToggleDark"
             on-click="_onTapDarkToggle"
           ></paper-toggle-button>
-          <div id="darkThemeToggleLabel">Dark theme (alpha)</div>
+          <div id="darkThemeToggleLabel">
+            Dark theme (the toggle reloads the page)
+          </div>
         </div>
-        <p>
-          Gerrit's dark theme is in early alpha, and almost definitely will not
-          play nicely with themes set by specific Gerrit hosts. Filing feedback
-          via the link in the app footer is strongly encouraged!
-        </p>
       </section>
       <h2 id="Profile" class$="[[_computeHeaderClass(_accountInfoChanged)]]">
         Profile
@@ -136,7 +137,10 @@
             >Changes per page</label
           >
           <span class="value">
-            <gr-select bind-value="{{_localPrefs.changes_per_page}}">
+            <gr-select
+              bind-value="[[_convertToString(_localPrefs.changes_per_page)]]"
+              on-change="_handleChangesPerPage"
+            >
               <select id="changesPerPageSelect">
                 <option value="10">10 rows per page</option>
                 <option value="25">25 rows per page</option>
@@ -151,7 +155,10 @@
             >Date/time format</label
           >
           <span class="value">
-            <gr-select bind-value="{{_localPrefs.date_format}}">
+            <gr-select
+              bind-value="[[_convertToString(_localPrefs.date_format)]]"
+              on-change="_handleDateFormat"
+            >
               <select id="dateTimeFormatSelect">
                 <option value="STD">Jun 3 ; Jun 3, 2016</option>
                 <option value="US">06/03 ; 06/03/16</option>
@@ -161,10 +168,11 @@
               </select>
             </gr-select>
             <gr-select
-              bind-value="{{_localPrefs.time_format}}"
+              bind-value="[[_convertToString(_localPrefs.time_format)]]"
               aria-label="Time Format"
+              on-change="_handleTimeFormat"
             >
-              <select>
+              <select id="timeFormatSelect">
                 <option value="HHMM_12">4:10 PM</option>
                 <option value="HHMM_24">16:10</option>
               </select>
@@ -176,7 +184,10 @@
             >Email notifications</label
           >
           <span class="value">
-            <gr-select bind-value="{{_localPrefs.email_strategy}}">
+            <gr-select
+              bind-value="[[_convertToString(_localPrefs.email_strategy)]]"
+              on-change="_handleEmailStrategy"
+            >
               <select id="emailNotificationsSelect">
                 <option value="CC_ON_OWN_COMMENTS">Every comment</option>
                 <option value="ENABLED">Only comments left by others</option>
@@ -188,10 +199,13 @@
             </gr-select>
           </span>
         </section>
-        <section hidden$="[[!_localPrefs.email_format]]">
+        <section hidden$="[[!_convertToString(_localPrefs.email_format)]]">
           <label class="title" for="emailFormatSelect">Email format</label>
           <span class="value">
-            <gr-select bind-value="{{_localPrefs.email_format}}">
+            <gr-select
+              bind-value="[[_convertToString(_localPrefs.email_format)]]"
+              on-change="_handleEmailFormat"
+            >
               <select id="emailFormatSelect">
                 <option value="HTML_PLAINTEXT">HTML and plaintext</option>
                 <option value="PLAINTEXT">Plaintext only</option>
@@ -202,8 +216,11 @@
         <section hidden$="[[!_localPrefs.default_base_for_merges]]">
           <span class="title">Default Base For Merges</span>
           <span class="value">
-            <gr-select bind-value="{{_localPrefs.default_base_for_merges}}">
-              <select>
+            <gr-select
+              bind-value="[[_convertToString(_localPrefs.default_base_for_merges)]]"
+              on-change="_handleDefaultBaseForMerges"
+            >
+              <select id="defaultBaseForMergesSelect">
                 <option value="AUTO_MERGE">Auto Merge</option>
                 <option value="FIRST_PARENT">First Parent</option>
               </select>
@@ -226,8 +243,11 @@
         <section>
           <span class="title">Diff view</span>
           <span class="value">
-            <gr-select bind-value="{{_localPrefs.diff_view}}">
-              <select>
+            <gr-select
+              bind-value="[[_convertToString(_localPrefs.diff_view)]]"
+              on-change="_handleDiffView"
+            >
+              <select id="diffViewSelect">
                 <option value="SIDE_BY_SIDE">Side by side</option>
                 <option value="UNIFIED_DIFF">Unified diff</option>
               </select>
@@ -274,6 +294,32 @@
           </span>
         </section>
         <section>
+          <label for="disableKeyboardShortcuts" class="title"
+            >Disable all keyboard shortcuts</label
+          >
+          <span class="value">
+            <input
+              id="disableKeyboardShortcuts"
+              type="checkbox"
+              checked$="[[_localPrefs.disable_keyboard_shortcuts]]"
+              on-change="_handleDisableKeyboardShortcutsChanged"
+            />
+          </span>
+        </section>
+        <section>
+          <label for="disableTokenHighlighting" class="title"
+            >Disable token highlighting on hover</label
+          >
+          <span class="value">
+            <input
+              id="disableTokenHighlighting"
+              type="checkbox"
+              checked$="[[_localPrefs.disable_token_highlighting]]"
+              on-change="_handleDisableTokenHighlightingChanged"
+            />
+          </span>
+        </section>
+        <section>
           <label for="insertSignedOff" class="title">
             Insert Signed-off-by Footer For Inline Edit Changes
           </label>
@@ -338,7 +384,7 @@
           disabled="[[!_menuChanged]]"
           >Save changes</gr-button
         >
-        <gr-button id="resetMenu" link="" on-click="_handleResetMenuButton"
+        <gr-button id="resetButton" link="" on-click="_handleResetMenuButton"
           >Reset</gr-button
         >
       </fieldset>
@@ -405,8 +451,6 @@
             >
               <input
                 class="newEmailInput"
-                bind-value="{{_newEmail}}"
-                is="iron-input"
                 type="text"
                 disabled="[[_addingEmail]]"
                 on-keydown="_handleNewEmailKeydown"
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
deleted file mode 100644
index 79789bb..0000000
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.js
+++ /dev/null
@@ -1,501 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import {getComputedStyleValue} from '../../../utils/dom-util.js';
-import './gr-settings-view.js';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom.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');
-
-suite('gr-settings-view tests', () => {
-  let element;
-  let account;
-  let preferences;
-  let config;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  // Because deepEqual isn't behaving in Safari.
-  function assertMenusEqual(actual, expected) {
-    assert.equal(actual.length, expected.length);
-    for (let i = 0; i < actual.length; i++) {
-      assert.equal(actual[i].name, expected[i].name);
-      assert.equal(actual[i].url, expected[i].url);
-    }
-  }
-
-  function stubAddAccountEmail(statusCode) {
-    return stubRestApi('addAccountEmail').callsFake(
-        () => Promise.resolve({status: statusCode}));
-  }
-
-  setup(done => {
-    account = {
-      _account_id: 123,
-      name: 'user name',
-      email: 'user@email',
-      username: 'user username',
-      registered: '2000-01-01 00:00:00.000000000',
-    };
-    preferences = {
-      changes_per_page: 25,
-      date_format: 'UK',
-      time_format: 'HHMM_12',
-      diff_view: 'UNIFIED_DIFF',
-      email_strategy: 'ENABLED',
-      email_format: 'HTML_PLAINTEXT',
-      default_base_for_merges: 'FIRST_PARENT',
-      relative_date_in_change_table: false,
-      size_bar_in_change_table: true,
-
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-      ],
-      change_table: [],
-    };
-    config = {auth: {editable_account_fields: []}};
-
-    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.
-    element._testOnly_loadingPromise.then(done);
-  });
-
-  test('theme changing', () => {
-    window.localStorage.removeItem('dark-theme');
-    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
-    const themeToggle = element.shadowRoot
-        .querySelector('.darkToggle paper-toggle-button');
-    MockInteractions.tap(themeToggle);
-    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
-    assert.equal(
-        getComputedStyleValue('--primary-text-color', document.body), '#e8eaed'
-    );
-    MockInteractions.tap(themeToggle);
-    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
-  });
-
-  test('calls the title-change event', () => {
-    const titleChangedStub = sinon.stub();
-
-    // Create a new view.
-    const newElement = document.createElement('gr-settings-view');
-    newElement.addEventListener('title-change', titleChangedStub);
-
-    const blank = blankFixture.instantiate();
-    blank.appendChild(newElement);
-
-    flush();
-
-    assert.isTrue(titleChangedStub.called);
-    assert.equal(titleChangedStub.getCall(0).args[0].detail.title,
-        'Settings');
-  });
-
-  test('user preferences', done => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Changes per page', 'preferences')
-        .firstElementChild.bindValue, preferences.changes_per_page);
-    assert.equal(valueOf('Date/time format', 'preferences')
-        .firstElementChild.bindValue, preferences.date_format);
-    assert.equal(valueOf('Date/time format', 'preferences')
-        .lastElementChild.bindValue, preferences.time_format);
-    assert.equal(valueOf('Email notifications', 'preferences')
-        .firstElementChild.bindValue, preferences.email_strategy);
-    assert.equal(valueOf('Email format', 'preferences')
-        .firstElementChild.bindValue, preferences.email_format);
-    assert.equal(valueOf('Default Base For Merges', 'preferences')
-        .firstElementChild.bindValue, preferences.default_base_for_merges);
-    assert.equal(
-        valueOf('Show Relative Dates In Changes Table', 'preferences')
-            .firstElementChild.checked, false);
-    assert.equal(valueOf('Diff view', 'preferences')
-        .firstElementChild.bindValue, preferences.diff_view);
-    assert.equal(valueOf('Show size bars in file list', 'preferences')
-        .firstElementChild.checked, true);
-    assert.equal(valueOf('Publish comments on push', 'preferences')
-        .firstElementChild.checked, false);
-    assert.equal(valueOf(
-        'Set new changes to "work in progress" by default', 'preferences')
-        .firstElementChild.checked, false);
-    assert.equal(valueOf(
-        'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
-        .firstElementChild.checked, false);
-
-    assert.isFalse(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-
-    // Change the diff view element.
-    const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
-    diffSelect.bindValue = 'SIDE_BY_SIDE';
-
-    const publishOnPush =
-        valueOf('Publish comments on push', 'preferences').firstElementChild;
-    diffSelect.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
-
-    MockInteractions.tap(publishOnPush);
-
-    assert.isTrue(element._prefsChanged);
-    assert.isFalse(element._menuChanged);
-
-    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.
-    element._handleSavePreferences().then(() => {
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-      done();
-    });
-  });
-
-  test('publish comments on push', done => {
-    const publishCommentsOnPush =
-      valueOf('Publish comments on push', 'preferences').firstElementChild;
-    MockInteractions.tap(publishCommentsOnPush);
-
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assert.equal(prefs.publish_comments_on_push, true);
-      return Promise.resolve();
-    });
-
-    // Save the change.
-    element._handleSavePreferences().then(() => {
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-      done();
-    });
-  });
-
-  test('set new changes work-in-progress', done => {
-    const newChangesWorkInProgress =
-      valueOf('Set new changes to "work in progress" by default',
-          'preferences').firstElementChild;
-    MockInteractions.tap(newChangesWorkInProgress);
-
-    assert.isFalse(element._menuChanged);
-    assert.isTrue(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assert.equal(prefs.work_in_progress_by_default, true);
-      return Promise.resolve();
-    });
-
-    // Save the change.
-    element._handleSavePreferences().then(() => {
-      assert.isFalse(element._prefsChanged);
-      assert.isFalse(element._menuChanged);
-      done();
-    });
-  });
-
-  test('menu', done => {
-    assert.isFalse(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    assertMenusEqual(element._localMenu, preferences.my);
-
-    const menu = element.$.menu.firstElementChild;
-    let tableRows = menu.root.querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length);
-
-    // Add a menu item:
-    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
-    flush();
-
-    tableRows = menu.root.querySelectorAll('tbody tr');
-    assert.equal(tableRows.length, preferences.my.length + 1);
-
-    assert.isTrue(element._menuChanged);
-    assert.isFalse(element._prefsChanged);
-
-    stubRestApi('savePreferences').callsFake(prefs => {
-      assertMenusEqual(prefs.my, element._localMenu);
-      return Promise.resolve();
-    });
-
-    element._handleSaveMenu().then(() => {
-      assert.isFalse(element._menuChanged);
-      assert.isFalse(element._prefsChanged);
-      assertMenusEqual(element.prefs.my, element._localMenu);
-      done();
-    });
-  });
-
-  test('add email validation', () => {
-    assert.isFalse(element._isNewEmailValid('invalid email'));
-    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
-
-    assert.isFalse(
-        element._computeAddEmailButtonEnabled('invalid email'), true);
-    assert.isFalse(
-        element._computeAddEmailButtonEnabled('vaguely@valid.email', true));
-    assert.isTrue(
-        element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
-  });
-
-  test('add email does not save invalid', () => {
-    const addEmailStub = stubAddAccountEmail(201);
-
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'invalid email';
-
-    element._handleAddEmailButton();
-
-    assert.isFalse(element._addingEmail);
-    assert.isFalse(addEmailStub.called);
-    assert.isNotOk(element._lastSentVerificationEmail);
-
-    assert.isFalse(addEmailStub.called);
-  });
-
-  test('add email does save valid', done => {
-    const addEmailStub = stubAddAccountEmail(201);
-
-    assert.isFalse(element._addingEmail);
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
-
-    element._handleAddEmailButton();
-
-    assert.isTrue(element._addingEmail);
-    assert.isTrue(addEmailStub.called);
-
-    assert.isTrue(addEmailStub.called);
-    addEmailStub.lastCall.returnValue.then(() => {
-      assert.isOk(element._lastSentVerificationEmail);
-      done();
-    });
-  });
-
-  test('add email does not set last-email if error', done => {
-    const addEmailStub = stubAddAccountEmail(500);
-
-    assert.isNotOk(element._lastSentVerificationEmail);
-    element._newEmail = 'valid@email.com';
-
-    element._handleAddEmailButton();
-
-    assert.isTrue(addEmailStub.called);
-    addEmailStub.lastCall.returnValue.then(() => {
-      assert.isNotOk(element._lastSentVerificationEmail);
-      done();
-    });
-  });
-
-  test('emails are loaded without emailToken', () => {
-    sinon.stub(element.$.emailEditor, 'loadData');
-    element.params = {};
-    element.connectedCallback();
-    assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-  });
-
-  test('_handleSaveChangeTable', () => {
-    let newColumns = ['Owner', 'Project', 'Branch'];
-    element._localChangeTableColumns = newColumns.slice(0);
-    element._showNumber = false;
-    element._handleSaveChangeTable();
-    assert.deepEqual(element.prefs.change_table, newColumns);
-    assert.isNotOk(element.prefs.legacycid_in_change_table);
-
-    newColumns = ['Size'];
-    element._localChangeTableColumns = newColumns;
-    element._showNumber = true;
-    element._handleSaveChangeTable();
-    assert.deepEqual(element.prefs.change_table, newColumns);
-    assert.isTrue(element.prefs.legacycid_in_change_table);
-  });
-
-  test('reset menu item back to default', done => {
-    const originalMenu = {
-      my: [
-        {url: '/first/url', name: 'first name', target: '_blank'},
-        {url: '/second/url', name: 'second name', target: '_blank'},
-        {url: '/third/url', name: 'third name', target: '_blank'},
-      ],
-    };
-
-    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
-
-    const updatedMenu = [
-      {url: '/first/url', name: 'first name', target: '_blank'},
-      {url: '/second/url', name: 'second name', target: '_blank'},
-      {url: '/third/url', name: 'third name', target: '_blank'},
-      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
-    ];
-
-    element.set('_localMenu', updatedMenu);
-
-    element._handleResetMenuButton().then(() => {
-      assertMenusEqual(element._localMenu, originalMenu.my);
-      done();
-    });
-  });
-
-  test('test that reset button is called', () => {
-    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
-
-    MockInteractions.tap(element.$.resetMenu);
-
-    assert.isTrue(overlayOpen.called);
-  });
-
-  test('_showHttpAuth', () => {
-    let serverConfig;
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP',
-      },
-    };
-
-    assert.isTrue(element._showHttpAuth(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'HTTP_LDAP',
-      },
-    };
-
-    assert.isTrue(element._showHttpAuth(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'LDAP',
-      },
-    };
-
-    assert.isFalse(element._showHttpAuth(serverConfig));
-
-    serverConfig = {
-      auth: {
-        git_basic_auth_policy: 'OAUTH',
-      },
-    };
-
-    assert.isFalse(element._showHttpAuth(serverConfig));
-
-    serverConfig = {};
-
-    assert.isFalse(element._showHttpAuth(serverConfig));
-  });
-
-  suite('_getFilterDocsLink', () => {
-    test('with http: docs base URL', () => {
-      const base = 'http://example.com/';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with http: docs base URL without slash', () => {
-      const base = 'http://example.com';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'http://example.com/user-notify.html');
-    });
-
-    test('with https: docs base URL', () => {
-      const base = 'https://example.com/';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'https://example.com/user-notify.html');
-    });
-
-    test('without docs base URL', () => {
-      const result = element._getFilterDocsLink(null);
-      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html');
-    });
-
-    test('ignores non HTTP links', () => {
-      const base = 'javascript://alert("evil");';
-      const result = element._getFilterDocsLink(base);
-      assert.equal(result, 'https://gerrit-review.googlesource.com/' +
-          'Documentation/user-notify.html');
-    });
-  });
-
-  suite('when email verification token is provided', () => {
-    let resolveConfirm;
-    let confirmEmailStub;
-
-    setup(() => {
-      sinon.stub(element.$.emailEditor, 'loadData');
-      confirmEmailStub = stubRestApi('confirmEmail').returns(
-          new Promise(resolve => { resolveConfirm = resolve; }));
-      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
-      element.connectedCallback();
-    });
-
-    test('it is used to confirm email via rest API', () => {
-      assert.isTrue(confirmEmailStub.calledOnce);
-      assert.isTrue(confirmEmailStub.calledWith('foo'));
-    });
-
-    test('emails are not loaded initially', () => {
-      assert.isFalse(element.$.emailEditor.loadData.called);
-    });
-
-    test('user emails are loaded after email confirmed', done => {
-      element._testOnly_loadingPromise.then(() => {
-        assert.isTrue(element.$.emailEditor.loadData.calledOnce);
-        done();
-      });
-      resolveConfirm();
-    });
-
-    test('show-alert is fired when email is confirmed', done => {
-      sinon.spy(element, 'dispatchEvent');
-      element._testOnly_loadingPromise.then(() => {
-        assert.equal(
-            element.dispatchEvent.lastCall.args[0].type, 'show-alert');
-        assert.deepEqual(
-            element.dispatchEvent.lastCall.args[0].detail, {message: 'bar'}
-        );
-        done();
-      });
-      resolveConfirm('bar');
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
new file mode 100644
index 0000000..61876fe
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.ts
@@ -0,0 +1,592 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-settings-view';
+import {GrSettingsView} from './gr-settings-view';
+import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {GerritView} from '../../../services/router/router-model';
+import {queryAll, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {
+  AuthInfo,
+  AccountDetailInfo,
+  EmailAddress,
+  PreferencesInfo,
+  ServerInfo,
+  TopMenuItemInfo,
+} from '../../../types/common';
+import {
+  createDefaultPreferences,
+  DateFormat,
+  DefaultBase,
+  DiffViewMode,
+  EmailFormat,
+  EmailStrategy,
+  TimeFormat,
+} from '../../../constants/constants';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createAccountDetailWithId,
+  createPreferences,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import {GrSelect} from '../../shared/gr-select/gr-select';
+import {AppElementSettingsParam} from '../../gr-app-types';
+
+const basicFixture = fixtureFromElement('gr-settings-view');
+const blankFixture = fixtureFromElement('div');
+
+suite('gr-settings-view tests', () => {
+  let element: GrSettingsView;
+  let account: AccountDetailInfo;
+  let preferences: PreferencesInfo;
+  let config: ServerInfo;
+
+  function valueOf(title: string, id: string) {
+    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl?.textContent?.trim() === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
+      }
+    }
+    assert.fail(`element with title ${title} not found`);
+  }
+
+  // Because deepEqual isn't behaving in Safari.
+  function assertMenusEqual(
+    actual?: TopMenuItemInfo[],
+    expected?: TopMenuItemInfo[]
+  ) {
+    if (actual === undefined) {
+      assert.fail("assertMenusEqual 'actual' param is undefined");
+    } else if (expected === undefined) {
+      assert.fail("assertMenusEqual 'expected' param is undefined");
+    }
+    assert.equal(actual.length, expected.length);
+    for (let i = 0; i < actual.length; i++) {
+      assert.equal(actual[i].name, expected[i].name);
+      assert.equal(actual[i].url, expected[i].url);
+    }
+  }
+
+  function stubAddAccountEmail(statusCode: number) {
+    return stubRestApi('addAccountEmail').callsFake(() =>
+      Promise.resolve({status: statusCode} as Response)
+    );
+  }
+
+  setup(async () => {
+    account = {
+      ...createAccountDetailWithId(123),
+      name: 'user name',
+      email: 'user@email' as EmailAddress,
+      username: 'user username',
+    };
+    preferences = {
+      ...createPreferences(),
+      changes_per_page: 25,
+      date_format: DateFormat.UK,
+      time_format: TimeFormat.HHMM_12,
+      diff_view: DiffViewMode.UNIFIED,
+      email_strategy: EmailStrategy.ENABLED,
+      email_format: EmailFormat.HTML_PLAINTEXT,
+      default_base_for_merges: DefaultBase.FIRST_PARENT,
+      relative_date_in_change_table: false,
+      size_bar_in_change_table: true,
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+      ] as TopMenuItemInfo[],
+      change_table: [],
+    };
+    config = createServerInfo();
+
+    stubRestApi('getAccount').returns(Promise.resolve(account));
+    stubRestApi('getPreferences').returns(Promise.resolve(preferences));
+    stubRestApi('getAccountEmails').returns(Promise.resolve(undefined));
+    stubRestApi('getConfig').returns(Promise.resolve(config));
+    element = basicFixture.instantiate();
+
+    // Allow the element to render.
+    if (element._testOnly_loadingPromise)
+      await element._testOnly_loadingPromise;
+  });
+
+  test('theme changing', async () => {
+    const reloadStub = sinon.stub(element, 'reloadPage');
+
+    window.localStorage.removeItem('dark-theme');
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+    const themeToggle = queryAndAssert(
+      element,
+      '.darkToggle paper-toggle-button'
+    );
+    MockInteractions.tap(themeToggle);
+    assert.isTrue(window.localStorage.getItem('dark-theme') === 'true');
+    assert.isTrue(reloadStub.calledOnce);
+
+    element._isDark = true;
+    await flush();
+    MockInteractions.tap(themeToggle);
+    assert.isFalse(window.localStorage.getItem('dark-theme') === 'true');
+    assert.isTrue(reloadStub.calledTwice);
+  });
+
+  test('calls the title-change event', () => {
+    const titleChangedStub = sinon.stub();
+
+    // Create a new view.
+    const newElement = document.createElement('gr-settings-view');
+    newElement.addEventListener('title-change', titleChangedStub);
+
+    const blank = blankFixture.instantiate();
+    blank.appendChild(newElement);
+
+    flush();
+
+    assert.isTrue(titleChangedStub.called);
+    assert.equal(titleChangedStub.getCall(0).args[0].detail.title, 'Settings');
+  });
+
+  test('user preferences', async () => {
+    // Rendered with the expected preferences selected.
+    assert.equal(
+      Number(
+        (
+          valueOf('Changes per page', 'preferences')!
+            .firstElementChild as GrSelect
+        ).bindValue
+      ),
+      preferences.changes_per_page
+    );
+    assert.equal(
+      (
+        valueOf('Date/time format', 'preferences')!
+          .firstElementChild as GrSelect
+      ).bindValue,
+      preferences.date_format
+    );
+    assert.equal(
+      (valueOf('Date/time format', 'preferences')!.lastElementChild as GrSelect)
+        .bindValue,
+      preferences.time_format
+    );
+    assert.equal(
+      (
+        valueOf('Email notifications', 'preferences')!
+          .firstElementChild as GrSelect
+      ).bindValue,
+      preferences.email_strategy
+    );
+    assert.equal(
+      (valueOf('Email format', 'preferences')!.firstElementChild as GrSelect)
+        .bindValue,
+      preferences.email_format
+    );
+    assert.equal(
+      (
+        valueOf('Default Base For Merges', 'preferences')!
+          .firstElementChild as GrSelect
+      ).bindValue,
+      preferences.default_base_for_merges
+    );
+    assert.equal(
+      (
+        valueOf('Show Relative Dates In Changes Table', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (valueOf('Diff view', 'preferences')!.firstElementChild as GrSelect)
+        .bindValue,
+      preferences.diff_view
+    );
+    assert.equal(
+      (
+        valueOf('Show size bars in file list', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      true
+    );
+    assert.equal(
+      (
+        valueOf('Publish comments on push', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (
+        valueOf(
+          'Set new changes to "work in progress" by default',
+          'preferences'
+        )!.firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (
+        valueOf('Disable token highlighting on hover', 'preferences')!
+          .firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+    assert.equal(
+      (
+        valueOf(
+          'Insert Signed-off-by Footer For Inline Edit Changes',
+          'preferences'
+        )!.firstElementChild as HTMLInputElement
+      ).checked,
+      false
+    );
+
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+
+    const publishOnPush = valueOf('Publish comments on push', 'preferences')!
+      .firstElementChild!;
+
+    MockInteractions.tap(publishOnPush);
+
+    assert.isTrue(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assertMenusEqual(prefs.my, preferences.my);
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve(createDefaultPreferences());
+    });
+
+    // Save the change.
+    await element._handleSavePreferences();
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+  });
+
+  test('publish comments on push', async () => {
+    const publishCommentsOnPush = valueOf(
+      'Publish comments on push',
+      'preferences'
+    )!.firstElementChild!;
+    MockInteractions.tap(publishCommentsOnPush);
+
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.publish_comments_on_push, true);
+      return Promise.resolve(createDefaultPreferences());
+    });
+
+    // Save the change.
+    await element._handleSavePreferences();
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+  });
+
+  test('set new changes work-in-progress', async () => {
+    const newChangesWorkInProgress = valueOf(
+      'Set new changes to "work in progress" by default',
+      'preferences'
+    )!.firstElementChild!;
+    MockInteractions.tap(newChangesWorkInProgress);
+
+    assert.isFalse(element._menuChanged);
+    assert.isTrue(element._prefsChanged);
+
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assert.equal(prefs.work_in_progress_by_default, true);
+      return Promise.resolve(createDefaultPreferences());
+    });
+
+    // Save the change.
+    await element._handleSavePreferences();
+    assert.isFalse(element._prefsChanged);
+    assert.isFalse(element._menuChanged);
+  });
+
+  test('menu', async () => {
+    assert.isFalse(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    assertMenusEqual(element._localMenu, preferences.my);
+
+    const menu = element.$.menu.firstElementChild!;
+    let tableRows = queryAll(menu, 'tbody tr');
+    // let tableRows = menu.root.querySelectorAll('tbody tr');
+    assert.equal(tableRows.length, preferences.my.length);
+
+    // Add a menu item:
+    element.splice('_localMenu', 1, 0, {name: 'foo', url: 'bar', target: ''});
+    flush();
+
+    // tableRows = menu.root.querySelectorAll('tbody tr');
+    tableRows = queryAll(menu, 'tbody tr');
+    assert.equal(tableRows.length, preferences.my.length + 1);
+
+    assert.isTrue(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+
+    stubRestApi('savePreferences').callsFake(prefs => {
+      assertMenusEqual(prefs.my, element._localMenu);
+      return Promise.resolve(createDefaultPreferences());
+    });
+
+    await element._handleSaveMenu();
+    assert.isFalse(element._menuChanged);
+    assert.isFalse(element._prefsChanged);
+    assertMenusEqual(element.prefs.my, element._localMenu);
+  });
+
+  test('add email validation', () => {
+    assert.isFalse(element._isNewEmailValid('invalid email'));
+    assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
+
+    assert.isFalse(
+      element._computeAddEmailButtonEnabled('invalid email', true)
+    );
+    assert.isFalse(
+      element._computeAddEmailButtonEnabled('vaguely@valid.email', true)
+    );
+    assert.isTrue(
+      element._computeAddEmailButtonEnabled('vaguely@valid.email', false)
+    );
+  });
+
+  test('add email does not save invalid', () => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'invalid email';
+
+    element._handleAddEmailButton();
+
+    assert.isFalse(element._addingEmail);
+    assert.isFalse(addEmailStub.called);
+    assert.isNotOk(element._lastSentVerificationEmail);
+
+    assert.isFalse(addEmailStub.called);
+  });
+
+  test('add email does save valid', async () => {
+    const addEmailStub = stubAddAccountEmail(201);
+
+    assert.isFalse(element._addingEmail);
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(element._addingEmail);
+    assert.isTrue(addEmailStub.called);
+
+    assert.isTrue(addEmailStub.called);
+    await addEmailStub.lastCall.returnValue;
+    assert.isOk(element._lastSentVerificationEmail);
+  });
+
+  test('add email does not set last-email if error', async () => {
+    const addEmailStub = stubAddAccountEmail(500);
+
+    assert.isNotOk(element._lastSentVerificationEmail);
+    element._newEmail = 'valid@email.com';
+
+    element._handleAddEmailButton();
+
+    assert.isTrue(addEmailStub.called);
+    await addEmailStub.lastCall.returnValue;
+    assert.isNotOk(element._lastSentVerificationEmail);
+  });
+
+  test('emails are loaded without emailToken', () => {
+    const emailEditorLoadDataStub = sinon.stub(
+      element.$.emailEditor,
+      'loadData'
+    );
+    element.params = {
+      view: GerritView.SETTINGS,
+    } as AppElementSettingsParam;
+    element.connectedCallback();
+    assert.isTrue(emailEditorLoadDataStub.calledOnce);
+  });
+
+  test('_handleSaveChangeTable', () => {
+    let newColumns = ['Owner', 'Project', 'Branch'];
+    element._localChangeTableColumns = newColumns.slice(0);
+    element._showNumber = false;
+    element._handleSaveChangeTable();
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isNotOk(element.prefs.legacycid_in_change_table);
+
+    newColumns = ['Size'];
+    element._localChangeTableColumns = newColumns;
+    element._showNumber = true;
+    element._handleSaveChangeTable();
+    assert.deepEqual(element.prefs.change_table, newColumns);
+    assert.isTrue(element.prefs.legacycid_in_change_table);
+  });
+
+  test('reset menu item back to default', async () => {
+    const originalMenu = {
+      ...createDefaultPreferences(),
+      my: [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+        {url: '/third/url', name: 'third name', target: '_blank'},
+      ] as TopMenuItemInfo[],
+    };
+
+    stubRestApi('getDefaultPreferences').returns(Promise.resolve(originalMenu));
+
+    const updatedMenu = [
+      {url: '/first/url', name: 'first name', target: '_blank'},
+      {url: '/second/url', name: 'second name', target: '_blank'},
+      {url: '/third/url', name: 'third name', target: '_blank'},
+      {url: '/fourth/url', name: 'fourth name', target: '_blank'},
+    ];
+
+    element.set('_localMenu', updatedMenu);
+
+    await element._handleResetMenuButton();
+    assertMenusEqual(element._localMenu, originalMenu.my);
+  });
+
+  test('test that reset button is called', () => {
+    const overlayOpen = sinon.stub(element, '_handleResetMenuButton');
+
+    MockInteractions.tap(element.$.resetButton);
+
+    assert.isTrue(overlayOpen.called);
+  });
+
+  test('_showHttpAuth', () => {
+    const serverConfig: ServerInfo = {
+      ...createServerInfo(),
+      auth: {
+        git_basic_auth_policy: 'HTTP',
+      } as AuthInfo,
+    };
+
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig.auth.git_basic_auth_policy = 'HTTP_LDAP';
+    assert.isTrue(element._showHttpAuth(serverConfig));
+
+    serverConfig.auth.git_basic_auth_policy = 'LDAP';
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    serverConfig.auth.git_basic_auth_policy = 'OAUTH';
+    assert.isFalse(element._showHttpAuth(serverConfig));
+
+    assert.isFalse(element._showHttpAuth(undefined));
+  });
+
+  suite('_getFilterDocsLink', () => {
+    test('with http: docs base URL', () => {
+      const base = 'http://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with http: docs base URL without slash', () => {
+      const base = 'http://example.com';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'http://example.com/user-notify.html');
+    });
+
+    test('with https: docs base URL', () => {
+      const base = 'https://example.com/';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(result, 'https://example.com/user-notify.html');
+    });
+
+    test('without docs base URL', () => {
+      const result = element._getFilterDocsLink(null);
+      assert.equal(
+        result,
+        'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html'
+      );
+    });
+
+    test('ignores non HTTP links', () => {
+      const base = 'javascript://alert("evil");';
+      const result = element._getFilterDocsLink(base);
+      assert.equal(
+        result,
+        'https://gerrit-review.googlesource.com/' +
+          'Documentation/user-notify.html'
+      );
+    });
+  });
+
+  suite('when email verification token is provided', () => {
+    let resolveConfirm: (
+      value: string | PromiseLike<string | null> | null
+    ) => void;
+    let confirmEmailStub: sinon.SinonStub;
+    let emailEditorLoadDataStub: sinon.SinonStub;
+
+    setup(() => {
+      emailEditorLoadDataStub = sinon.stub(element.$.emailEditor, 'loadData');
+      confirmEmailStub = stubRestApi('confirmEmail').returns(
+        new Promise(resolve => {
+          resolveConfirm = resolve;
+        })
+      );
+
+      element.params = {view: GerritView.SETTINGS, emailToken: 'foo'};
+      element.connectedCallback();
+    });
+
+    test('it is used to confirm email via rest API', () => {
+      assert.isTrue(confirmEmailStub.calledOnce);
+      assert.isTrue(confirmEmailStub.calledWith('foo'));
+    });
+
+    test('emails are not loaded initially', () => {
+      assert.isFalse(emailEditorLoadDataStub.called);
+    });
+
+    test('user emails are loaded after email confirmed', async () => {
+      resolveConfirm('bar');
+      await element._testOnly_loadingPromise;
+      assert.isTrue(emailEditorLoadDataStub.calledOnce);
+    });
+
+    test('show-alert is fired when email is confirmed', async () => {
+      const dispatchEventSpy = sinon.spy(element, 'dispatchEvent');
+      resolveConfirm('bar');
+
+      await element._testOnly_loadingPromise;
+      assert.equal(
+        (dispatchEventSpy.lastCall.args[0] as CustomEvent).type,
+        'show-alert'
+      );
+      assert.deepEqual(
+        (dispatchEventSpy.lastCall.args[0] as CustomEvent).detail,
+        {message: 'bar'}
+      );
+    });
+  });
+});
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 0bee1d3..e853b58 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
@@ -76,9 +76,9 @@
               </td>
               <td>
                 <gr-copy-clipboard
-                  has-tooltip=""
-                  button-title="Copy SSH public key to clipboard"
-                  hide-input=""
+                  hasTooltip=""
+                  buttonTitle="Copy SSH public key to clipboard"
+                  hideInput=""
                   text="[[key.ssh_public_key]]"
                 >
                 </gr-copy-clipboard>
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 8f99baa..cd2c1df 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,7 +17,7 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-ssh-editor.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromElement('gr-ssh-editor');
 
@@ -25,7 +25,7 @@
   let element;
   let keys;
 
-  setup(done => {
+  setup(async () => {
     keys = [{
       seq: 1,
       ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
@@ -46,7 +46,8 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
@@ -61,7 +62,7 @@
     assert.equal(cells[0].textContent, keys[1].comment);
   });
 
-  test('remove key', done => {
+  test('remove key', async () => {
     const lastKey = keys[1];
 
     const saveStub = stubRestApi('deleteAccountSSHKey')
@@ -82,13 +83,11 @@
     assert.isTrue(element.hasUnsavedChanges);
     assert.isFalse(saveStub.called);
 
-    element.save().then(() => {
-      assert.isTrue(saveStub.called);
-      assert.equal(saveStub.lastCall.args[0], lastKey.seq);
-      assert.equal(element._keysToRemove.length, 0);
-      assert.isFalse(element.hasUnsavedChanges);
-      done();
-    });
+    await element.save();
+    assert.isTrue(saveStub.called);
+    assert.equal(saveStub.lastCall.args[0], lastKey.seq);
+    assert.equal(element._keysToRemove.length, 0);
+    assert.isFalse(element.hasUnsavedChanges);
   });
 
   test('show key', () => {
@@ -104,7 +103,7 @@
     assert.isTrue(openSpy.called);
   });
 
-  test('add key', done => {
+  test('add key', async () => {
     const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
     const newKeyObject = {
       seq: 3,
@@ -124,11 +123,12 @@
     assert.isFalse(element.$.addButton.disabled);
     assert.isFalse(element.$.newKey.disabled);
 
+    const promise = mockPromise();
     element._handleAddKey().then(() => {
       assert.isTrue(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
       assert.equal(element._keys.length, 3);
-      done();
+      promise.resolve();
     });
 
     assert.isTrue(element.$.addButton.disabled);
@@ -136,9 +136,10 @@
 
     assert.isTrue(addStub.called);
     assert.equal(addStub.lastCall.args[0], newKeyString);
+    await promise;
   });
 
-  test('add invalid key', done => {
+  test('add invalid key', async () => {
     const newKeyString = 'not even close to valid';
 
     const addStub = stubRestApi(
@@ -150,11 +151,12 @@
     assert.isFalse(element.$.addButton.disabled);
     assert.isFalse(element.$.newKey.disabled);
 
+    const promise = mockPromise();
     element._handleAddKey().then(() => {
       assert.isFalse(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
       assert.equal(element._keys.length, 2);
-      done();
+      promise.resolve();
     });
 
     assert.isTrue(element.$.addButton.disabled);
@@ -162,6 +164,7 @@
 
     assert.isTrue(addStub.called);
     assert.equal(addStub.lastCall.args[0], newKeyString);
+    await promise;
   });
 });
 
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 6178e7a..4381a59 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
@@ -31,6 +31,7 @@
 import {hasOwnProperty} from '../../../utils/common-util';
 import {ProjectWatchInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {IronInputElement} from '@polymer/iron-input';
 
 const NOTIFICATION_TYPES = [
   {name: 'Changes', key: 'notify_new_changes'},
@@ -43,9 +44,11 @@
 export interface GrWatchedProjectsEditor {
   $: {
     newFilter: HTMLInputElement;
+    newFilterInput: IronInputElement;
     newProject: GrAutocomplete;
   };
 }
+
 @customElement('gr-watched-projects-editor')
 export class GrWatchedProjectsEditor extends PolymerElement {
   static get template() {
@@ -62,7 +65,7 @@
   _projectsToRemove: ProjectWatchInfo[] = [];
 
   @property({type: Object})
-  _query?: AutocompleteQuery;
+  _query: AutocompleteQuery;
 
   private readonly restApiService = appContext.restApiService;
 
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 edc8fb2..fb65a03 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
@@ -102,13 +102,13 @@
           </th>
           <th colspan$="[[_getTypeCount()]]">
             <iron-input
+              id="newFilterInput"
               class="newFilterInput"
               placeholder="branch:name, or other search expression"
             >
               <input
                 id="newFilter"
                 class="newFilterInput"
-                is="iron-input"
                 placeholder="branch:name, or other search expression"
               />
             </iron-input>
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.ts
similarity index 67%
rename from polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.js
rename to polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.ts
index aac8995..c0580f6 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.ts
@@ -15,44 +15,53 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-watched-projects-editor.js';
-import {stubRestApi} from '../../../test/test-utils.js';
+import '../../../test/common-test-setup-karma';
+import './gr-watched-projects-editor';
+import {GrWatchedProjectsEditor} from './gr-watched-projects-editor';
+import {stubRestApi} from '../../../test/test-utils';
+import {ProjectWatchInfo} from '../../../types/common';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-watched-projects-editor');
 
 suite('gr-watched-projects-editor tests', () => {
-  let element;
+  let element: GrWatchedProjectsEditor;
 
-  setup(done => {
+  setup(async () => {
     const projects = [
       {
         project: 'project a',
         notify_submitted_changes: true,
         notify_abandoned_changes: true,
-      }, {
+      },
+      {
         project: 'project b',
         filter: 'filter 1',
         notify_new_changes: true,
-      }, {
+      },
+      {
         project: 'project b',
         filter: 'filter 2',
-      }, {
+      },
+      {
         project: 'project c',
         notify_new_changes: true,
         notify_new_patch_sets: true,
         notify_all_comments: true,
       },
-    ];
+    ] as ProjectWatchInfo[];
 
     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: [],
-        }});
+        return Promise.resolve({
+          'the project': {
+            id: 'the project',
+            state: 'ACTIVE',
+            web_links: [],
+          },
+        });
       } else {
         return Promise.resolve({});
       }
@@ -60,18 +69,17 @@
 
     element = basicFixture.instantiate();
 
-    element.loadData().then(() => { flush(done); });
+    await element.loadData();
+    await flush();
   });
 
   test('renders', () => {
-    const rows = element.shadowRoot
-        .querySelector('table').querySelectorAll('tbody tr');
+    const rows = queryAndAssert(element, 'table').querySelectorAll('tbody tr');
     assert.equal(rows.length, 4);
 
-    function getKeysOfRow(row) {
-      const boxes = rows[row].querySelectorAll('input[checked]');
-      return Array.prototype.map.call(boxes,
-          e => e.getAttribute('data-key'));
+    function getKeysOfRow(row: number) {
+      const boxes = queryAll(rows[row], 'input[checked]');
+      return Array.prototype.map.call(boxes, e => e.getAttribute('data-key'));
     }
 
     let checkedKeys = getKeysOfRow(0);
@@ -93,27 +101,21 @@
     assert.equal(checkedKeys[2], 'notify_all_comments');
   });
 
-  test('_getProjectSuggestions empty', done => {
-    element._getProjectSuggestions('nonexistent').then(projects => {
-      assert.equal(projects.length, 0);
-      done();
-    });
+  test('_getProjectSuggestions empty', async () => {
+    const projects = await element._getProjectSuggestions('nonexistent');
+    assert.equal(projects.length, 0);
   });
 
-  test('_getProjectSuggestions non-empty', done => {
-    element._getProjectSuggestions('the project').then(projects => {
-      assert.equal(projects.length, 1);
-      assert.equal(projects[0].name, 'the project');
-      done();
-    });
+  test('_getProjectSuggestions non-empty', async () => {
+    const projects = await element._getProjectSuggestions('the project');
+    assert.equal(projects.length, 1);
+    assert.equal(projects[0].name, 'the project');
   });
 
-  test('_getProjectSuggestions non-empty with two letter project', done => {
-    element._getProjectSuggestions('th').then(projects => {
-      assert.equal(projects.length, 1);
-      assert.equal(projects[0].name, 'the project');
-      done();
-    });
+  test('_getProjectSuggestions non-empty with two letter project', async () => {
+    const projects = await element._getProjectSuggestions('th');
+    assert.equal(projects.length, 1);
+    assert.equal(projects[0].name, 'the project');
   });
 
   test('_canAddProject', () => {
@@ -157,41 +159,44 @@
   test('_handleAddProject', () => {
     element.$.newProject.value = 'project d';
     element.$.newProject.setText('project d');
-    element.$.newFilter.bindValue = '';
+    element.$.newFilterInput.bindValue = '';
 
     element._handleAddProject();
 
-    assert.equal(element._projects.length, 5);
-    assert.equal(element._projects[4].project, 'project d');
-    assert.isNotOk(element._projects[4].filter);
-    assert.isTrue(element._projects[4]._is_local);
+    const projects = element._projects!;
+    assert.equal(projects.length, 5);
+    assert.equal(projects[4].project, 'project d');
+    assert.isNotOk(projects[4].filter);
+    assert.isTrue(projects[4]._is_local);
   });
 
   test('_handleAddProject with invalid inputs', () => {
     element.$.newProject.value = 'project b';
     element.$.newProject.setText('project b');
-    element.$.newFilter.bindValue = 'filter 1';
+    element.$.newFilterInput.bindValue = 'filter 1';
     element.$.newFilter.value = 'filter 1';
 
     element._handleAddProject();
 
-    assert.equal(element._projects.length, 4);
+    assert.equal(element._projects!.length, 4);
   });
 
   test('_handleRemoveProject', () => {
-    assert.equal(element._projectsToRemove, 0);
-    const button = element.shadowRoot
-        .querySelector('table tbody tr:nth-child(2) gr-button');
+    assert.deepEqual(element._projectsToRemove, []);
+
+    const button = queryAndAssert(
+      element,
+      'table tbody tr:nth-child(2) gr-button'
+    );
     MockInteractions.tap(button);
 
     flush();
 
-    const rows = element.shadowRoot
-        .querySelector('table tbody').querySelectorAll('tr');
+    const rows = queryAndAssert(element, 'table tbody').querySelectorAll('tr');
+
     assert.equal(rows.length, 3);
 
     assert.equal(element._projectsToRemove.length, 1);
     assert.equal(element._projectsToRemove[0].project, 'project b');
   });
 });
-
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 dab778b..31c62b1 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,19 +17,14 @@
 import '../gr-account-link/gr-account-link';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-chip_html';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {classMap} from 'lit/directives/class-map';
 
 @customElement('gr-account-chip')
-export class GrAccountChip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountChip extends LitElement {
   /**
    * Fired to indicate a key was pressed while this chip was focused.
    *
@@ -64,10 +59,10 @@
   @property({type: String})
   voteableText?: string;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   removable = false;
 
   /**
@@ -78,7 +73,7 @@
   @property({type: Boolean})
   highlightAttention = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   showAvatar?: boolean;
 
   @property({type: Boolean})
@@ -86,18 +81,116 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  ready() {
-    super.ready();
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          overflow: hidden;
+        }
+        .container {
+          align-items: center;
+          background-color: var(--background-color-primary);
+          /** round */
+          border-radius: var(--account-chip-border-radius, 20px);
+          border: 1px solid var(--border-color);
+          display: inline-flex;
+          padding: 0 1px;
+        }
+        :host:focus {
+          border-color: transparent;
+          box-shadow: none;
+          outline: none;
+        }
+        :host:focus .container,
+        :host:focus gr-button {
+          background: #ccc;
+        }
+        .transparentBackground,
+        gr-button.transparentBackground {
+          background-color: transparent;
+        }
+        :host([disabled]) {
+          opacity: 0.6;
+          pointer-events: none;
+        }
+        iron-icon {
+          height: 1.2rem;
+          width: 1.2rem;
+        }
+        .container gr-account-link::part(gr-account-link-text) {
+          color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
+    const customStyle = html`
+      <style>
+        .container {
+          --account-label-padding-horizontal: 6px;
+        }
+        gr-button.remove::part(paper-button),
+        gr-button.remove:hover::part(paper-button),
+        gr-button.remove:focus::part(paper-button) {
+          border-top-width: 0;
+          border-right-width: 0;
+          border-bottom-width: 0;
+          border-left-width: 0;
+          color: var(--deemphasized-text-color);
+          font-weight: var(--font-weight-normal);
+          height: 0.6em;
+          line-height: 10px;
+          /* This cancels most of the --account-label-padding-horizontal. */
+          margin-left: -4px;
+          padding: 0 2px 0 0;
+          text-decoration: none;
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div
+        class="${classMap({
+          container: true,
+          transparentBackground: this.transparentBackground,
+        })}"
+      >
+        <gr-account-link
+          .account="${this.account}"
+          .change="${this.change}"
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          .voteableText=${this.voteableText}
+        >
+        </gr-account-link>
+        <slot name="vote-chip"></slot>
+        <gr-button
+          id="remove"
+          link=""
+          ?hidden=${!this.removable}
+          aria-label="Remove"
+          class="${classMap({
+            remove: true,
+            transparentBackground: this.transparentBackground,
+          })}"
+          @click=${this._handleRemoveTap}
+        >
+          <iron-icon icon="gr-icons:close"></iron-icon>
+        </gr-button>
+      </div>`;
+  }
+
+  constructor() {
+    super();
     this._getHasAvatars().then(hasAvatars => {
       this.showAvatar = hasAvatars;
     });
   }
 
-  _getBackgroundClass(transparent: boolean) {
-    return transparent ? 'transparentBackground' : '';
-  }
-
   _handleRemoveTap(e: MouseEvent) {
     e.preventDefault();
     this.dispatchEvent(
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
deleted file mode 100644
index e5efebb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip_html.ts
+++ /dev/null
@@ -1,114 +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>
-    :host {
-      display: block;
-      overflow: hidden;
-    }
-    .container {
-      align-items: center;
-      background-color: var(--background-color-primary);
-      /** round */
-      border-radius: var(--account-chip-border-radius, 20px);
-      border: 1px solid var(--border-color);
-      display: inline-flex;
-      padding: 0 1px;
-
-      --account-label-padding-horizontal: 6px;
-      --gr-account-label-text-style: {
-        color: var(--deemphasized-text-color);
-      }
-    }
-    :host([show-avatar]) .container {
-    }
-    :host([removable]) .container {
-    }
-    gr-button.remove {
-      --gr-remove-button-style: {
-        border-top-width: 0;
-        border-right-width: 0;
-        border-bottom-width: 0;
-        border-left-width: 0;
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-normal);
-        height: 0.6em;
-        line-height: 10px;
-        /* This cancels most of the --account-label-padding-horizontal. */
-        margin-left: -4px;
-        padding: 0 2px 0 0;
-        text-decoration: none;
-      }
-    }
-
-    gr-button.remove:hover,
-    gr-button.remove:focus {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    gr-button.remove {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    :host:focus {
-      border-color: transparent;
-      box-shadow: none;
-      outline: none;
-    }
-    :host:focus .container,
-    :host:focus gr-button {
-      background: #ccc;
-    }
-    .transparentBackground,
-    gr-button.transparentBackground {
-      background-color: transparent;
-    }
-    :host([disabled]) {
-      opacity: 0.6;
-      pointer-events: none;
-    }
-    iron-icon {
-      height: 1.2rem;
-      width: 1.2rem;
-    }
-  </style>
-  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-    <gr-account-link
-      account="[[account]]"
-      change="[[change]]"
-      force-attention="[[forceAttention]]"
-      highlight-attention="[[highlightAttention]]"
-      voteable-text="[[voteableText]]"
-    >
-    </gr-account-link>
-    <gr-button
-      id="remove"
-      link=""
-      hidden$="[[!removable]]"
-      hidden=""
-      aria-label="Remove"
-      class$="remove [[_getBackgroundClass(transparentBackground)]]"
-      on-click="_handleRemoveTap"
-    >
-      <iron-icon icon="gr-icons:close"></iron-icon>
-    </gr-button>
-  </div>
-`;
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 d793a8d..acb8348 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
@@ -19,7 +19,10 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-account-entry_html';
 import {customElement, property} from '@polymer/decorators';
-import {GrAutocomplete} from '../gr-autocomplete/gr-autocomplete';
+import {
+  AutocompleteQuery,
+  GrAutocomplete,
+} from '../gr-autocomplete/gr-autocomplete';
 
 export interface GrAccountEntry {
   $: {
@@ -51,28 +54,25 @@
    */
 
   @property({type: Boolean})
-  allowAnyInput?: boolean;
+  allowAnyInput = false;
 
   @property({type: Boolean})
-  borderless?: boolean;
+  borderless = false;
 
   @property({type: String})
-  placeholder?: string;
-
-  @property({type: Number})
-  suggestFrom = 0;
+  placeholder = '';
 
   @property({type: Object, notify: true})
-  querySuggestions = () => Promise.resolve([]);
+  querySuggestions: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: String, observer: '_inputTextChanged'})
-  _inputText?: string;
+  _inputText = '';
 
   get focusStart() {
     return this.$.input.focusStart;
   }
 
-  focus() {
+  override focus() {
     this.$.input.focus();
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
index c6c2b7f..d84ef62 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_html.ts
@@ -28,7 +28,6 @@
     id="input"
     borderless="[[borderless]]"
     placeholder="[[placeholder]]"
-    threshold="[[suggestFrom]]"
     query="[[querySuggestions]]"
     allow-non-suggested-values="[[allowAnyInput]]"
     on-commit="_handleInputCommit"
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
deleted file mode 100644
index 4430c65..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-entry.js';
-
-const basicFixture = fixtureFromElement('gr-account-entry');
-
-suite('gr-account-entry tests', () => {
-  let element;
-
-  const suggestion1 = {
-    email: 'email1@example.com',
-    _account_id: 1,
-    some_property: 'value',
-  };
-  const suggestion2 = {
-    email: 'email2@example.com',
-    _account_id: 2,
-  };
-  const suggestion3 = {
-    email: 'email25@example.com',
-    _account_id: 25,
-    some_other_property: 'other value',
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('stubbed values for querySuggestions', () => {
-    setup(() => {
-      element.querySuggestions = input => Promise.resolve([
-        suggestion1,
-        suggestion2,
-        suggestion3,
-      ]);
-    });
-  });
-
-  test('account-text-changed fired when input text changed and allowAnyInput',
-      () => {
-        // Spy on query, as that is called when _updateSuggestions proceeds.
-        const changeStub = sinon.stub();
-        element.allowAnyInput = true;
-        element.querySuggestions = input => Promise.resolve([]);
-        element.addEventListener('account-text-changed', changeStub);
-        element.$.input.text = 'a';
-        assert.isTrue(changeStub.calledOnce);
-        element.$.input.text = 'ab';
-        assert.isTrue(changeStub.calledTwice);
-      });
-
-  test('account-text-changed not fired when input text changed without ' +
-      'allowAnyInput', () => {
-    // Spy on query, as that is called when _updateSuggestions proceeds.
-    const changeStub = sinon.stub();
-    element.querySuggestions = input => Promise.resolve([]);
-    element.addEventListener('account-text-changed', changeStub);
-    element.$.input.text = 'a';
-    assert.isFalse(changeStub.called);
-  });
-
-  test('setText', () => {
-    // Spy on query, as that is called when _updateSuggestions proceeds.
-    const suggestSpy = sinon.spy(element.$.input, 'query');
-    element.setText('test text');
-    flush();
-
-    assert.equal(element.$.input.$.input.value, 'test text');
-    assert.isFalse(suggestSpy.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
new file mode 100644
index 0000000..4bb2232
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-entry/gr-account-entry_test.ts
@@ -0,0 +1,65 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-account-entry';
+import {GrAccountEntry} from './gr-account-entry';
+
+const basicFixture = fixtureFromElement('gr-account-entry');
+
+suite('gr-account-entry tests', () => {
+  let element: GrAccountEntry;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('account-text-changed fired when input text changed and allowAnyInput', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const changeStub = sinon.stub();
+    element.allowAnyInput = true;
+    element.querySuggestions = () => Promise.resolve([]);
+    element.addEventListener('account-text-changed', changeStub);
+    element.$.input.text = 'a';
+    assert.isTrue(changeStub.calledOnce);
+    element.$.input.text = 'ab';
+    assert.isTrue(changeStub.calledTwice);
+  });
+
+  test(
+    'account-text-changed not fired when input text changed without ' +
+      'allowAnyInput',
+    () => {
+      // Spy on query, as that is called when _updateSuggestions proceeds.
+      const changeStub = sinon.stub();
+      element.querySuggestions = () => Promise.resolve([]);
+      element.addEventListener('account-text-changed', changeStub);
+      element.$.input.text = 'a';
+      assert.isFalse(changeStub.called);
+    }
+  );
+
+  test('setText', () => {
+    // Spy on query, as that is called when _updateSuggestions proceeds.
+    const suggestSpy = sinon.spy(element.$.input, 'query');
+    element.setText('test text');
+    flush();
+
+    assert.equal(element.$.input.$.input.value, 'test text');
+    assert.isFalse(suggestSpy.called);
+  });
+});
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 f6bdf33..dabf761 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
@@ -15,30 +15,27 @@
  * limitations under the License.
  */
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-hovercard-account/gr-hovercard-account';
-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 {isSelf, isServiceUser} from '../../../utils/account-util';
-import {customElement, property} from '@polymer/decorators';
 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';
+import {LitElement, css, html} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+import {classMap} from 'lit/directives/class-map';
+import {modifierPressed} from '../../../utils/dom-util';
+import {getRemovedByIconClickReason} from '../../../utils/attention-set-util';
 
 @customElement('gr-account-label')
-export class GrAccountLabel extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLabel extends LitElement {
   @property({type: Object})
-  account!: AccountInfo;
+  account?: AccountInfo;
 
   @property({type: Object})
   _selfAccount?: AccountInfo;
@@ -49,7 +46,7 @@
    * related features like adding the user as a reviewer.
    */
   @property({type: Object})
-  change!: ChangeInfo;
+  change?: ChangeInfo;
 
   @property({type: String})
   voteableText?: string;
@@ -83,37 +80,197 @@
 
   @property({
     type: Boolean,
-    reflectToAttribute: true,
-    computed:
-      '_computeCancelLeftPadding(hideAvatar, _config, ' +
-      'highlightAttention, account, change, forceAttention)',
+    reflect: true,
   })
   cancelLeftPadding = false;
 
   @property({type: Boolean})
   hideStatus = false;
 
-  @property({type: Object})
+  @state()
   _config?: ServerInfo;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
+  selectionChipStyle = false;
+
+  @property({
+    type: Boolean,
+    reflect: true,
+  })
   selected = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   deselected = false;
 
   reporting: ReportingService;
 
   private readonly restApiService = appContext.restApiService;
 
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+          position: relative;
+          border-radius: var(--label-border-radius);
+          box-sizing: border-box;
+          white-space: nowrap;
+          padding: 0 var(--account-label-padding-horizontal, 0);
+        }
+        /* If the first element is the avatar, then we cancel the left padding,
+        so we can fit nicely into the gr-account-chip rounding. The obvious
+        alternative of 'chip has padding' and 'avatar gets negative margin'
+        does not work, because we need 'overflow:hidden' on the label. */
+        :host([cancelLeftPadding]) {
+          padding-left: 0;
+        }
+        :host::after {
+          content: var(--account-label-suffix);
+        }
+        :host([deselected][selectionChipStyle]) {
+          background-color: var(--background-color-primary);
+          border: 1px solid var(--comment-separator-color);
+          border-radius: 8px;
+          color: var(--deemphasized-text-color);
+        }
+        :host([selected][selectionChipStyle]) {
+          background-color: var(--chip-selected-background-color);
+          border: 1px solid var(--chip-selected-background-color);
+          border-radius: 8px;
+          color: var(--chip-selected-text-color);
+        }
+        :host([selected]) iron-icon.attention {
+          color: var(--chip-selected-text-color);
+        }
+        gr-avatar {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+          vertical-align: top;
+          position: relative;
+          top: 1px;
+        }
+        #attentionButton {
+          /* This negates the 4px horizontal padding, which we appreciate as a
+         larger click target, but which we don't want to consume space. :-) */
+          margin: 0 -4px 0 -4px;
+          vertical-align: top;
+        }
+        iron-icon.attention {
+          color: var(--deemphasized-text-color);
+          width: 12px;
+          height: 12px;
+          vertical-align: top;
+        }
+        iron-icon.status {
+          color: var(--deemphasized-text-color);
+          width: 14px;
+          height: 14px;
+          vertical-align: top;
+          position: relative;
+          top: 2px;
+        }
+        .name {
+          display: inline-block;
+          text-decoration: inherit;
+          vertical-align: top;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          max-width: var(--account-max-length, 180px);
+        }
+        .hasAttention .name {
+          font-weight: var(--font-weight-bold);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const {account, change, highlightAttention, forceAttention, _config} = this;
+    if (!account) return;
+    const hasAttention =
+      forceAttention ||
+      this._hasUnforcedAttention(highlightAttention, account, change);
+    this.deselected = !this.selected;
+    const hasAvatars = !!_config?.plugin?.has_avatars;
+    this.cancelLeftPadding = !this.hideAvatar && !hasAttention && hasAvatars;
+
+    return html`<span>
+        ${!this.hideHovercard
+          ? html`<gr-hovercard-account
+              for="hovercardTarget"
+              .account=${account}
+              .change=${change}
+              .highlightAttention=${highlightAttention}
+              .voteableText=${this.voteableText}
+            ></gr-hovercard-account>`
+          : ''}
+        ${hasAttention
+          ? html` <gr-tooltip-content
+              ?has-tooltip=${this._computeAttentionButtonEnabled(
+                highlightAttention,
+                account,
+                change,
+                false,
+                this._selfAccount
+              )}
+              title="${this._computeAttentionIconTitle(
+                highlightAttention,
+                account,
+                change,
+                forceAttention,
+                this.selected,
+                this._selfAccount
+              )}"
+            >
+              <gr-button
+                id="attentionButton"
+                link=""
+                aria-label="Remove user from attention set"
+                @click=${this._handleRemoveAttentionClick}
+                ?disabled=${!this._computeAttentionButtonEnabled(
+                  highlightAttention,
+                  account,
+                  change,
+                  this.selected,
+                  this._selfAccount
+                )}
+                ><iron-icon
+                  class="attention"
+                  icon="gr-icons:attention"
+                ></iron-icon>
+              </gr-button>
+            </gr-tooltip-content>`
+          : ''}
+      </span>
+      <span
+        id="hovercardTarget"
+        tabindex="0"
+        @keydown="${(e: KeyboardEvent) => this.handleKeyDown(e)}"
+        class="${classMap({
+          hasAttention: !!hasAttention,
+        })}"
+      >
+        ${!this.hideAvatar
+          ? html`<gr-avatar .account="${account}" imageSize="32"></gr-avatar>`
+          : ''}
+        <span class="text" part="gr-account-label-text">
+          <span class="name"
+            >${this._computeName(account, this.firstName, this._config)}</span
+          >
+          ${!this.hideStatus && account.status
+            ? html`<iron-icon
+                class="status"
+                icon="gr-icons:calendar"
+              ></iron-icon>`
+            : ''}
+        </span>
+      </span>`;
+  }
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
-  }
-
-  /** @override */
-  ready() {
-    super.ready();
     this.restApiService.getConfig().then(config => {
       this._config = config;
     });
@@ -122,90 +279,51 @@
     });
     this.addEventListener('attention-set-updated', () => {
       // For re-evaluation of everything that depends on 'change'.
-      this.change = {...this.change};
+      if (this.change) this.change = {...this.change};
     });
   }
 
+  handleKeyDown(e: KeyboardEvent) {
+    if (modifierPressed(e)) return;
+    // Only react to `return` and `space`.
+    if (e.keyCode !== 13 && e.keyCode !== 32) return;
+    e.preventDefault();
+    e.stopPropagation();
+    this.dispatchEvent(new Event('click'));
+  }
+
   _isAttentionSetEnabled(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
-    return (
-      !!config &&
-      !!config.change &&
-      !!config.change.enable_attention_set &&
-      !!highlight &&
-      !!change &&
-      !!account &&
-      !isServiceUser(account)
-    );
-  }
-
-  _computeCancelLeftPadding(
-    hideAvatar: boolean,
-    config: ServerInfo | undefined,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    const hasAvatars = !!config?.plugin?.has_avatars;
-    return (
-      !hideAvatar &&
-      !this._hasAttention(config, highlight, account, change, force) &&
-      hasAvatars
-    );
-  }
-
-  _hasAttention(
-    config: ServerInfo | undefined,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return (
-      force || this._hasUnforcedAttention(config, highlight, account, change)
-    );
+    return highlight && !!change && !!account && !isServiceUser(account);
   }
 
   _hasUnforcedAttention(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo
+    change?: ChangeInfo
   ) {
     return (
-      this._isAttentionSetEnabled(config, highlight, account, change) &&
+      this._isAttentionSetEnabled(highlight, account, change) &&
+      change &&
       change.attention_set &&
       !!account._account_id &&
       hasOwnProperty(change.attention_set, account._account_id)
     );
   }
 
-  _computeHasAttentionClass(
-    config: ServerInfo | undefined,
-    highlight: boolean,
-    account: AccountInfo,
-    change: ChangeInfo,
-    force: boolean
-  ) {
-    return this._hasAttention(config, highlight, account, change, force)
-      ? 'hasAttention'
-      : '';
-  }
-
   _computeName(
     account?: AccountInfo,
-    config?: ServerInfo,
-    firstName?: boolean
+    firstName?: boolean,
+    config?: ServerInfo
   ) {
     return getDisplayName(config, account, firstName);
   }
 
   _handleRemoveAttentionClick(e: MouseEvent) {
+    if (!this.account || !this.change) return;
     if (this.selected) return;
     e.preventDefault();
     e.stopPropagation();
@@ -224,8 +342,7 @@
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const selfName = getDisplayName(this._config, this._selfAccount);
-    const reason = `Removed by ${selfName} by clicking the attention icon`;
+    const reason = getRemovedByIconClickReason(this._selfAccount, this._config);
     if (this.change.attention_set)
       delete this.change.attention_set[this.account._account_id];
     // For re-evaluation of everything that depends on 'change'.
@@ -247,6 +364,7 @@
   }
 
   _reportingDetails() {
+    if (!this.account) return;
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
@@ -268,36 +386,33 @@
   }
 
   _computeAttentionButtonEnabled(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
-    selfAccount: AccountInfo,
-    selected: boolean
+    change: ChangeInfo | undefined,
+    selected: boolean,
+    selfAccount?: AccountInfo
   ) {
     if (selected) return true;
     return (
-      this._hasUnforcedAttention(config, highlight, account, change) &&
+      !!this._hasUnforcedAttention(highlight, account, change) &&
       (isInvolved(change, selfAccount) || isSelf(account, selfAccount))
     );
   }
 
   _computeAttentionIconTitle(
-    config: ServerInfo | undefined,
     highlight: boolean,
     account: AccountInfo,
-    change: ChangeInfo,
-    selfAccount: AccountInfo,
+    change: ChangeInfo | undefined,
     force: boolean,
-    selected: boolean
+    selected: boolean,
+    selfAccount?: AccountInfo
   ) {
     const enabled = this._computeAttentionButtonEnabled(
-      config,
       highlight,
       account,
       change,
-      selfAccount,
-      selected
+      selected,
+      selfAccount
     );
     return enabled
       ? 'Click to remove the user from the attention set'
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
deleted file mode 100644
index ff40aeb..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_html.ts
+++ /dev/null
@@ -1,142 +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>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-      position: relative;
-      border-radius: var(--label-border-radius);
-      box-sizing: border-box;
-      white-space: nowrap;
-      padding: 0 var(--account-label-padding-horizontal, 0);
-    }
-    /* If the first element is the avatar, then we cancel the left padding, so
-       we can fit nicely into the gr-account-chip rounding.
-       The obvious alternative of 'chip has padding' and 'avatar gets negative
-       margin' does not work, because we need 'overflow:hidden' on the label. */
-    :host([cancel-left-padding]) {
-      padding-left: 0;
-    }
-    :host::after {
-      content: var(--account-label-suffix);
-    }
-    :host([deselected]) {
-      background-color: var(--background-color-primary);
-      border: 1px solid var(--comment-separator-color);
-      border-radius: 8px;
-      color: var(--deemphasized-text-color);
-    }
-    :host([selected]) {
-      background-color: var(--chip-selected-background-color);
-      border: 1px solid var(--chip-selected-background-color);
-      border-radius: 8px;
-      color: var(--chip-selected-text-color);
-    }
-    :host([selected]) iron-icon.attention {
-      color: var(--chip-selected-text-color);
-    }
-    gr-avatar {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-      vertical-align: top;
-      position: relative;
-      top: 1px;
-    }
-    .text {
-      @apply --gr-account-label-text-style;
-    }
-    .text:hover {
-      @apply --gr-account-label-text-hover-style;
-    }
-    #attentionButton {
-      /* This negates the 4px horizontal padding, which we appreciate as a
-         larger click target, but which we don't want to consume space. :-) */
-      margin: 0 -4px 0 -4px;
-      vertical-align: top;
-    }
-    iron-icon.attention {
-      color: var(--deemphasized-text-color);
-      width: 12px;
-      height: 12px;
-      vertical-align: top;
-    }
-    iron-icon.status {
-      color: var(--deemphasized-text-color);
-      width: 14px;
-      height: 14px;
-      vertical-align: top;
-      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);
-    }
-  </style>
-  <span>
-    <template is="dom-if" if="[[!hideHovercard]]">
-      <gr-hovercard-account
-        for="hovercardTarget"
-        account="[[account]]"
-        change="[[change]]"
-        highlight-attention="[[highlightAttention]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-hovercard-account>
-    </template>
-    <template
-      is="dom-if"
-      if="[[_hasAttention(_config, highlightAttention, account, change, forceAttention)]]"
-    >
-      <gr-button
-        id="attentionButton"
-        link=""
-        aria-label="Remove user from attention set"
-        on-click="_handleRemoveAttentionClick"
-        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>
-  </span>
-  <span
-    id="hovercardTarget"
-    class$="[[_computeHasAttentionClass(_config, highlightAttention, account, change, forceAttention)]]"
-  >
-    <template is="dom-if" if="[[!hideAvatar]]">
-      <gr-avatar account="[[account]]" image-size="32"></gr-avatar>
-    </template>
-    <span class="text">
-      <span class="name">[[_computeName(account, _config, firstName)]]</span>
-      <template is="dom-if" if="[[!hideStatus]]">
-        <template is="dom-if" if="[[account.status]]">
-          <iron-icon class="status" icon="gr-icons:calendar"></iron-icon>
-        </template>
-      </template>
-    </span>
-  </span>
-`;
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
deleted file mode 100644
index f37aa01..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.js
+++ /dev/null
@@ -1,116 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-label.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-account-label');
-
-suite('gr-account-label tests', () => {
-  let element;
-
-  function createAccount(name, id) {
-    return {name, _account_id: id};
-  }
-
-  setup(() => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    element = basicFixture.instantiate();
-    element._config = {
-      user: {
-        anonymous_coward_name: 'Anonymous Coward',
-      },
-    };
-  });
-
-  test('null guard', () => {
-    assert.doesNotThrow(() => {
-      element.account = null;
-    });
-  });
-
-  suite('_computeName', () => {
-    test('not showing anonymous', () => {
-      const account = {name: 'Wyatt'};
-      assert.deepEqual(element._computeName(account, null), 'Wyatt');
-    });
-
-    test('showing anonymous but no config', () => {
-      const account = {};
-      assert.deepEqual(element._computeName(account, null),
-          'Anonymous');
-    });
-
-    test('test for Anonymous Coward user and replace with Anonymous', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'Anonymous Coward',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'Anonymous');
-    });
-
-    test('test for anonymous_coward_name', () => {
-      const config = {
-        user: {
-          anonymous_coward_name: 'TestAnon',
-        },
-      };
-      const account = {};
-      assert.deepEqual(element._computeName(account, config),
-          'TestAnon');
-    });
-  });
-
-  suite('attention set', () => {
-    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 = kermit;
-      element.account = createAccount('ernie', 42);
-      element.change = {
-        attention_set: {42: {}},
-        owner: kermit,
-        reviewers: {},
-      };
-      await flush();
-    });
-
-    test('show attention button', () => {
-      assert.ok(element.shadowRoot.querySelector('#attentionButton'));
-    });
-
-    test('tap attention button', () => {
-      const apiStub = stubRestApi(
-          'removeFromAttentionSet')
-          .callsFake(() => Promise.resolve());
-      const button = element.shadowRoot.querySelector('#attentionButton');
-      assert.ok(button);
-      MockInteractions.tap(button);
-      assert.isTrue(apiStub.calledOnce);
-      assert.equal(apiStub.lastCall.args[1], 42);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
new file mode 100644
index 0000000..574e450
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.ts
@@ -0,0 +1,136 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-account-label';
+import {
+  queryAndAssert,
+  spyRestApi,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {GrAccountLabel} from './gr-account-label';
+import {AccountDetailInfo, ServerInfo} from '../../../types/common';
+import {
+  createAccountDetailWithId,
+  createChange,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
+const basicFixture = fixtureFromElement('gr-account-label');
+
+suite('gr-account-label tests', () => {
+  let element: GrAccountLabel;
+  const kermit: AccountDetailInfo = {
+    ...createAccountDetailWithId(31),
+    name: 'kermit',
+  };
+
+  setup(() => {
+    stubRestApi('getAccount').callsFake(() => Promise.resolve(kermit));
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    element = basicFixture.instantiate();
+    element._config = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+  });
+
+  suite('_computeName', () => {
+    test('not showing anonymous', () => {
+      const account = {name: 'Wyatt'};
+      assert.deepEqual(element._computeName(account, false), 'Wyatt');
+    });
+
+    test('showing anonymous but no config', () => {
+      const account = {};
+      assert.deepEqual(element._computeName(account, false), 'Anonymous');
+    });
+
+    test('test for Anonymous Coward user and replace with Anonymous', () => {
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'Anonymous Coward',
+        },
+      };
+      const account = {};
+      assert.deepEqual(
+        element._computeName(account, false, config),
+        'Anonymous'
+      );
+    });
+
+    test('test for anonymous_coward_name', () => {
+      const config = {
+        ...createServerInfo(),
+        user: {
+          anonymous_coward_name: 'TestAnon',
+        },
+      };
+      const account = {};
+      assert.deepEqual(
+        element._computeName(account, false, config),
+        'TestAnon'
+      );
+    });
+  });
+
+  suite('attention set', () => {
+    setup(async () => {
+      element.highlightAttention = true;
+      element._config = {
+        ...createServerInfo(),
+        user: {anonymous_coward_name: 'Anonymous Coward'},
+      };
+      element._selfAccount = kermit;
+      element.account = {
+        ...createAccountDetailWithId(42),
+        name: 'ernie',
+      };
+      element.change = {
+        ...createChange(),
+        attention_set: {
+          42: {
+            account: createAccountDetailWithId(42),
+          },
+        },
+        owner: kermit,
+        reviewers: {},
+      };
+      await flush();
+    });
+
+    test('show attention button', () => {
+      const button = queryAndAssert(element, '#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
+    });
+
+    test('tap attention button', async () => {
+      const apiSpy = spyRestApi('removeFromAttentionSet');
+      const button = queryAndAssert(element, '#attentionButton');
+      assert.ok(button);
+      assert.isNull(button.getAttribute('disabled'));
+      MockInteractions.tap(button);
+      assert.isTrue(apiSpy.calledOnce);
+      assert.equal(apiSpy.lastCall.args[1], 42);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
index 5c4b76a..f0c9106 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.ts
@@ -16,19 +16,14 @@
  */
 
 import '../gr-account-label/gr-account-label';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-account-link_html';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo, ChangeInfo} from '../../../types/common';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {ParsedChangeInfo} from '../../../types/types';
 
 @customElement('gr-account-link')
-class GrAccountLink extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrAccountLink extends LitElement {
   @property({type: String})
   voteableText?: string;
 
@@ -41,7 +36,7 @@
    * related features like adding the user as a reviewer.
    */
   @property({type: Object})
-  change?: ChangeInfo;
+  change?: ChangeInfo | ParsedChangeInfo;
 
   /**
    * Should this user be considered to be in the attention set, regardless
@@ -70,6 +65,44 @@
   @property({type: Boolean})
   firstName = false;
 
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: inline-block;
+          vertical-align: top;
+        }
+        a {
+          color: var(--primary-text-color);
+          text-decoration: none;
+        }
+        gr-account-label::part(gr-account-label-text):hover {
+          text-decoration: underline !important;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.account) return;
+    return html`<span>
+      <a href="${this._computeOwnerLink(this.account)}">
+        <gr-account-label
+          .account="${this.account}"
+          .change="${this.change}"
+          ?forceAttention=${this.forceAttention}
+          ?highlightAttention=${this.highlightAttention}
+          ?hideAvatar=${this.hideAvatar}
+          ?hideStatus=${this.hideStatus}
+          ?firstName=${this.firstName}
+          .voteableText=${this.voteableText}
+          exportparts="gr-account-label-text: gr-account-link-text"
+        >
+        </gr-account-label>
+      </a>
+    </span>`;
+  }
+
   _computeOwnerLink(account?: AccountInfo) {
     if (!account) {
       return;
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
deleted file mode 100644
index 98a6e77..0000000
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_html.ts
+++ /dev/null
@@ -1,50 +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>
-    :host {
-      display: inline-block;
-      vertical-align: top;
-    }
-    a {
-      color: var(--primary-text-color);
-      text-decoration: none;
-    }
-    gr-account-label {
-      --gr-account-label-text-hover-style: {
-        text-decoration: underline;
-      }
-    }
-  </style>
-  <span>
-    <a href$="[[_computeOwnerLink(account)]]">
-      <gr-account-label
-        account="[[account]]"
-        change="[[change]]"
-        force-attention="[[forceAttention]]"
-        highlight-attention="[[highlightAttention]]"
-        hide-avatar="[[hideAvatar]]"
-        hide-status="[[hideStatus]]"
-        first-name="[[firstName]]"
-        voteable-text="[[voteableText]]"
-      >
-      </gr-account-label>
-    </a>
-  </span>
-`;
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.ts
similarity index 73%
rename from polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.js
rename to polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.ts
index 34fef2f..c754e47 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.ts
@@ -15,14 +15,17 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-link.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-link';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {GrAccountLink} from './gr-account-link';
+import {createAccountWithId} from '../../../test/test-data-generators';
+import {AccountId, AccountInfo, EmailAddress} from '../../../types/common';
 
 const basicFixture = fixtureFromElement('gr-account-link');
 
 suite('gr-account-link tests', () => {
-  let element;
+  let element: GrAccountLink;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -31,11 +34,12 @@
   test('computed fields', () => {
     const url = 'test/url';
     const urlStub = sinon.stub(GerritNav, 'getUrlForOwner').returns(url);
-    const account = {
-      email: 'email',
+    const account: AccountInfo = {
+      ...createAccountWithId(),
+      email: 'email' as EmailAddress,
       username: 'username',
       name: 'name',
-      _account_id: '_account_id',
+      _account_id: 5 as AccountId,
     };
     assert.isNotOk(element._computeOwnerLink());
     assert.equal(element._computeOwnerLink(account), url);
@@ -51,7 +55,6 @@
 
     delete account.name;
     assert.equal(element._computeOwnerLink(account), url);
-    assert.isTrue(urlStub.lastCall.calledWithExactly('_account_id'));
+    assert.isTrue(urlStub.lastCall.calledWithExactly('5'));
   });
 });
-
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 1e54f69..5449981 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
@@ -26,9 +26,10 @@
   Suggestion,
   AccountInfo,
   GroupInfo,
+  EmailAddress,
 } from '../../../types/common';
 import {
-  GrReviewerSuggestionsProvider,
+  ReviewerSuggestionsProvider,
   SuggestionItem,
 } from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
@@ -37,6 +38,7 @@
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
 import {PaperInputElementExt} from '../../../types/types';
 import {fireAlert} from '../../../utils/event-util';
+import {accountOrGroupKey} from '../../../utils/account-util';
 
 const VALID_EMAIL_ALERT = 'Please input a valid email.';
 
@@ -143,7 +145,7 @@
    * Returns suggestions and convert them to list item
    */
   @property({type: Object})
-  suggestionsProvider?: GrReviewerSuggestionsProvider;
+  suggestionsProvider?: ReviewerSuggestionsProvider;
 
   /**
    * Needed for template checking since value is initially set to null.
@@ -176,14 +178,10 @@
   @property({type: Object})
   _querySuggestions: (input: string) => Promise<SuggestionItem[]>;
 
-  /**
-   * Set to true to disable suggestions on empty input.
-   */
-  @property({type: Boolean})
-  skipSuggestOnEmpty = false;
-
   reporting: ReportingService;
 
+  private pendingRemoval: Set<AccountInput> = new Set();
+
   constructor() {
     super();
     this.reporting = appContext.reportingService;
@@ -202,17 +200,10 @@
   }
 
   _getSuggestions(input: string) {
-    if (this.skipSuggestOnEmpty && !input) {
-      return Promise.resolve([]);
-    }
     const provider = this.suggestionsProvider;
-    if (!provider) {
-      return Promise.resolve([]);
-    }
+    if (!provider) return Promise.resolve([]);
     return provider.getSuggestions(input).then(suggestions => {
-      if (!suggestions) {
-        return [];
-      }
+      if (!suggestions) return [];
       if (this.filter) {
         suggestions = suggestions.filter(this.filter);
       }
@@ -233,6 +224,7 @@
     let itemTypeAdded = 'unknown';
     if (isAccountObject(item)) {
       const account = {...item.account, _pendingAdd: true};
+      this.removeFromPendingRemoval(account);
       this.push('accounts', account);
       itemTypeAdded = 'account';
     } else if (isGroupObjectInput(item)) {
@@ -242,6 +234,7 @@
       }
       const group = {...item.group, _pendingAdd: true, _group: true};
       this.push('accounts', group);
+      this.removeFromPendingRemoval(group);
       itemTypeAdded = 'group';
     } else if (this.allowAnyInput) {
       if (!item.includes('@')) {
@@ -251,8 +244,9 @@
         fireAlert(this, VALID_EMAIL_ALERT);
         return false;
       } else {
-        const account = {email: item, _pendingAdd: true};
+        const account = {email: item as EmailAddress, _pendingAdd: true};
         this.push('accounts', account);
+        this.removeFromPendingRemoval(account);
         itemTypeAdded = 'email';
       }
     }
@@ -283,32 +277,16 @@
     return classes.join(' ');
   }
 
-  _accountMatches(a: AccountInput, b: AccountInput) {
-    // TODO(TS): seems a & b always exists ?
-    if (a && b) {
-      // both conditions are checking against AccountInfo
-      // and only check a not b.. typeguard won't work very good without
-      // changing logic, so keep it as inline casting
-      if ((a as AccountInfoInput)._account_id) {
-        return (
-          (a as AccountInfoInput)._account_id ===
-          (b as AccountInfoInput)._account_id
-        );
-      }
-      if ((a as AccountInfoInput).email) {
-        return (a as AccountInfoInput).email === (b as AccountInfoInput).email;
-      }
-    }
-    return a === b;
-  }
-
   _computeRemovable(account: AccountInput, readonly: boolean) {
     if (readonly) {
       return false;
     }
     if (this.removableValues) {
       for (let i = 0; i < this.removableValues.length; i++) {
-        if (this._accountMatches(this.removableValues[i], account)) {
+        if (
+          accountOrGroupKey(this.removableValues[i]) ===
+          accountOrGroupKey(account)
+        ) {
           return true;
         }
       }
@@ -328,21 +306,16 @@
       return;
     }
     for (let i = 0; i < this.accounts.length; i++) {
-      let matches;
-      const account = this.accounts[i];
-      if (toRemove._group) {
-        matches =
-          (toRemove as GroupInfoInput).id === (account as GroupInfoInput).id;
-      } else {
-        matches = this._accountMatches(toRemove, account);
-      }
-      if (matches) {
+      if (accountOrGroupKey(toRemove) === accountOrGroupKey(this.accounts[i])) {
         this.splice('accounts', i, 1);
+        this.pendingRemoval.add(toRemove);
         this.reporting.reportInteraction(`Remove from ${this.id}`);
         return;
       }
     }
-    console.warn('received remove event for missing account', toRemove);
+    this.reporting.error(
+      new Error(`Received "remove" event for missing account: ${toRemove}`)
+    );
   }
 
   _getNativeInput(paperInput: PaperInputElementExt) {
@@ -445,6 +418,26 @@
       });
   }
 
+  removals(): AccountAddition[] {
+    return Array.from(this.pendingRemoval).map(account => {
+      if (isGroupInfoInput(account)) {
+        return {group: account};
+      } else if (isAccountInfoInput(account)) {
+        return {account};
+      } else {
+        throw new Error('AccountInput must be either Account or Group.');
+      }
+    });
+  }
+
+  removeFromPendingRemoval(account: AccountInput) {
+    this.pendingRemoval.delete(account);
+  }
+
+  clearPendingRemovals() {
+    this.pendingRemoval.clear();
+  }
+
   _computeEntryHidden(
     maxCount: number,
     accountsRecord: PolymerDeepPropertyChange<AccountInput[], AccountInput[]>,
diff --git a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
index 2824bb5..7a47e29 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_html.ts
@@ -38,7 +38,6 @@
       align-items: center;
       display: flex;
       flex-wrap: wrap;
-      @apply --account-list-style;
     }
   </style>
   <!--
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.ts
similarity index 62%
rename from polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.js
rename to polygerrit-ui/app/elements/shared/gr-account-list/gr-account-list_test.ts
index 20e8672..23e5a72 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.ts
@@ -14,46 +14,78 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-account-list.js';
+import '../../../test/common-test-setup-karma';
+import './gr-account-list';
+import {
+  AccountInfoInput,
+  GrAccountList,
+  RawAccountInput,
+} from './gr-account-list';
+import {
+  AccountId,
+  AccountInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  SuggestedReviewerAccountInfo,
+  Suggestion,
+} from '../../../types/common';
+import {queryAll} from '../../../test/test-utils';
+import {ReviewerSuggestionsProvider} from '../../../scripts/gr-reviewer-suggestions-provider/gr-reviewer-suggestions-provider';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-account-list');
 
-class MockSuggestionsProvider {
-  getSuggestions(input) {
+class MockSuggestionsProvider implements ReviewerSuggestionsProvider {
+  init() {}
+
+  getSuggestions(_: string): Promise<Suggestion[]> {
     return Promise.resolve([]);
   }
 
-  makeSuggestionItem(item) {
-    return item;
+  makeSuggestionItem(_: Suggestion) {
+    return {
+      name: 'test',
+      value: {
+        account: {
+          _account_id: 1 as AccountId,
+        } as AccountInfo,
+        count: 1,
+      } as SuggestedReviewerAccountInfo,
+    };
   }
 }
 
 suite('gr-account-list tests', () => {
   let _nextAccountId = 0;
-  const makeAccount = function() {
+  const makeAccount: () => AccountInfo = function () {
     const accountId = ++_nextAccountId;
     return {
-      _account_id: accountId,
+      _account_id: accountId as AccountId,
     };
   };
-  const makeGroup = function() {
-    const groupId = 'group' + (++_nextAccountId);
+  const makeGroup: () => GroupInfo = function () {
+    const groupId = `group${++_nextAccountId}`;
     return {
-      id: groupId,
+      id: groupId as GroupId,
       _group: true,
     };
   };
 
-  let existingAccount1;
-  let existingAccount2;
+  let existingAccount1: AccountInfo;
+  let existingAccount2: AccountInfo;
 
-  let element;
-  let suggestionsProvider;
+  let element: GrAccountList;
+  let suggestionsProvider: MockSuggestionsProvider;
 
   function getChips() {
-    return element.root.querySelectorAll('gr-account-chip');
+    return queryAll(element, 'gr-account-chip');
+  }
+
+  function handleAdd(value: RawAccountInput) {
+    element._handleAdd(
+      new CustomEvent<{value: RawAccountInput}>('add', {detail: {value}})
+    );
   }
 
   setup(() => {
@@ -84,13 +116,7 @@
 
     // New accounts are added to end with pendingAdd class.
     const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
+    handleAdd({account: newAccount});
     flush();
     chips = getChips();
     assert.equal(chips.length, 3);
@@ -100,10 +126,12 @@
 
     // Removed accounts are taken out of the list.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: existingAccount1},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -112,15 +140,19 @@
 
     // Invalid remove is ignored.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: existingAccount1},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: existingAccount1},
+        composed: true,
+        bubbles: true,
+      })
+    );
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newAccount},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: newAccount},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 1);
@@ -128,13 +160,7 @@
 
     // New groups are added to end with pendingAdd and group classes.
     const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
+    handleAdd({group: newGroup, confirm: false});
     flush();
     chips = getChips();
     assert.equal(chips.length, 2);
@@ -143,10 +169,12 @@
 
     // Removed groups are taken out of the list.
     element.dispatchEvent(
-        new CustomEvent('remove', {
-          detail: {account: newGroup},
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('remove', {
+        detail: {account: newGroup},
+        composed: true,
+        bubbles: true,
+      })
+    );
     flush();
     chips = getChips();
     assert.equal(chips.length, 1);
@@ -154,54 +182,67 @@
   });
 
   test('_getSuggestions uses filter correctly', () => {
-    const originalSuggestions = [
+    const originalSuggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-        _account_id: 3,
-      },
+        _account_id: 3 as AccountId,
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-        _account_id: 1,
-      },
+        _account_id: 1 as AccountId,
+      } as AccountInfo,
       {
-        email: 'xyz@example.com',
+        email: 'xyz@example.com' as EmailAddress,
         text: 'aaaaa',
-        _account_id: 25,
-      },
+        _account_id: 25 as AccountId,
+      } as AccountInfo,
     ];
-    sinon.stub(suggestionsProvider, 'getSuggestions')
-        .returns(Promise.resolve(originalSuggestions));
-    sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-        .callsFake( suggestion => {
-          return {
-            name: suggestion.email,
-            value: suggestion._account_id,
-          };
-        });
+    sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(originalSuggestions));
+    sinon
+      .stub(suggestionsProvider, 'makeSuggestionItem')
+      .callsFake(suggestion => {
+        return {
+          name: ((suggestion as AccountInfo).email as string) ?? '',
+          value: {
+            account: suggestion as AccountInfo,
+            count: 1,
+          },
+        };
+      });
 
-    return element._getSuggestions().then(suggestions => {
-      // Default is no filtering.
-      assert.equal(suggestions.length, 3);
+    return element
+      ._getSuggestions('')
+      .then(suggestions => {
+        // Default is no filtering.
+        assert.equal(suggestions.length, 3);
 
-      // Set up filter that only accepts suggestion1.
-      const accountId = originalSuggestions[0]._account_id;
-      element.filter = function(suggestion) {
-        return suggestion._account_id === accountId;
-      };
+        // Set up filter that only accepts suggestion1.
+        const accountId = (originalSuggestions[0] as AccountInfo)._account_id;
+        element.filter = function (suggestion) {
+          return (suggestion as AccountInfo)._account_id === accountId;
+        };
 
-      return element._getSuggestions();
-    })
-        .then(suggestions => {
-          assert.deepEqual(suggestions,
-              [{name: originalSuggestions[0].email,
-                value: originalSuggestions[0]._account_id}]);
-        });
+        return element._getSuggestions('');
+      })
+      .then(suggestions => {
+        assert.deepEqual(suggestions, [
+          {
+            name: (originalSuggestions[0] as AccountInfo).email as string,
+            value: {
+              account: originalSuggestions[0] as AccountInfo,
+              count: 1,
+            },
+          },
+        ]);
+      });
   });
 
   test('_computeChipClass', () => {
-    const account = makeAccount();
+    const account = makeAccount() as AccountInfoInput;
     assert.equal(element._computeChipClass(account), '');
     account._pendingAdd = true;
     assert.equal(element._computeChipClass(account), 'pendingAdd');
@@ -212,7 +253,7 @@
   });
 
   test('_computeRemovable', () => {
-    const newAccount = makeAccount();
+    const newAccount = makeAccount() as AccountInfoInput;
     newAccount._pendingAdd = true;
     element.readonly = false;
     element.removableValues = [];
@@ -250,28 +291,19 @@
     // When entry is valid, return true and clear text.
     assert.isTrue(element.submitEntryText());
     assert.isTrue(clearStub.called);
-    assert.equal(element.additions()[0].account.email, 'test@test');
+    assert.equal(
+      element.additions()[0].account?.email,
+      'test@test' as EmailAddress
+    );
   });
 
   test('additions returns sanitized new accounts and groups', () => {
     assert.equal(element.additions().length, 0);
 
     const newAccount = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: newAccount,
-        },
-      },
-    });
+    handleAdd({account: newAccount});
     const newGroup = makeGroup();
-    element._handleAdd({
-      detail: {
-        value: {
-          group: newGroup,
-        },
-      },
-    });
+    handleAdd({group: newGroup, confirm: false});
 
     assert.deepEqual(element.additions(), [
       {
@@ -300,11 +332,7 @@
       count: 10,
       confirm: true,
     };
-    element._handleAdd({
-      detail: {
-        value: reviewer,
-      },
-    });
+    handleAdd(reviewer);
 
     assert.deepEqual(element.pendingConfirmation, reviewer);
     assert.deepEqual(element.additions(), []);
@@ -334,35 +362,30 @@
   test('max-count', () => {
     element.maxCount = 1;
     const acct = makeAccount();
-    element._handleAdd({
-      detail: {
-        value: {
-          account: acct,
-        },
-      },
-    });
+    handleAdd({account: acct});
     flush();
     assert.isTrue(element.$.entry.hasAttribute('hidden'));
   });
 
   test('enter text calls suggestions provider', async () => {
-    const suggestions = [
+    const suggestions: Suggestion[] = [
       {
-        email: 'abc@example.com',
+        email: 'abc@example.com' as EmailAddress,
         text: 'abcd',
-      },
+      } as AccountInfo,
       {
-        email: 'qwe@example.com',
+        email: 'qwe@example.com' as EmailAddress,
         text: 'qwer',
-      },
+      } as AccountInfo,
     ];
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
+    const getSuggestionsStub = sinon
+      .stub(suggestionsProvider, 'getSuggestions')
+      .returns(Promise.resolve(suggestions));
 
-    const makeSuggestionItemStub =
-        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-            .callsFake( item => item);
+    const makeSuggestionItemSpy = sinon.spy(
+      suggestionsProvider,
+      'makeSuggestionItem'
+    );
 
     const input = element.$.entry.$.input;
 
@@ -372,53 +395,7 @@
     await flush();
     assert.isTrue(getSuggestionsStub.calledOnce);
     assert.equal(getSuggestionsStub.lastCall.args[0], 'newTest');
-    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
-  });
-
-  test('suggestion on empty', async () => {
-    element.skipSuggestOnEmpty = false;
-    const suggestions = [
-      {
-        email: 'abc@example.com',
-        text: 'abcd',
-      },
-      {
-        email: 'qwe@example.com',
-        text: 'qwer',
-      },
-    ];
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve(suggestions));
-
-    const makeSuggestionItemStub =
-        sinon.stub(suggestionsProvider, 'makeSuggestionItem')
-            .callsFake( item => item);
-
-    const input = element.$.entry.$.input;
-
-    input.text = '';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    await flush();
-    assert.isTrue(getSuggestionsStub.calledOnce);
-    assert.equal(getSuggestionsStub.lastCall.args[0], '');
-    assert.equal(makeSuggestionItemStub.getCalls().length, 2);
-  });
-
-  test('skip suggestion on empty', async () => {
-    element.skipSuggestOnEmpty = true;
-    const getSuggestionsStub =
-        sinon.stub(suggestionsProvider, 'getSuggestions')
-            .returns(Promise.resolve([]));
-
-    const input = element.$.entry.$.input;
-
-    input.text = '';
-    MockInteractions.focus(input.$.input);
-    input.noDebounce = true;
-    await flush();
-    assert.isTrue(getSuggestionsStub.notCalled);
+    assert.equal(makeSuggestionItemSpy.getCalls().length, 2);
   });
 
   suite('allowAnyInput', () => {
@@ -428,32 +405,22 @@
 
     test('adds emails', () => {
       const accountLen = element.accounts.length;
-      element._handleAdd({detail: {value: 'test@test'}});
+      handleAdd('test@test');
       assert.equal(element.accounts.length, accountLen + 1);
-      assert.equal(element.accounts[accountLen].email, 'test@test');
+      assert.equal(
+        (element.accounts[accountLen] as AccountInfoInput).email,
+        'test@test' as EmailAddress
+      );
     });
 
     test('toasts on invalid email', () => {
       const toastHandler = sinon.stub();
       element.addEventListener('show-alert', toastHandler);
-      element._handleAdd({detail: {value: 'test'}});
+      handleAdd('test');
       assert.isTrue(toastHandler.called);
     });
   });
 
-  test('_accountMatches', () => {
-    const acct = makeAccount();
-
-    assert.isTrue(element._accountMatches(acct, acct));
-    acct.email = 'test';
-    assert.isTrue(element._accountMatches(acct, acct));
-    assert.isTrue(element._accountMatches({email: 'test'}, acct));
-
-    assert.isFalse(element._accountMatches({}, acct));
-    assert.isFalse(element._accountMatches({email: 'test2'}, acct));
-    assert.isFalse(element._accountMatches({_account_id: -1}, acct));
-  });
-
   suite('keyboard interactions', () => {
     test('backspace at text input start removes last account', async () => {
       const input = element.$.entry.$.input;
@@ -462,18 +429,21 @@
       await flush();
       // Next line is a workaround for Firefox not moving cursor
       // on input field update
-      assert.equal(
-          element._getNativeInput(input.$.input).selectionStart, 0);
+      assert.equal(element._getNativeInput(input.$.input).selectionStart, 0);
       input.text = 'test';
       MockInteractions.focus(input.$.input);
       flush();
       assert.equal(element.accounts.length, 2);
       MockInteractions.pressAndReleaseKeyOn(
-          element._getNativeInput(input.$.input), 8); // Backspace
+        element._getNativeInput(input.$.input),
+        8
+      ); // Backspace
       assert.equal(element.accounts.length, 2);
       input.text = '';
       MockInteractions.pressAndReleaseKeyOn(
-          element._getNativeInput(input.$.input), 8); // Backspace
+        element._getNativeInput(input.$.input),
+        8
+      ); // Backspace
       flush();
       assert.equal(element.accounts.length, 1);
     });
@@ -503,15 +473,12 @@
       flush();
       const focusSpy = sinon.spy(element.accountChips[1], 'focus');
       const removeSpy = sinon.spy(element, 'removeAccount');
-      MockInteractions.pressAndReleaseKeyOn(
-          element.accountChips[0], 8); // Backspace
+      MockInteractions.pressAndReleaseKeyOn(element.accountChips[0], 8); // Backspace
       assert.isTrue(focusSpy.called);
       assert.isTrue(removeSpy.calledOnce);
 
-      MockInteractions.pressAndReleaseKeyOn(
-          element.accountChips[1], 46); // Delete
+      MockInteractions.pressAndReleaseKeyOn(element.accountChips[1], 46); // Delete
       assert.isTrue(removeSpy.calledTwice);
     });
   });
 });
-
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 4d2576a..fa547dc 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.ts
@@ -16,11 +16,11 @@
  */
 import '../gr-button/gr-button';
 import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-alert_html';
 import {getRootElement} from '../../../scripts/rootElement';
-import {customElement, property} from '@polymer/decorators';
 import {ErrorType} from '../../../types/types';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {sharedStyles} from '../../../styles/shared-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -29,9 +29,83 @@
 }
 
 @customElement('gr-alert')
-export class GrAlert extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
+export class GrAlert extends LitElement {
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        /**
+         * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
+         * HOW THEY ARE USED IN THE CODE.
+         */
+        :host([toast]) {
+          background-color: var(--tooltip-background-color);
+          bottom: 1.25rem;
+          border-radius: var(--border-radius);
+          box-shadow: var(--elevation-level-2);
+          left: 1.25rem;
+          position: fixed;
+          transform: translateY(5rem);
+          transition: transform var(--gr-alert-transition-duration, 80ms)
+            ease-out;
+          z-index: 1000;
+        }
+        :host([shown]) {
+          transform: translateY(0);
+        }
+        /**
+         * NOTE: To avoid style being overwritten by outside of the shadow DOM
+         * (as outside styles always win), .content-wrapper is introduced as a
+         * wrapper around main content to have better encapsulation, styles that
+         * may be affected by outside should be defined on it.
+         * In this case, \`padding:0px\` is defined in main.css for all elements
+         * with the universal selector: *.
+         */
+        .content-wrapper {
+          padding: var(--spacing-l) var(--spacing-xl);
+        }
+        .text {
+          color: var(--tooltip-text-color);
+          display: inline-block;
+          max-height: 10rem;
+          max-width: 80vw;
+          vertical-align: bottom;
+          word-break: break-all;
+        }
+        gr-button.action {
+          --text-color: var(--tooltip-button-text-color);
+          --gr-button-padding: 0 var(--spacing-s);
+          margin-left: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  renderDismissButton() {
+    if (!this.showDismiss) return '';
+    return html`<gr-button
+      link=""
+      class="action"
+      @click=${this._handleDismissTap}
+      >Dismiss</gr-button
+    >`;
+  }
+
+  override render() {
+    const {text, actionText} = this;
+    return html`
+      <div class="content-wrapper">
+        <span class="text">${text}</span>
+        <gr-button
+          link=""
+          class="action"
+          ?hidden="${this._hideActionButton}"
+          @click=${this._handleActionTap}
+          >${actionText}
+        </gr-button>
+        ${this.renderDismissButton()}
+      </div>
+    `;
   }
 
   /**
@@ -49,10 +123,10 @@
   @property({type: String})
   type?: ErrorType;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   shown = true;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   toast = true;
 
   @property({type: Boolean})
@@ -70,15 +144,13 @@
   @property()
   _actionCallback?: () => void;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this._boundTransitionEndHandler = () => this._handleTransitionEnd();
     this.addEventListener('transitionend', this._boundTransitionEndHandler);
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     if (this._boundTransitionEndHandler) {
       this.removeEventListener(
         'transitionend',
@@ -128,5 +200,6 @@
     if (this._actionCallback) {
       this._actionCallback();
     }
+    this.hide();
   }
 }
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
deleted file mode 100644
index bc517a8..0000000
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_html.ts
+++ /dev/null
@@ -1,83 +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">
-    /**
-       * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
-       * HOW THEY ARE USED IN THE CODE.
-       */
-    :host([toast]) {
-      background-color: var(--tooltip-background-color);
-      bottom: 1.25rem;
-      border-radius: var(--border-radius);
-      box-shadow: var(--elevation-level-2);
-      color: var(--tooltip-text-color);
-      left: 1.25rem;
-      position: fixed;
-      transform: translateY(5rem);
-      transition: transform var(--gr-alert-transition-duration, 80ms) ease-out;
-      z-index: 1000;
-    }
-    :host([shown]) {
-      transform: translateY(0);
-    }
-    /**
-       * NOTE: To avoid style being overwritten by outside of the shadow DOM
-       * (as outside styles always win), .content-wrapper is introduced as a
-       * wrapper around main content to have better encapsulation, styles that
-       * may be affected by outside should be defined on it.
-       * In this case, \`padding:0px\` is defined in main.css for all elements
-       * with the universal selector: *.
-       */
-    .content-wrapper {
-      padding: var(--spacing-l) var(--spacing-xl);
-    }
-    .text {
-      color: var(--tooltip-text-color);
-      display: inline-block;
-      max-height: 10rem;
-      max-width: 80vw;
-      vertical-align: bottom;
-      word-break: break-all;
-    }
-    .action {
-      color: var(--link-color);
-      font-weight: var(--font-weight-bold);
-      margin-left: var(--spacing-l);
-      text-decoration: none;
-      --gr-button: {
-        padding: 0;
-      }
-    }
-  </style>
-  <div class="content-wrapper">
-    <span class="text">[[text]]</span>
-    <gr-button
-      link=""
-      class="action"
-      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-alert/gr-alert_test.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
rename to polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
index 11ec496..d0fe563 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.ts
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-alert.js';
+import '../../../test/common-test-setup-karma';
+import './gr-alert';
+import {GrAlert} from './gr-alert';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+
 suite('gr-alert tests', () => {
-  let element;
+  let element: GrAlert;
 
   setup(() => {
     element = document.createElement('gr-alert');
@@ -30,22 +33,24 @@
     }
   });
 
-  test('show/hide', () => {
+  test('show/hide', async () => {
     assert.isNull(element.parentNode);
-    element.show();
+    element.show('Alert text');
+    // wait for element to be rendered after being attached to DOM
+    await flush();
     assert.equal(element.parentNode, document.body);
-    element.updateStyles({'--gr-alert-transition-duration': '0ms'});
+    element.style.setProperty('--gr-alert-transition-duration', '0ms');
     element.hide();
     assert.isNull(element.parentNode);
   });
 
-  test('action event', () => {
+  test('action event', async () => {
     const spy = sinon.spy();
-    element.show();
+    element.show('Alert text');
+    await flush();
     element._actionCallback = spy;
     assert.isFalse(spy.called);
-    MockInteractions.tap(element.shadowRoot.querySelector('.action'));
+    MockInteractions.tap(element.shadowRoot!.querySelector('.action')!);
     assert.isTrue(spy.called);
   });
 });
-
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 fdc72ce..e7137e4 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
@@ -20,12 +20,12 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete-dropdown_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {IronFitMixin} from '../../../mixins/iron-fit-mixin/iron-fit-mixin';
 import {customElement, property, observe} from '@polymer/decorators';
 import {IronFitBehavior} from '@polymer/iron-fit-behavior/iron-fit-behavior';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {fireEvent} from '../../../utils/event-util';
+import {addShortcut, Key} from '../../../utils/dom-util';
 
 export interface GrAutocompleteDropdown {
   $: {
@@ -39,7 +39,7 @@
   }
 }
 
-interface Item {
+export interface Item {
   dataValue?: string;
   name?: string;
   text?: string;
@@ -47,11 +47,16 @@
   value?: string;
 }
 
+export interface ItemSelectedEvent {
+  trigger: string;
+  selected: HTMLElement | null;
+}
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = IronFitMixin(PolymerElement, IronFitBehavior as IronFitBehavior);
+
 @customElement('gr-autocomplete-dropdown')
-export class GrAutocompleteDropdown extends IronFitMixin(
-  KeyboardShortcutMixin(PolymerElement),
-  IronFitBehavior as IronFitBehavior
-) {
+export class GrAutocompleteDropdown extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -75,25 +80,19 @@
   isHidden = true;
 
   @property({type: Number})
-  verticalOffset: number | null = null;
+  override verticalOffset: number | null = null;
 
   @property({type: Number})
-  horizontalOffset: number | null = null;
+  override horizontalOffset: number | null = null;
 
   @property({type: Array})
   suggestions: Item[] = [];
 
-  get keyBindings() {
-    return {
-      up: '_handleUp',
-      down: '_handleDown',
-      enter: '_handleEnter',
-      esc: '_handleEscape',
-      tab: '_handleTab',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
-  private cursor = new GrCursorManager();
+  // visible for testing
+  cursor = new GrCursorManager();
 
   constructor() {
     super();
@@ -101,9 +100,29 @@
     this.cursor.focusOnMove = true;
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, _ => this._handleEscape())
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+    );
+  }
+
+  override disconnectedCallback() {
     this.cursor.unsetCursor();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
@@ -154,7 +173,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
-      new CustomEvent('item-selected', {
+      new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
           trigger: 'tab',
           selected: this.cursor.target,
@@ -169,7 +188,7 @@
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
-      new CustomEvent('item-selected', {
+      new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
           trigger: 'enter',
           selected: this.cursor.target,
@@ -188,7 +207,7 @@
   _handleClickItem(e: Event) {
     e.preventDefault();
     e.stopPropagation();
-    let selected = e.target! as Element;
+    let selected = e.target! as HTMLElement;
     while (!selected.classList.contains('autocompleteOption')) {
       if (!selected || selected === this) {
         return;
@@ -196,7 +215,7 @@
       selected = selected.parentElement!;
     }
     this.dispatchEvent(
-      new CustomEvent('item-selected', {
+      new CustomEvent<ItemSelectedEvent>('item-selected', {
         detail: {
           trigger: 'click',
           selected,
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
rename to polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
index 200fddc..86de3b3 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.ts
@@ -14,37 +14,43 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-autocomplete-dropdown.js';
+import '../../../test/common-test-setup-karma';
+import './gr-autocomplete-dropdown';
+import {GrAutocompleteDropdown} from './gr-autocomplete-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {assertIsDefined} from '../../../utils/common-util';
 
 const basicFixture = fixtureFromElement('gr-autocomplete-dropdown');
 
 suite('gr-autocomplete-dropdown', () => {
-  let element;
+  let element: GrAutocompleteDropdown;
 
-  setup(() => {
+  const suggestionsEl = () => queryAndAssert(element, '#suggestions');
+
+  setup(async () => {
     element = basicFixture.instantiate();
     element.open();
     element.suggestions = [
-      {dataValue: 'test value 1', name: 'test name 1', text: 1, label: 'hi'},
-      {dataValue: 'test value 2', name: 'test name 2', text: 2}];
-    flush();
+      {dataValue: 'test value 1', name: 'test name 1', text: '1', label: 'hi'},
+      {dataValue: 'test value 2', name: 'test name 2', text: '2'},
+    ];
+    await flush();
   });
 
   teardown(() => {
-    if (element.isOpen) element.close();
+    element.close();
   });
 
   test('shows labels', () => {
-    const els = element.$.suggestions.querySelectorAll('li');
+    const els = queryAll<HTMLElement>(suggestionsEl(), 'li');
     assert.equal(els[0].innerText.trim(), '1\nhi');
     assert.equal(els[1].innerText.trim(), '2');
   });
 
   test('escape key', () => {
     const closeSpy = sinon.spy(element, 'close');
-    MockInteractions.pressAndReleaseKeyOn(element, 27);
+    MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'Escape');
     flush();
     assert.isTrue(closeSpy.called);
   });
@@ -53,7 +59,7 @@
     const handleTabSpy = sinon.spy(element, '_handleTab');
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 9);
+    MockInteractions.pressAndReleaseKeyOn(element, 9, null, 'Tab');
     assert.isTrue(handleTabSpy.called);
     assert.equal(element.cursor.index, 0);
     assert.isTrue(itemSelectedStub.called);
@@ -67,7 +73,7 @@
     const handleEnterSpy = sinon.spy(element, '_handleEnter');
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-    MockInteractions.pressAndReleaseKeyOn(element, 13);
+    MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'Enter');
     assert.isTrue(handleEnterSpy.called);
     assert.equal(element.cursor.index, 0);
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
@@ -79,11 +85,11 @@
   test('down key', () => {
     element.isHidden = true;
     const nextSpy = sinon.spy(element.cursor, 'next');
-    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
     assert.isFalse(nextSpy.called);
     assert.equal(element.cursor.index, 0);
     element.isHidden = false;
-    MockInteractions.pressAndReleaseKeyOn(element, 40);
+    MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
     assert.isTrue(nextSpy.called);
     assert.equal(element.cursor.index, 1);
   });
@@ -91,13 +97,13 @@
   test('up key', () => {
     element.isHidden = true;
     const prevSpy = sinon.spy(element.cursor, 'previous');
-    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
     assert.isFalse(prevSpy.called);
     assert.equal(element.cursor.index, 0);
     element.isHidden = false;
     element.cursor.setCursorAtIndex(1);
     assert.equal(element.cursor.index, 1);
-    MockInteractions.pressAndReleaseKeyOn(element, 38);
+    MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
     assert.isTrue(prevSpy.called);
     assert.equal(element.cursor.index, 0);
   });
@@ -106,24 +112,25 @@
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
 
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+    MockInteractions.tap(suggestionsEl().querySelectorAll('li')[1]);
     flush();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[1],
+      selected: suggestionsEl().querySelectorAll('li')[1],
     });
   });
 
   test('tapping child still selects item', () => {
     const itemSelectedStub = sinon.stub();
     element.addEventListener('item-selected', itemSelectedStub);
-
-    MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[0]
-        .lastElementChild);
+    const lastElChild = queryAll<HTMLElement>(suggestionsEl(), 'li')[0]
+      ?.lastElementChild;
+    assertIsDefined(lastElChild);
+    MockInteractions.tap(lastElChild);
     flush();
     assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
       trigger: 'click',
-      selected: element.$.suggestions.querySelectorAll('li')[0],
+      selected: queryAll<HTMLElement>(suggestionsEl(), 'li')[0],
     });
   });
 
@@ -133,4 +140,3 @@
     assert.isTrue(resetStopsSpy.called);
   });
 });
-
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 1ff5350..8e84aa2 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.ts
@@ -22,13 +22,13 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-autocomplete_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {property, customElement, observe} from '@polymer/decorators';
 import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import {PaperInputElementExt} from '../../../types/types';
-import {CustomKeyboardEvent} from '../../../types/events';
 import {fireEvent} from '../../../utils/event-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {PropertyType} from '../../../types/common';
+import {modifierPressed} from '../../../utils/dom-util';
 
 const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 const DEBOUNCE_WAIT_MS = 200;
@@ -40,9 +40,9 @@
   };
 }
 
-export type AutocompleteQuery = (
+export type AutocompleteQuery<T = string> = (
   text: string
-) => Promise<AutocompleteSuggestion[]>;
+) => Promise<Array<AutocompleteSuggestion<T>>>;
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -50,21 +50,22 @@
   }
 }
 
-export interface AutocompleteSuggestion {
+export interface AutocompleteSuggestion<T = string> {
   name?: string;
   label?: string;
-  value?: string;
-  text?: string;
+  value?: T;
+  text?: T;
 }
 
 export interface AutocompleteCommitEventDetail {
   value: string;
 }
 
-export type AutocompleteCommitEvent = CustomEvent<AutocompleteCommitEventDetail>;
+export type AutocompleteCommitEvent =
+  CustomEvent<AutocompleteCommitEventDetail>;
 
 @customElement('gr-autocomplete')
-export class GrAutocomplete extends KeyboardShortcutMixin(PolymerElement) {
+export class GrAutocomplete extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -98,7 +99,7 @@
    *
    */
   @property({type: Object})
-  query: AutocompleteQuery = () => Promise.resolve([]);
+  query?: AutocompleteQuery = () => Promise.resolve([]);
 
   /**
    * The number of characters that must be typed before suggestions are
@@ -175,7 +176,7 @@
   _suggestionEls = [];
 
   @property({type: Number})
-  _index?: number;
+  _index: number | null = null;
 
   @property({type: Boolean})
   _disableSuggestions = false;
@@ -202,14 +203,12 @@
       this.$.input.inputElement) as HTMLInputElement;
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     document.addEventListener('click', this.handleBodyClick);
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     document.removeEventListener('click', this.handleBodyClick);
     this.updateSuggestionsTask?.cancel();
     super.disconnectedCallback();
@@ -219,7 +218,7 @@
     return this.$.input;
   }
 
-  focus() {
+  override focus() {
     this._nativeInput.focus();
   }
 
@@ -296,6 +295,12 @@
     if (this._disableSuggestions) {
       return;
     }
+
+    const query = this.query;
+    if (!query) {
+      return;
+    }
+
     if (text.length < threshold) {
       this.value = '';
       return;
@@ -306,7 +311,7 @@
     }
 
     const update = () => {
-      this.query(text).then(suggestions => {
+      query(text).then(suggestions => {
         if (text !== this.text) {
           // Late response.
           return;
@@ -341,7 +346,7 @@
     return this.$.suggestions.close();
   }
 
-  _computeClass(borderless: boolean) {
+  _computeClass(borderless?: boolean) {
     return borderless ? 'borderless' : '';
   }
 
@@ -349,7 +354,7 @@
    * _handleKeydown used for key handling in the this.$.input AND all child
    * autocomplete options.
    */
-  _handleKeydown(e: CustomKeyboardEvent) {
+  _handleKeydown(e: KeyboardEvent) {
     this._focused = true;
     switch (e.keyCode) {
       case 38: // Up
@@ -374,7 +379,7 @@
         }
         break;
       case 13: // Enter
-        if (this.modifierPressed(e)) {
+        if (modifierPressed(e)) {
           break;
         }
         e.preventDefault();
@@ -503,3 +508,24 @@
     return showSearchIcon ? 'showSearchIcon' : '';
   }
 }
+
+/**
+ * Often gr-autocomplete is used for BranchName, RepoName, etc...
+ * GrTypedAutocomplete allows to define more precise typing in templates.
+ * For example, instead of
+ * $: {
+ *   branchSelect: GrAutocomplete
+ * }
+ * you can write
+ * $: {
+ *   branchSelect: GrTypedAutocomplete<BranchName>
+ * }
+ * And later user $.branchSelect.text without type conversion to BranchName.
+ */
+export interface GrTypedAutocomplete<
+  T extends PropertyType<GrAutocomplete, 'text'>
+> extends GrAutocomplete {
+  text: T;
+  value: T;
+  query?: AutocompleteQuery<T>;
+}
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
deleted file mode 100644
index d72007e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.js
+++ /dev/null
@@ -1,579 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-autocomplete.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-
-const basicFixture = fixtureFromTemplate(
-    html`<gr-autocomplete no-debounce></gr-autocomplete>`);
-
-suite('gr-autocomplete tests', () => {
-  let element;
-
-  const focusOnInput = element => {
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-        'enter');
-  };
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('renders', () => {
-    let promise;
-    const queryStub = sinon.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.cursor.index, -1);
-
-    focusOnInput(element);
-    element.text = 'blah';
-
-    assert.isTrue(queryStub.called);
-    element._focused = true;
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-      const suggestions =
-          element.$.suggestions.root.querySelectorAll('li');
-      assert.equal(suggestions.length, 5);
-
-      for (let i = 0; i < 5; i++) {
-        assert.equal(suggestions[i].innerText.trim(), 'blah ' + i);
-      }
-
-      assert.notEqual(element.$.suggestions.cursor.index, -1);
-    });
-  });
-
-  test('selectAll', async () => {
-    await flush();
-    const nativeInput = element._nativeInput;
-    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
-
-    element.selectAll();
-    assert.isFalse(selectionStub.called);
-
-    element.$.input.value = 'test';
-    element.selectAll();
-    assert.isTrue(selectionStub.called);
-  });
-
-  test('esc key behavior', () => {
-    let promise;
-    const queryStub = sinon.spy(() => promise = Promise.resolve([
-      {name: 'blah', value: 123},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-
-    element._focused = true;
-    element.text = 'blah';
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const cancelHandler = sinon.spy();
-      element.addEventListener('cancel', cancelHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isFalse(cancelHandler.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.equal(element._suggestions.length, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-      assert.isTrue(cancelHandler.called);
-    });
-  });
-
-  test('emits commit and handles cursor movement', () => {
-    let promise;
-    const queryStub = sinon.spy(input => promise = Promise.resolve([
-      {name: input + ' 0', value: 0},
-      {name: input + ' 1', value: 1},
-      {name: input + ' 2', value: 2},
-      {name: input + ' 3', value: 3},
-      {name: input + ' 4', value: 4},
-    ]));
-    element.query = queryStub;
-
-    assert.isTrue(element.$.suggestions.isHidden);
-    assert.equal(element.$.suggestions.cursor.index, -1);
-    element._focused = true;
-    element.text = 'blah';
-
-    return promise.then(() => {
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      assert.equal(element.$.suggestions.cursor.index, 0);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
-          'down');
-
-      assert.equal(element.$.suggestions.cursor.index, 2);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
-
-      assert.equal(element.$.suggestions.cursor.index, 1);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.equal(element.value, 1);
-      assert.isTrue(commitHandler.called);
-      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-      assert.isTrue(element.$.suggestions.isHidden);
-      assert.isTrue(element._focused);
-    });
-  });
-
-  test('clear-on-commit behavior (off)', () => {
-    let promise;
-    const queryStub = sinon.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'suggestion');
-    });
-  });
-
-  test('clear-on-commit behavior (on)', () => {
-    let promise;
-    const queryStub = sinon.spy(() => {
-      promise = Promise.resolve([{name: 'suggestion', value: 0}]);
-      return promise;
-    });
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah';
-    element.clearOnCommit = true;
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, '');
-    });
-  });
-
-  test('threshold guards the query', () => {
-    const queryStub = sinon.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.threshold = 2;
-    focusOnInput(element);
-    element.text = 'a';
-    assert.isFalse(queryStub.called);
-    element.text = 'ab';
-    assert.isTrue(queryStub.called);
-  });
-
-  test('noDebounce=false debounces the query', () => {
-    const clock = sinon.useFakeTimers();
-    const queryStub = sinon.spy(() => Promise.resolve([]));
-    element.query = queryStub;
-    element.noDebounce = false;
-    focusOnInput(element);
-    element.text = 'a';
-
-    // not called right away
-    assert.isFalse(queryStub.called);
-
-    // but called after a while
-    clock.tick(1000);
-    assert.isTrue(queryStub.called);
-  });
-
-  test('_computeClass respects border property', () => {
-    assert.equal(element._computeClass(), '');
-    assert.equal(element._computeClass(false), '');
-    assert.equal(element._computeClass(true), 'borderless');
-  });
-
-  test('undefined or empty text results in no suggestions', () => {
-    element._updateSuggestions(undefined, 0, null);
-    assert.equal(element._suggestions.length, 0);
-  });
-
-  test('when focused', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    focusOnInput(element);
-    element.text = 'bla';
-    assert.equal(element._focused, true);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      assert.equal(queryStub.notCalled, false);
-    });
-  });
-
-  test('when not focused', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    element.suggestOnlyWhenFocus = true;
-    element.text = 'bla';
-    assert.equal(element._focused, false);
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 0);
-    });
-  });
-
-  test('suggestions should not carry over', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'bla';
-    flush();
-    return promise.then(() => {
-      assert.equal(element._suggestions.length, 1);
-      element._updateSuggestions('', 0, false);
-      assert.equal(element._suggestions.length, 0);
-    });
-  });
-
-  test('multi completes only the last part of the query', () => {
-    let promise;
-    const queryStub = sinon.stub()
-        .returns(promise = Promise.resolve([
-          {name: 'suggestion', value: 0},
-        ]));
-    element.query = queryStub;
-    focusOnInput(element);
-    element.text = 'blah blah';
-    element.multi = true;
-
-    return promise.then(() => {
-      const commitHandler = sinon.spy();
-      element.addEventListener('commit', commitHandler);
-
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-
-      assert.isTrue(commitHandler.called);
-      assert.equal(element.text, 'blah 0');
-    });
-  });
-
-  test('tabComplete flag functions', () => {
-    // commitHandler checks for the commit event, whereas commitSpy checks for
-    // the _commit function of the element.
-    const commitHandler = sinon.spy();
-    element.addEventListener('commit', commitHandler);
-    const commitSpy = sinon.spy(element, '_commit');
-    element._focused = true;
-
-    element._suggestions = ['tunnel snakes rule!'];
-    element.tabComplete = false;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isFalse(commitSpy.called);
-    assert.isFalse(element._focused);
-
-    element.tabComplete = true;
-    element._focused = true;
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    assert.isFalse(commitHandler.called);
-    assert.isTrue(commitSpy.called);
-    assert.isTrue(element._focused);
-  });
-
-  test('_focused flag properly triggered', () => {
-    flush();
-    assert.isFalse(element._focused);
-    const input = element.shadowRoot
-        .querySelector('paper-input').inputElement;
-    MockInteractions.focus(input);
-    assert.isTrue(element._focused);
-  });
-
-  test('search icon shows with showSearchIcon property', () => {
-    flush();
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('iron-icon')).display,
-    'none');
-    element.showSearchIcon = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('iron-icon')).display,
-    'none');
-  });
-
-  test('vertical offset overridden by param if it exists', () => {
-    assert.equal(element.$.suggestions.verticalOffset, 31);
-    element.verticalOffset = 30;
-    assert.equal(element.$.suggestions.verticalOffset, 30);
-  });
-
-  test('_focused flag shows/hides the suggestions', () => {
-    const openStub = sinon.stub(element.$.suggestions, 'open');
-    const closedStub = sinon.stub(element.$.suggestions, 'close');
-    element._suggestions = ['hello', 'its me'];
-    assert.isFalse(openStub.called);
-    assert.isTrue(closedStub.calledOnce);
-    element._focused = true;
-    assert.isTrue(openStub.calledOnce);
-    element._suggestions = [];
-    assert.isTrue(closedStub.calledTwice);
-    assert.isTrue(openStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete hidden does nothing without' +
-        'without allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isFalse(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete hidden with' +
-        'allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = true;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.called);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('_handleInputCommit with autocomplete open calls commit' +
-        'with allowNonSuggestedValues', () => {
-    const commitStub = sinon.stub(element, '_commit');
-    element.allowNonSuggestedValues = true;
-    element.$.suggestions.isHidden = false;
-    element._handleInputCommit();
-    assert.isTrue(commitStub.calledOnce);
-  });
-
-  test('issue 8655', () => {
-    function makeSuggestion(s) { return {name: s, text: s, value: s}; }
-    const keydownSpy = sinon.spy(element, '_handleKeydown');
-    element.setText('file:');
-    element._suggestions =
-        [makeSuggestion('file:'), makeSuggestion('-file:')];
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
-    // Must set the value, because the MockInteraction does not.
-    element.$.input.value = 'file:x';
-    assert.isTrue(keydownSpy.calledOnce);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input,
-        13,
-        null,
-        'enter'
-    );
-    assert.isTrue(keydownSpy.calledTwice);
-    assert.equal(element.text, 'file:x');
-  });
-
-  suite('focus', () => {
-    let commitSpy;
-    let focusSpy;
-
-    setup(() => {
-      commitSpy = sinon.spy(element, '_commit');
-    });
-
-    test('enter does not call focus', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
-          'enter');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = true', () => {
-      focusSpy = sinon.spy(element, 'focus');
-      const commitHandler = sinon.stub();
-      element.addEventListener('commit', commitHandler);
-      element.tabComplete = true;
-      element._suggestions = ['tunnel snakes drool'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(focusSpy.called);
-      assert.isFalse(commitHandler.called);
-      assert.equal(element._suggestions.length, 0);
-    });
-
-    test('tab in input, tabComplete = false', () => {
-      element._suggestions = ['sugar bombs'];
-      focusSpy = sinon.spy(element, 'focus');
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      flush();
-
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(focusSpy.called);
-      assert.equal(element._suggestions.length, 1);
-    });
-
-    test('tab on suggestion, tabComplete = false', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is false, do not focus.
-      element.tabComplete = false;
-      focusSpy = sinon.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flush();
-      assert.isFalse(commitSpy.called);
-      assert.isFalse(element._focused);
-    });
-
-    test('tab on suggestion, tabComplete = true', () => {
-      element._suggestions = [{name: 'sugar bombs'}];
-      element._focused = true;
-      // When tabComplete is true, focus.
-      element.tabComplete = true;
-      focusSpy = sinon.spy(element, 'focus');
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-
-      MockInteractions.pressAndReleaseKeyOn(
-          element.$.suggestions.shadowRoot
-              .querySelector('li:first-child'), 9, null, 'tab');
-      flush();
-
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element._focused);
-    });
-
-    test('tap on suggestion commits, does not call focus', () => {
-      focusSpy = sinon.spy(element, 'focus');
-      element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      flush$0();
-      assert.isFalse(element.$.suggestions.isHidden);
-      MockInteractions.tap(element.$.suggestions.shadowRoot
-          .querySelector('li:first-child'));
-      flush();
-
-      assert.isFalse(focusSpy.called);
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element.$.suggestions.isHidden);
-    });
-  });
-
-  test('input-keydown event fired', () => {
-    const listener = sinon.spy();
-    element.addEventListener('input-keydown', listener);
-    MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-    flush();
-    assert.isTrue(listener.called);
-  });
-
-  test('enter with modifier does not complete', () => {
-    const handleSpy = sinon.spy(element, '_handleKeydown');
-    const commitStub = sinon.stub(element, '_handleInputCommit');
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, 'ctrl', 'enter');
-    assert.isTrue(handleSpy.called);
-    assert.isFalse(commitStub.called);
-    MockInteractions.pressAndReleaseKeyOn(
-        element.$.input, 13, null, 'enter');
-    assert.isTrue(commitStub.called);
-  });
-
-  suite('warnUncommitted', () => {
-    let inputClassList;
-    setup(() => {
-      inputClassList = element.$.input.classList;
-    });
-
-    test('enabled', () => {
-      element.warnUncommitted = true;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isTrue(inputClassList.contains('warnUncommitted'));
-      MockInteractions.focus(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('disabled', () => {
-      element.warnUncommitted = false;
-      element.text = 'blah blah blah';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-
-    test('no text', () => {
-      element.warnUncommitted = true;
-      element.text = '';
-      MockInteractions.blur(element.$.input);
-      assert.isFalse(inputClassList.contains('warnUncommitted'));
-    });
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
new file mode 100644
index 0000000..7da7ed5
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.ts
@@ -0,0 +1,621 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import '../../../test/common-test-setup-karma';
+import './gr-autocomplete';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {flush as flush$0} from '@polymer/polymer/lib/legacy/polymer.dom';
+import {AutocompleteSuggestion, GrAutocomplete} from './gr-autocomplete';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {assertIsDefined} from '../../../utils/common-util';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {PaperInputElement} from '@polymer/paper-input/paper-input';
+
+const basicFixture = fixtureFromTemplate(
+  html`<gr-autocomplete no-debounce></gr-autocomplete>`
+);
+
+suite('gr-autocomplete tests', () => {
+  let element: GrAutocomplete;
+
+  const focusOnInput = () => {
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+  };
+
+  const suggestionsEl = () =>
+    queryAndAssert<GrAutocompleteDropdown>(element, '#suggestions');
+
+  const inputEl = () => queryAndAssert<HTMLInputElement>(element, '#input');
+
+  setup(() => {
+    element = basicFixture.instantiate() as GrAutocomplete;
+  });
+
+  test('renders', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (input: string) =>
+        (promise = Promise.resolve([
+          {name: input + ' 0', value: '0'},
+          {name: input + ' 1', value: '1'},
+          {name: input + ' 2', value: '2'},
+          {name: input + ' 3', value: '3'},
+          {name: input + ' 4', value: '4'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+    assert.isTrue(suggestionsEl().isHidden);
+    assert.equal(suggestionsEl().cursor.index, -1);
+
+    focusOnInput();
+    element.text = 'blah';
+
+    assert.isTrue(queryStub.called);
+    element._focused = true;
+
+    assertIsDefined(promise);
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+      const suggestions = queryAll<HTMLElement>(suggestionsEl(), 'li');
+      assert.equal(suggestions.length, 5);
+
+      for (let i = 0; i < 5; i++) {
+        assert.equal(suggestions[i].innerText.trim(), `blah ${i}`);
+      }
+
+      assert.notEqual(suggestionsEl().cursor.index, -1);
+    });
+  });
+
+  test('selectAll', async () => {
+    await flush();
+    const nativeInput = element._nativeInput;
+    const selectionStub = sinon.stub(nativeInput, 'setSelectionRange');
+
+    element.selectAll();
+    assert.isFalse(selectionStub.called);
+
+    inputEl().value = 'test';
+    element.selectAll();
+    assert.isTrue(selectionStub.called);
+  });
+
+  test('esc key behavior', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (_: string) =>
+        (promise = Promise.resolve([
+          {name: 'blah', value: '123'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+
+      const cancelHandler = sinon.spy();
+      element.addEventListener('cancel', cancelHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isFalse(cancelHandler.called);
+      assert.isTrue(suggestionsEl().isHidden);
+      assert.equal(element._suggestions.length, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 27, null, 'esc');
+      assert.isTrue(cancelHandler.called);
+    });
+  });
+
+  test('emits commit and handles cursor movement', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(
+      (input: string) =>
+        (promise = Promise.resolve([
+          {name: input + ' 0', value: '0'},
+          {name: input + ' 1', value: '1'},
+          {name: input + ' 2', value: '2'},
+          {name: input + ' 3', value: '3'},
+          {name: input + ' 4', value: '4'},
+        ] as AutocompleteSuggestion[]))
+    );
+    element.query = queryStub;
+
+    assert.isTrue(suggestionsEl().isHidden);
+    assert.equal(suggestionsEl().cursor.index, -1);
+    element._focused = true;
+    element.text = 'blah';
+
+    return promise.then(() => {
+      assert.isFalse(suggestionsEl().isHidden);
+
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      assert.equal(suggestionsEl().cursor.index, 0);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+
+      assert.equal(suggestionsEl().cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 40, null, 'down');
+
+      assert.equal(suggestionsEl().cursor.index, 2);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 38, null, 'up');
+
+      assert.equal(suggestionsEl().cursor.index, 1);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.equal(element.value, '1');
+      assert.isTrue(commitHandler.called);
+      assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
+      assert.isTrue(suggestionsEl().isHidden);
+      assert.isTrue(element._focused);
+    });
+  });
+
+  test('clear-on-commit behavior (off)', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([
+        {name: 'suggestion', value: '0'},
+      ] as AutocompleteSuggestion[]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah';
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'suggestion');
+    });
+  });
+
+  test('clear-on-commit behavior (on)', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon.spy(() => {
+      promise = Promise.resolve([
+        {name: 'suggestion', value: '0'},
+      ] as AutocompleteSuggestion[]);
+      return promise;
+    });
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah';
+    element.clearOnCommit = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, '');
+    });
+  });
+
+  test('threshold guards the query', () => {
+    const queryStub = sinon.spy(() =>
+      Promise.resolve([] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+    element.threshold = 2;
+    focusOnInput();
+    element.text = 'a';
+    assert.isFalse(queryStub.called);
+    element.text = 'ab';
+    assert.isTrue(queryStub.called);
+  });
+
+  test('noDebounce=false debounces the query', () => {
+    const clock = sinon.useFakeTimers();
+    const queryStub = sinon.spy(() =>
+      Promise.resolve([] as AutocompleteSuggestion[])
+    );
+    element.query = queryStub;
+    element.noDebounce = false;
+    focusOnInput();
+    element.text = 'a';
+
+    // not called right away
+    assert.isFalse(queryStub.called);
+
+    // but called after a while
+    clock.tick(1000);
+    assert.isTrue(queryStub.called);
+  });
+
+  test('_computeClass respects border property', () => {
+    assert.equal(element._computeClass(), '');
+    assert.equal(element._computeClass(false), '');
+    assert.equal(element._computeClass(true), 'borderless');
+  });
+
+  test('undefined or empty text results in no suggestions', () => {
+    element._updateSuggestions(undefined, 0, undefined);
+    assert.equal(element._suggestions.length, 0);
+  });
+
+  test('when focused', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    assert.equal(element._focused, true);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      assert.equal(queryStub.notCalled, false);
+    });
+  });
+
+  test('when not focused', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    element.text = 'bla';
+    assert.equal(element._focused, false);
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('suggestions should not carry over', () => {
+    let promise: Promise<AutocompleteSuggestion[]> = Promise.resolve([]);
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'bla';
+    flush();
+    return promise.then(() => {
+      assert.equal(element._suggestions.length, 1);
+      element._updateSuggestions('', 0, false);
+      assert.equal(element._suggestions.length, 0);
+    });
+  });
+
+  test('multi completes only the last part of the query', () => {
+    let promise;
+    const queryStub = sinon
+      .stub()
+      .returns(
+        (promise = Promise.resolve([
+          {name: 'suggestion', value: '0'},
+        ] as AutocompleteSuggestion[]))
+      );
+    element.query = queryStub;
+    focusOnInput();
+    element.text = 'blah blah';
+    element.multi = true;
+
+    return promise.then(() => {
+      const commitHandler = sinon.spy();
+      element.addEventListener('commit', commitHandler);
+
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+
+      assert.isTrue(commitHandler.called);
+      assert.equal(element.text, 'blah 0');
+    });
+  });
+
+  test('tabComplete flag functions', () => {
+    // commitHandler checks for the commit event, whereas commitSpy checks for
+    // the _commit function of the element.
+    const commitHandler = sinon.spy();
+    element.addEventListener('commit', commitHandler);
+    const commitSpy = sinon.spy(element, '_commit');
+    element._focused = true;
+
+    element._suggestions = [{text: 'tunnel snakes rule!'}];
+    element.tabComplete = false;
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isFalse(commitSpy.called);
+    assert.isFalse(element._focused);
+
+    element.tabComplete = true;
+    element._focused = true;
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    assert.isFalse(commitHandler.called);
+    assert.isTrue(commitSpy.called);
+    assert.isTrue(element._focused);
+  });
+
+  test('_focused flag properly triggered', () => {
+    flush();
+    assert.isFalse(element._focused);
+    const input = queryAndAssert<PaperInputElement>(
+      element,
+      'paper-input'
+    ).inputElement;
+    MockInteractions.focus(input);
+    assert.isTrue(element._focused);
+  });
+
+  test('search icon shows with showSearchIcon property', () => {
+    flush();
+    assert.equal(
+      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      'none'
+    );
+    element.showSearchIcon = true;
+    assert.notEqual(
+      getComputedStyle(queryAndAssert(element, 'iron-icon')).display,
+      'none'
+    );
+  });
+
+  test('vertical offset overridden by param if it exists', () => {
+    assert.equal(suggestionsEl().verticalOffset, 31);
+    element.verticalOffset = 30;
+    assert.equal(suggestionsEl().verticalOffset, 30);
+  });
+
+  test('_focused flag shows/hides the suggestions', () => {
+    const openStub = sinon.stub(suggestionsEl(), 'open');
+    const closedStub = sinon.stub(suggestionsEl(), 'close');
+    element._suggestions = [{text: 'hello'}, {text: 'its me'}];
+    assert.isFalse(openStub.called);
+    assert.isTrue(closedStub.calledOnce);
+    element._focused = true;
+    assert.isTrue(openStub.calledOnce);
+    element._suggestions = [];
+    assert.isTrue(closedStub.calledTwice);
+    assert.isTrue(openStub.calledOnce);
+  });
+
+  test(
+    '_handleInputCommit with autocomplete hidden does nothing without' +
+      'without allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      suggestionsEl().isHidden = true;
+      element._handleInputCommit();
+      assert.isFalse(commitStub.called);
+    }
+  );
+
+  test(
+    '_handleInputCommit with autocomplete hidden with' +
+      'allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      suggestionsEl().isHidden = true;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.called);
+    }
+  );
+
+  test('_handleInputCommit with autocomplete open calls commit', () => {
+    const commitStub = sinon.stub(element, '_commit');
+    suggestionsEl().isHidden = false;
+    element._handleInputCommit();
+    assert.isTrue(commitStub.calledOnce);
+  });
+
+  test(
+    '_handleInputCommit with autocomplete open calls commit' +
+      'with allowNonSuggestedValues',
+    () => {
+      const commitStub = sinon.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      suggestionsEl().isHidden = false;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.calledOnce);
+    }
+  );
+
+  test('issue 8655', () => {
+    function makeSuggestion(s: string) {
+      return {name: s, text: s, value: s};
+    }
+    const keydownSpy = sinon.spy(element, '_handleKeydown');
+    element.setText('file:');
+    element._suggestions = [makeSuggestion('file:'), makeSuggestion('-file:')];
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 88, null, 'x');
+    // Must set the value, because the MockInteraction does not.
+    inputEl().value = 'file:x';
+    assert.isTrue(keydownSpy.calledOnce);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    assert.isTrue(keydownSpy.calledTwice);
+    assert.equal(element.text, 'file:x');
+  });
+
+  suite('focus', () => {
+    let commitSpy: sinon.SinonSpy;
+    let focusSpy: sinon.SinonSpy;
+
+    setup(() => {
+      commitSpy = sinon.spy(element, '_commit');
+    });
+
+    test('enter does not call focus', () => {
+      element._suggestions = [{text: 'sugar bombs'}];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = true', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      const commitHandler = sinon.stub();
+      element.addEventListener('commit', commitHandler);
+      element.tabComplete = true;
+      element._suggestions = [{text: 'tunnel snakes drool'}];
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(focusSpy.called);
+      assert.isFalse(commitHandler.called);
+      assert.equal(element._suggestions.length, 0);
+    });
+
+    test('tab in input, tabComplete = false', () => {
+      element._suggestions = [{text: 'sugar bombs'}];
+      focusSpy = sinon.spy(element, 'focus');
+      MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+      flush();
+
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(focusSpy.called);
+      assert.equal(element._suggestions.length, 1);
+    });
+
+    test('tab on suggestion, tabComplete = false', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is false, do not focus.
+      element.tabComplete = false;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert(suggestionsEl(), 'li:first-child'),
+        9,
+        null,
+        'tab'
+      );
+      flush();
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(element._focused);
+    });
+
+    test('tab on suggestion, tabComplete = true', () => {
+      element._suggestions = [{name: 'sugar bombs'}];
+      element._focused = true;
+      // When tabComplete is true, focus.
+      element.tabComplete = true;
+      focusSpy = sinon.spy(element, 'focus');
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+
+      MockInteractions.pressAndReleaseKeyOn(
+        queryAndAssert(suggestionsEl(), 'li:first-child'),
+        9,
+        null,
+        'tab'
+      );
+      flush();
+
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element._focused);
+    });
+
+    test('tap on suggestion commits, does not call focus', () => {
+      focusSpy = sinon.spy(element, 'focus');
+      element._focused = true;
+      element._suggestions = [{name: 'first suggestion'}];
+      flush$0();
+      assert.isFalse(suggestionsEl().isHidden);
+      MockInteractions.tap(queryAndAssert(suggestionsEl(), 'li:first-child'));
+      flush();
+
+      assert.isFalse(focusSpy.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(suggestionsEl().isHidden);
+    });
+  });
+
+  test('input-keydown event fired', () => {
+    const listener = sinon.spy();
+    element.addEventListener('input-keydown', listener);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 9, null, 'tab');
+    flush();
+    assert.isTrue(listener.called);
+  });
+
+  test('enter with modifier does not complete', () => {
+    const handleSpy = sinon.spy(element, '_handleKeydown');
+    const commitStub = sinon.stub(element, '_handleInputCommit');
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, 'ctrl', 'enter');
+    assert.isTrue(handleSpy.called);
+    assert.isFalse(commitStub.called);
+    MockInteractions.pressAndReleaseKeyOn(inputEl(), 13, null, 'enter');
+    assert.isTrue(commitStub.called);
+  });
+
+  suite('warnUncommitted', () => {
+    let inputClassList: DOMTokenList;
+    setup(() => {
+      inputClassList = inputEl().classList;
+    });
+
+    test('enabled', () => {
+      element.warnUncommitted = true;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(inputEl());
+      assert.isTrue(inputClassList.contains('warnUncommitted'));
+      MockInteractions.focus(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('disabled', () => {
+      element.warnUncommitted = false;
+      element.text = 'blah blah blah';
+      MockInteractions.blur(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+
+    test('no text', () => {
+      element.warnUncommitted = true;
+      element.text = '';
+      MockInteractions.blur(inputEl());
+      assert.isFalse(inputClassList.contains('warnUncommitted'));
+    });
+  });
+});
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 e30e995..33bf6c6 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.ts
@@ -14,22 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-avatar_html';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
-import {customElement, property} from '@polymer/decorators';
 import {AccountInfo} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 @customElement('gr-avatar')
-export class GrAvatar extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: Object, observer: '_accountChanged'})
+export class GrAvatar extends LitElement {
+  @property({type: Object})
   account?: AccountInfo;
 
   @property({type: Number})
@@ -40,8 +34,31 @@
 
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  static override get styles() {
+    return [
+      css`
+        :host([hidden]) {
+          display: none;
+        }
+        :host {
+          display: inline-block;
+          border-radius: 50%;
+          background-size: cover;
+          background-color: var(
+            --avatar-background-color,
+            var(--gray-background)
+          );
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    this._updateAvatarURL();
+    return html``;
+  }
+
+  override connectedCallback() {
     super.connectedCallback();
     Promise.all([
       this._getConfig(),
@@ -57,10 +74,6 @@
     return this.restApiService.getConfig();
   }
 
-  _accountChanged() {
-    this._updateAvatarURL();
-  }
-
   _updateAvatarURL() {
     if (!this._hasAvatars || !this.account) {
       this.hidden = true;
@@ -80,7 +93,7 @@
     );
   }
 
-  _buildAvatarURL(account: AccountInfo) {
+  _buildAvatarURL(account?: AccountInfo) {
     if (!account) {
       return '';
     }
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
deleted file mode 100644
index a1e51df..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_html.ts
+++ /dev/null
@@ -1,28 +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>
-    :host {
-      display: inline-block;
-      border-radius: 50%;
-      background-size: cover;
-      background-color: var(--avatar-background-color, var(--gray-background));
-    }
-  </style>
-`;
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
deleted file mode 100644
index df8632f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.js
+++ /dev/null
@@ -1,198 +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-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(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s12-p/photo.jpg',
-              height: 12,
-            },
-            {
-              url: 'https://cdn.example.com/s16-p/photo.jpg',
-              height: 16,
-            },
-            {
-              url: 'https://cdn.example.com/s100-p/photo.jpg',
-              height: 100,
-            },
-          ],
-        }),
-        'https://cdn.example.com/s16-p/photo.jpg');
-    assert.equal(
-        element._buildAvatarURL({
-          _account_id: 123,
-          avatars: [
-            {
-              url: 'https://cdn.example.com/s95-p/photo.jpg',
-              height: 95,
-            },
-          ],
-        }),
-        '/accounts/123/avatar?s=16');
-    assert.equal(element._buildAvatarURL(undefined), '');
-  });
-
-  suite('config set', () => {
-    setup(() => {
-      stub('gr-avatar', '_getConfig').callsFake(() =>
-        Promise.resolve({plugin: {has_avatars: true}})
-      );
-      element = basicFixture.instantiate();
-    });
-
-    test('dom for existing account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      element.imageSize = 64;
-      element.account = {
-        _account_id: 123,
-        avatars: defaultAvatars,
-      };
-      flush();
-
-      assert.strictEqual(element.style.backgroundImage, '');
-
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isFalse(element.hasAttribute('hidden'));
-
-        assert.isTrue(
-            element.style.backgroundImage.includes(
-                '/accounts/123/avatar?s=64'));
-      });
-    });
-  });
-
-  suite('plugin has avatars', () => {
-    let element;
-
-    setup(() => {
-      stub('gr-avatar', '_getConfig').callsFake(() =>
-        Promise.resolve({plugin: {has_avatars: true}})
-      );
-
-      element = basicFixture.instantiate();
-    });
-
-    test('dom for non available account', () => {
-      assert.isFalse(element.hasAttribute('hidden'));
-
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-
-        assert.strictEqual(element.style.backgroundImage, '');
-      });
-    });
-  });
-
-  suite('config not set', () => {
-    let element;
-
-    setup(() => {
-      stub('gr-avatar', '_getConfig').callsFake(() => Promise.resolve({}));
-
-      element = basicFixture.instantiate();
-    });
-
-    test('avatar hidden when account set', async () => {
-      await flush();
-      assert.isTrue(element.hasAttribute('hidden'));
-
-      element.imageSize = 64;
-      element.account = {
-        _account_id: 123,
-        avatars: defaultAvatars,
-      };
-      // Emulate plugins loaded.
-      getPluginLoader().loadPlugins([]);
-
-      return Promise.all([
-        appContext.restApiService.getConfig(),
-        getPluginLoader().awaitPluginsLoaded(),
-      ]).then(() => {
-        assert.isTrue(element.hasAttribute('hidden'));
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
new file mode 100644
index 0000000..b3c485a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.ts
@@ -0,0 +1,215 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-avatar';
+import {GrAvatar} from './gr-avatar';
+import {getPluginLoader} from '../gr-js-api-interface/gr-plugin-loader';
+import {appContext} from '../../../services/app-context';
+import {AvatarInfo} from '../../../types/common';
+import {
+  createAccountWithEmail,
+  createAccountWithId,
+  createServerInfo,
+} from '../../../test/test-data-generators';
+
+const basicFixture = fixtureFromElement('gr-avatar');
+
+suite('gr-avatar tests', () => {
+  let element: GrAvatar;
+  const defaultAvatars: AvatarInfo[] = [
+    {
+      url: 'https://cdn.example.com/s12-p/photo.jpg',
+      height: 12,
+      width: 0,
+    },
+  ];
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('account without avatar', () => {
+    assert.equal(element._buildAvatarURL(createAccountWithId(123)), '');
+  });
+
+  test('methods', () => {
+    assert.equal(
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: defaultAvatars,
+      }),
+      '/accounts/123/avatar?s=16'
+    );
+    assert.equal(
+      element._buildAvatarURL({
+        ...createAccountWithEmail('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(
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: [
+          {
+            url: 'https://cdn.example.com/s12-p/photo.jpg',
+            height: 12,
+            width: 0,
+          },
+          {
+            url: 'https://cdn.example.com/s16-p/photo.jpg',
+            height: 16,
+            width: 0,
+          },
+          {
+            url: 'https://cdn.example.com/s100-p/photo.jpg',
+            height: 100,
+            width: 0,
+          },
+        ] as AvatarInfo[],
+      }),
+      'https://cdn.example.com/s16-p/photo.jpg'
+    );
+    assert.equal(
+      element._buildAvatarURL({
+        ...createAccountWithId(123),
+        avatars: [
+          {
+            url: 'https://cdn.example.com/s95-p/photo.jpg',
+            height: 95,
+            width: 0,
+          },
+        ] as AvatarInfo[],
+      }),
+      '/accounts/123/avatar?s=16'
+    );
+    assert.equal(element._buildAvatarURL(undefined), '');
+  });
+
+  suite('config set', () => {
+    setup(() => {
+      const config = {
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      };
+      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
+      element = basicFixture.instantiate();
+    });
+
+    test('dom for existing account', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      element.imageSize = 64;
+      element.account = {
+        ...createAccountWithId(123),
+        avatars: defaultAvatars,
+      };
+      flush();
+
+      assert.strictEqual(element.style.backgroundImage, '');
+
+      // Emulate plugins loaded.
+      getPluginLoader().loadPlugins([]);
+
+      return Promise.all([
+        appContext.restApiService.getConfig(),
+        getPluginLoader().awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isFalse(element.hasAttribute('hidden'));
+
+        assert.isTrue(
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64')
+        );
+      });
+    });
+  });
+
+  suite('plugin has avatars', () => {
+    let element: GrAvatar;
+
+    setup(() => {
+      const config = {
+        ...createServerInfo(),
+        plugin: {has_avatars: true, js_resource_paths: []},
+      };
+      stub('gr-avatar', '_getConfig').returns(Promise.resolve(config));
+
+      element = basicFixture.instantiate();
+    });
+
+    test('dom for non available account', () => {
+      assert.isFalse(element.hasAttribute('hidden'));
+
+      // Emulate plugins loaded.
+      getPluginLoader().loadPlugins([]);
+
+      return Promise.all([
+        appContext.restApiService.getConfig(),
+        getPluginLoader().awaitPluginsLoaded(),
+      ]).then(() => {
+        assert.isTrue(element.hasAttribute('hidden'));
+
+        assert.strictEqual(element.style.backgroundImage, '');
+      });
+    });
+  });
+
+  suite('config not set', () => {
+    let element: GrAvatar;
+
+    setup(() => {
+      stub('gr-avatar', '_getConfig').returns(Promise.resolve(undefined));
+
+      element = basicFixture.instantiate();
+    });
+
+    test('avatar hidden when account set', async () => {
+      await flush();
+      assert.isTrue(element.hasAttribute('hidden'));
+
+      element.imageSize = 64;
+      element.account = {
+        ...createAccountWithId(123),
+        avatars: defaultAvatars,
+      };
+      // Emulate plugins loaded.
+      getPluginLoader().loadPlugins([]);
+
+      return Promise.all([
+        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 6a7c05b..ea5b5bb 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.ts
@@ -15,16 +15,13 @@
  * limitations under the License.
  */
 import '@polymer/paper-button/paper-button';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property, computed, observe} from '@polymer/decorators';
-import {htmlTemplate} from './gr-button_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import {PolymerEvent, getEventPath} from '../../../utils/dom-util';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {votingStyles} from '../../../styles/gr-voting-styles';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {getEventPath, modifierPressed} from '../../../utils/dom-util';
 import {appContext} from '../../../services/app-context';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
-import {CustomKeyboardEvent} from '../../../types/events';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -33,75 +30,209 @@
 }
 
 @customElement('gr-button')
-export class GrButton extends KeyboardShortcutMixin(
-  TooltipMixin(PolymerElement)
-) {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrButton extends LitElement {
+  private readonly reporting: ReportingService = appContext.reportingService;
 
-  @property({type: Boolean, reflectToAttribute: true})
-  downArrow = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  link = false;
-
-  @property({type: Boolean})
-  noUppercase = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  loading = false;
-
-  @property({type: Boolean, reflectToAttribute: true})
-  disabled: boolean | null = null;
-
-  @property({type: String})
-  tooltip = '';
+  /**
+   * Should this button be rendered as a vote chip? Then we are applying
+   * the .voteChip class (see gr-voting-styles) to the paper-button.
+   */
+  @property({type: Boolean, reflect: true})
+  voteChip = false;
 
   // Note: don't assign a value to this, since constructor is called
   // after created, the initial value maybe overridden by this
-  @property({type: String})
-  _initialTabindex?: string;
+  private initialTabindex?: string;
 
-  @computed('disabled', 'loading')
-  get _disabled() {
-    return this.disabled || this.loading;
+  @property({type: Boolean, reflect: true, attribute: 'down-arrow'})
+  downArrow = false;
+
+  @property({type: Boolean, reflect: true})
+  link = false;
+
+  @property({type: Boolean, reflect: true})
+  loading = false;
+
+  @property({type: Boolean, reflect: true})
+  disabled: boolean | null = null;
+
+  static override get styles() {
+    return [
+      votingStyles,
+      spinnerStyles,
+      css`
+        /* general styles for all buttons */
+        :host {
+          --background-color: var(
+            --button-background-color,
+            var(--default-button-background-color)
+          );
+          --text-color: var(
+            --gr-button-text-color,
+            var(--default-button-text-color)
+          );
+          display: inline-block;
+          position: relative;
+        }
+        :host([hidden]) {
+          display: none;
+        }
+        :host([no-uppercase]) paper-button {
+          text-transform: none;
+        }
+        paper-button {
+          /* paper-button sets this to anti-aliased, which appears different than
+            bold font elsewhere on macOS. */
+          -webkit-font-smoothing: initial;
+          align-items: center;
+          background-color: var(--background-color);
+          color: var(--text-color);
+          display: flex;
+          font-family: var(--font-family, inherit);
+          /** Without this '.keyboard-focus' buttons will get bolded. */
+          font-weight: var(--font-weight-normal, inherit);
+          justify-content: center;
+          margin: var(--margin, 0);
+          min-width: var(--border, 0);
+          padding: var(--gr-button-padding, var(--spacing-s) var(--spacing-m));
+        }
+        paper-button[elevation='1'] {
+          box-shadow: var(--elevation-level-1);
+        }
+        paper-button[elevation='2'] {
+          box-shadow: var(--elevation-level-2);
+        }
+        paper-button[elevation='3'] {
+          box-shadow: var(--elevation-level-3);
+        }
+        paper-button[elevation='4'] {
+          box-shadow: var(--elevation-level-4);
+        }
+        paper-button[elevation='5'] {
+          box-shadow: var(--elevation-level-5);
+        }
+        paper-button:hover {
+          background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
+            var(--background-color);
+        }
+
+        /* Some mobile browsers treat focused element as hovered element.
+        As a result, element remains hovered after click (has grey background in default theme).
+        Use @media (hover:none) to remove background if
+        user's primary input mechanism can't hover over elements.
+        See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
+
+        Note 1: not all browsers support this media query
+        (see https://caniuse.com/#feat=css-media-interaction).
+        If browser doesn't support it, then the whole content of @media .. is ignored.
+        This is why the default behavior is placed outside of @media.
+        */
+        @media (hover: none) {
+          paper-button:hover {
+            background: transparent;
+          }
+        }
+
+        :host([primary]) {
+          --background-color: var(--primary-button-background-color);
+          --text-color: var(--primary-button-text-color);
+        }
+        :host([link][primary]) {
+          --text-color: var(--primary-button-background-color);
+        }
+
+        /* Keep below color definition for primary so that this takes precedence
+          when disabled. */
+        :host([disabled]),
+        :host([loading]) {
+          --background-color: var(--disabled-button-background-color);
+          --text-color: var(--deemphasized-text-color);
+          cursor: default;
+        }
+
+        /* Styles for link buttons specifically */
+        :host([link]) {
+          --background-color: transparent;
+          --margin: 0;
+        }
+        :host([link]) paper-button {
+          padding: var(--gr-button-padding, var(--spacing-s));
+        }
+        :host([disabled][link]),
+        :host([loading][link]) {
+          --background-color: transparent;
+          --text-color: var(--deemphasized-text-color);
+          cursor: default;
+        }
+
+        /* Styles for the optional down arrow */
+        :host(:not([down-arrow])) .downArrow {
+          display: none;
+        }
+        :host([down-arrow]) .downArrow {
+          border-top: 0.36em solid #ccc;
+          border-left: 0.36em solid transparent;
+          border-right: 0.36em solid transparent;
+          margin-bottom: var(--spacing-xxs);
+          margin-left: var(--spacing-m);
+          transition: border-top-color 200ms;
+        }
+        :host([down-arrow]) paper-button:hover .downArrow {
+          border-top-color: var(--deemphasized-text-color);
+        }
+      `,
+    ];
   }
 
-  @property({
-    computed: 'computeAriaDisabled(disabled, loading)',
-    reflectToAttribute: true,
-    type: Boolean,
-  })
-  ariaDisabled!: boolean;
-
-  computeAriaDisabled() {
-    return this._disabled;
+  override render() {
+    return html`<paper-button
+      ?raised="${!this.link}"
+      ?disabled="${this.disabled || this.loading}"
+      role="button"
+      tabindex="-1"
+      part="paper-button"
+      class="${this.voteChip ? 'voteChip' : ''}"
+    >
+      ${this.loading ? html`<span class="loadingSpin"></span>` : ''}
+      <slot></slot>
+      <i class="downArrow"></i>
+    </paper-button>`;
   }
 
-  private readonly reporting: ReportingService = appContext.reportingService;
-
   constructor() {
     super();
-    this._initialTabindex = this.getAttribute('tabindex') || '0';
-    // TODO(TS): try avoid using unknown
-    this.addEventListener('click', e =>
-      this._handleAction((e as unknown) as PolymerEvent)
-    );
-    this.addEventListener('keydown', e =>
-      this._handleKeydown((e as unknown) as CustomKeyboardEvent)
-    );
+    this.initialTabindex = this.getAttribute('tabindex') || '0';
+    this.addEventListener('click', e => this._handleAction(e));
+    this.addEventListener('keydown', e => this._handleKeydown(e));
   }
 
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'button');
-    this._ensureAttribute('tabindex', '0');
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('disabled')) {
+      this.setAttribute(
+        'tabindex',
+        this.disabled ? '-1' : this.initialTabindex || '0'
+      );
+    }
+    if (changedProperties.has('loading') || changedProperties.has('disabled')) {
+      this.setAttribute(
+        'aria-disabled',
+        this.disabled || this.loading ? 'true' : 'false'
+      );
+    }
   }
 
-  _handleAction(e: PolymerEvent) {
-    if (this._disabled) {
+  override connectedCallback() {
+    super.connectedCallback();
+    if (!this.getAttribute('role')) {
+      this.setAttribute('role', 'button');
+    }
+    if (!this.getAttribute('tabindex')) {
+      this.setAttribute('tabindex', '0');
+    }
+  }
+
+  _handleAction(e: MouseEvent) {
+    if (this.disabled || this.loading) {
       e.preventDefault();
       e.stopPropagation();
       e.stopImmediatePropagation();
@@ -111,20 +242,8 @@
     this.reporting.reportInteraction('button-click', {path: getEventPath(e)});
   }
 
-  @observe('disabled')
-  _disabledChanged(disabled: boolean) {
-    this.setAttribute(
-      'tabindex',
-      disabled ? '-1' : this._initialTabindex || '0'
-    );
-    this.updateStyles();
-  }
-
-  _handleKeydown(e: CustomKeyboardEvent) {
-    if (this.modifierPressed(e)) {
-      return;
-    }
-    e = this.getKeyboardEvent(e);
+  _handleKeydown(e: KeyboardEvent) {
+    if (modifierPressed(e)) return;
     // Handle `enter`, `space`.
     if (e.keyCode === 13 || e.keyCode === 32) {
       e.preventDefault();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
deleted file mode 100644
index 4d94a98..0000000
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_html.ts
+++ /dev/null
@@ -1,179 +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>
-    /* general styles for all buttons */
-    :host {
-      --background-color: var(
-        --button-background-color,
-        var(--default-button-background-color)
-      );
-      --text-color: var(--default-button-text-color);
-      display: inline-block;
-      position: relative;
-    }
-    :host([hidden]) {
-      display: none;
-    }
-    :host([no-uppercase]) paper-button {
-      text-transform: none;
-    }
-    paper-button {
-      /* The next lines contains a copy of paper-button style.
-          Without a copy, the @apply works incorrectly with Polymer 2.
-          @apply is deprecated and is not recommended to use. It is expected
-          that @apply will be replaced with the ::part CSS pseudo-element.
-          After replacement copied lines can be removed.
-        */
-      @apply --layout-inline;
-      @apply --layout-center-center;
-      position: relative;
-      box-sizing: border-box;
-      min-width: 5.14em;
-      margin: 0 0.29em;
-      background: transparent;
-      -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-      -webkit-tap-highlight-color: transparent;
-      font: inherit;
-      text-transform: uppercase;
-      outline-width: 0;
-      border-top-left-radius: var(--border-radius);
-      border-top-right-radius: var(--border-radius);
-      border-bottom-right-radius: var(--border-radius);
-      border-bottom-left-radius: var(--border-radius);
-      -moz-user-select: none;
-      -ms-user-select: none;
-      -webkit-user-select: none;
-      user-select: none;
-      cursor: pointer;
-      z-index: 0;
-      padding: var(--spacing-m);
-
-      @apply --paper-font-common-base;
-      @apply --paper-button;
-      /* End of copy*/
-
-      /* paper-button sets this to anti-aliased, which appears different than
-          bold font elsewhere on macOS. */
-      -webkit-font-smoothing: initial;
-      align-items: center;
-      background-color: var(--background-color);
-      color: var(--text-color);
-      display: flex;
-      font-family: inherit;
-      justify-content: center;
-      margin: var(--margin, 0);
-      min-width: var(--border, 0);
-      padding: var(--padding, 4px 8px);
-      @apply --gr-button;
-    }
-    /* https://github.com/PolymerElements/paper-button/blob/2.x/paper-button.html */
-    /* BEGIN: Copy from paper-button */
-    paper-button[elevation='1'] {
-      @apply --paper-material-elevation-1;
-    }
-    paper-button[elevation='2'] {
-      @apply --paper-material-elevation-2;
-    }
-    paper-button[elevation='3'] {
-      @apply --paper-material-elevation-3;
-    }
-    paper-button[elevation='4'] {
-      @apply --paper-material-elevation-4;
-    }
-    paper-button[elevation='5'] {
-      @apply --paper-material-elevation-5;
-    }
-    /* END: Copy from paper-button */
-    paper-button:hover {
-      background: linear-gradient(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.12)),
-        var(--background-color);
-    }
-
-    /* Some mobile browsers treat focused element as hovered element.
-      As a result, element remains hovered after click (has grey background in default theme).
-      Use @media (hover:none) to remove background if
-      user's primary input mechanism can't hover over elements.
-      See: https://developer.mozilla.org/en-US/docs/Web/CSS/@media/hover
-
-      Note 1: not all browsers support this media query
-      (see https://caniuse.com/#feat=css-media-interaction).
-      If browser doesn't support it, then the whole content of @media .. is ignored.
-      This is why the default behavior is placed outside of @media.
-      */
-    @media (hover: none) {
-      paper-button:hover {
-        background: transparent;
-      }
-    }
-
-    :host([primary]) {
-      --background-color: var(--primary-button-background-color);
-      --text-color: var(--primary-button-text-color);
-    }
-    :host([link][primary]) {
-      --text-color: var(--primary-button-background-color);
-    }
-
-    /* Keep below color definition for primary so that this takes precedence
-        when disabled. */
-    :host([disabled]),
-    :host([loading]) {
-      --background-color: var(--disabled-button-background-color);
-      --text-color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-
-    /* Styles for link buttons specifically */
-    :host([link]) {
-      --background-color: transparent;
-      --margin: 0;
-      --padding: var(--spacing-s);
-    }
-    :host([disabled][link]),
-    :host([loading][link]) {
-      --background-color: transparent;
-      --text-color: var(--deemphasized-text-color);
-      cursor: default;
-    }
-
-    /* Styles for the optional down arrow */
-    :host(:not([down-arrow])) .downArrow {
-      display: none;
-    }
-    :host([down-arrow]) .downArrow {
-      border-top: 0.36em solid #ccc;
-      border-left: 0.36em solid transparent;
-      border-right: 0.36em solid transparent;
-      margin-bottom: var(--spacing-xxs);
-      margin-left: var(--spacing-m);
-      transition: border-top-color 200ms;
-    }
-    :host([down-arrow]) paper-button:hover .downArrow {
-      border-top-color: var(--deemphasized-text-color);
-    }
-  </style>
-  <paper-button raised="[[!link]]" disabled="[[_disabled]]" tabindex="-1">
-    <template is="dom-if" if="[[loading]]">
-      <span class="loadingSpin"></span>
-    </template>
-    <slot></slot>
-    <i class="downArrow"></i>
-  </paper-button>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
index f0f122a..0149bd5 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.ts
@@ -17,6 +17,7 @@
 
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
+import './gr-button';
 import {addListener} from '@polymer/polymer/lib/utils/gestures';
 import {appContext} from '../../../services/app-context';
 import {html} from '@polymer/polymer/lib/utils/html-tag';
@@ -49,23 +50,26 @@
     return spy;
   };
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('disabled is set by disabled', () => {
+  test('disabled is set by disabled', async () => {
     const paperBtn = queryAndAssert<PaperButtonElement>(
       element,
       'paper-button'
     );
     assert.isFalse(paperBtn.disabled);
     element.disabled = true;
+    await element.updateComplete;
     assert.isTrue(paperBtn.disabled);
     element.disabled = false;
+    await element.updateComplete;
     assert.isFalse(paperBtn.disabled);
   });
 
-  test('loading set from listener', () => {
+  test('loading set from listener', async () => {
     let resolve: Function;
     element.addEventListener('click', e => {
       const target = e.target as HTMLElement;
@@ -78,36 +82,44 @@
     );
     assert.isFalse(paperBtn.disabled);
     MockInteractions.tap(element);
+    await element.updateComplete;
     assert.isTrue(paperBtn.disabled);
     assert.isTrue(element.hasAttribute('loading'));
     resolve!();
-    flush();
+    await element.updateComplete;
     assert.isFalse(paperBtn.disabled);
     assert.isFalse(element.hasAttribute('loading'));
   });
 
-  test('tabindex should be -1 if disabled', () => {
+  test('tabindex should be -1 if disabled', async () => {
     element.disabled = true;
-    assert.isTrue(element.getAttribute('tabindex') === '-1');
+    await element.updateComplete;
+    assert.equal(element.getAttribute('tabindex'), '-1');
   });
 
   // Regression tests for Issue: 11969
-  test('tabindex should be reset to 0 if enabled', () => {
+  test('tabindex should be reset to 0 if enabled', async () => {
     element.disabled = false;
+    await element.updateComplete;
     assert.equal(element.getAttribute('tabindex'), '0');
     element.disabled = true;
+    await element.updateComplete;
     assert.equal(element.getAttribute('tabindex'), '-1');
     element.disabled = false;
+    await element.updateComplete;
     assert.equal(element.getAttribute('tabindex'), '0');
   });
 
-  test('tabindex should be preserved', () => {
+  test('tabindex should be preserved', async () => {
     const tabIndexElement = tabindexFixture.instantiate() as GrButton;
     tabIndexElement.disabled = false;
+    await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
     tabIndexElement.disabled = true;
+    await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '-1');
     tabIndexElement.disabled = false;
+    await element.updateComplete;
     assert.equal(tabIndexElement.getAttribute('tabindex'), '3');
   });
 
@@ -152,8 +164,9 @@
   }
 
   suite('disabled', () => {
-    setup(() => {
+    setup(async () => {
       element.disabled = true;
+      await element.updateComplete;
     });
 
     for (const eventName of ['tap', 'click']) {
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
index 2b66af8..a23621e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.ts
@@ -20,8 +20,12 @@
 import {htmlTemplate} from './gr-change-star_html';
 import {customElement, property} from '@polymer/decorators';
 import {ChangeInfo} from '../../../types/common';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {fireAlert} from '../../../utils/event-util';
+import {
+  Shortcut,
+  ShortcutSection,
+} from '../../../services/shortcuts/shortcuts-config';
+import {appContext} from '../../../services/app-context';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -35,7 +39,7 @@
 }
 
 @customElement('gr-change-star')
-export class GrChangeStar extends KeyboardShortcutMixin(PolymerElement) {
+export class GrChangeStar extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -49,16 +53,18 @@
   @property({type: Object, notify: true})
   change?: ChangeInfo;
 
-  _computeStarClass(starred: boolean) {
+  private readonly shortcuts = appContext.shortcutsService;
+
+  _computeStarClass(starred?: boolean) {
     return starred ? 'active' : '';
   }
 
-  _computeStarIcon(starred: boolean) {
+  _computeStarIcon(starred?: boolean) {
     // Hollow star is used to indicate inactive state.
     return `gr-icons:star${starred ? '' : '-border'}`;
   }
 
-  _computeAriaLabel(starred: boolean) {
+  _computeAriaLabel(starred?: boolean) {
     return starred ? 'Unstar this change' : 'Star this change';
   }
 
@@ -84,4 +90,8 @@
       })
     );
   }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    return this.shortcuts.createTitle(shortcutName, section);
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
index 6c0f6f7..d404795 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_html.ts
@@ -36,6 +36,10 @@
         var(--line-height-normal, 20px)
       );
     }
+    :host([hidden]) {
+      visibility: hidden;
+      display: block !important;
+    }
   </style>
   <button
     role="checkbox"
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 1fd019f..0bd02d5 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
@@ -15,17 +15,28 @@
  * limitations under the License.
  */
 import '../gr-tooltip-content/gr-tooltip-content';
+import '../gr-icons/gr-icons';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-change-status_html';
 import {customElement, property} from '@polymer/decorators';
+import {
+  GeneratedWebLink,
+  GerritNav,
+} from '../../core/gr-navigation/gr-navigation';
+import {ChangeInfo} from '../../../types/common';
+import {ParsedChangeInfo} from '../../../types/types';
 
-enum ChangeStates {
-  MERGED = 'Merged',
+export enum ChangeStates {
   ABANDONED = 'Abandoned',
+  ACTIVE = 'Active',
   MERGE_CONFLICT = 'Merge Conflict',
-  WIP = 'WIP',
+  MERGED = 'Merged',
   PRIVATE = 'Private',
+  READY_TO_SUBMIT = 'Ready to submit',
+  REVERT_CREATED = 'Revert Created',
+  REVERT_SUBMITTED = 'Revert Submitted',
+  WIP = 'WIP',
 }
 
 const WIP_TOOLTIP =
@@ -43,7 +54,7 @@
   'current reviewers (or anyone with "View Private Changes" permission).';
 
 @customElement('gr-change-status')
-class GrChangeStatus extends PolymerElement {
+export class GrChangeStatus extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -51,23 +62,68 @@
   @property({type: Boolean, reflectToAttribute: true})
   flat = false;
 
+  @property({type: Object})
+  change?: ChangeInfo | ParsedChangeInfo;
+
   @property({type: String, observer: '_updateChipDetails'})
   status?: ChangeStates;
 
   @property({type: String})
   tooltipText = '';
 
-  _computeStatusString(status: ChangeStates) {
+  @property({type: Object})
+  revertedChange?: ChangeInfo;
+
+  @property({type: Object})
+  resolveWeblinks?: GeneratedWebLink[] = [];
+
+  _computeStatusString(status?: ChangeStates) {
     if (status === ChangeStates.WIP && !this.flat) {
       return 'Work in Progress';
     }
-    return status;
+    return status ?? '';
   }
 
   _toClassName(str?: ChangeStates) {
     return str ? str.toLowerCase().replace(/\s/g, '-') : '';
   }
 
+  hasStatusLink(
+    revertedChange?: ChangeInfo,
+    resolveWeblinks?: GeneratedWebLink[],
+    status?: ChangeStates
+  ): boolean {
+    const isRevertCreatedOrSubmitted =
+      (status === ChangeStates.REVERT_SUBMITTED ||
+        status === ChangeStates.REVERT_CREATED) &&
+      revertedChange !== undefined;
+    return (
+      isRevertCreatedOrSubmitted ||
+      !!(status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length)
+    );
+  }
+
+  getStatusLink(
+    revertedChange?: ChangeInfo,
+    resolveWeblinks?: GeneratedWebLink[],
+    status?: ChangeStates
+  ): string {
+    if (revertedChange) {
+      return GerritNav.getUrlForSearchQuery(`${revertedChange._number}`);
+    }
+    if (status === ChangeStates.MERGE_CONFLICT && resolveWeblinks?.length) {
+      return resolveWeblinks[0].url ?? '';
+    }
+    return '';
+  }
+
+  showResolveIcon(
+    resolveWeblinks?: GeneratedWebLink[],
+    status?: ChangeStates
+  ): boolean {
+    return status === ChangeStates.MERGE_CONFLICT && !!resolveWeblinks?.length;
+  }
+
   _updateChipDetails(status?: ChangeStates, previousStatus?: ChangeStates) {
     if (previousStatus) {
       this.classList.remove(this._toClassName(previousStatus));
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
index 542d8be..455bd4e 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_html.ts
@@ -52,6 +52,17 @@
       background-color: var(--status-ready);
       color: var(--status-ready);
     }
+    :host(.revert-created) .chip {
+      background-color: var(--status-revert-created);
+      color: var(--status-revert-created);
+    }
+    :host(.revert-submitted) .chip {
+      background-color: var(--status-revert-created);
+      color: var(--status-revert-created);
+    }
+    .status-link {
+      text-decoration: none;
+    }
     :host(.custom) .chip {
       background-color: var(--status-custom);
       color: var(--status-custom);
@@ -60,18 +71,39 @@
       background-color: transparent;
       padding: 0;
     }
-    :host(:not([flat])) .chip {
+    :host(:not([flat])) .chip, .icon {
       color: var(--status-text-color);
     }
+    .icon {
+      --iron-icon-height: 18px;
+      --iron-icon-width: 18px;
+    }
   </style>
   <gr-tooltip-content
-    has-tooltip=""
-    position-below=""
+    has-tooltip
+    position-below
     title="[[tooltipText]]"
     max-width="40em"
   >
-    <div class="chip" aria-label$="Label: [[status]]">
-      [[_computeStatusString(status)]]
-    </div>
+    <template
+      is="dom-if"
+      if="[[hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      <a class="status-link"
+         href="[[getStatusLink(revertedChange, resolveWeblinks, status)]]">
+        <div class="chip" aria-label$="Label: [[status]]">
+          [[_computeStatusString(status)]]
+          <iron-icon
+            class="icon"
+            icon="gr-icons:edit"
+            hidden$="[[!showResolveIcon(resolveWeblinks, status)]]">
+          </iron-icon>
+        </div>
+      </a>
+    </template>
+    <template is="dom-if" if="[[!hasStatusLink(revertedChange, resolveWeblinks, status)]]">
+      <div class="chip" aria-label$="Label: [[status]]"
+      >[[_computeStatusString(status)]]</div>
+    </template>
   </gr-tooltip-content>
+</span>
 `;
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
deleted file mode 100644
index 16fc664..0000000
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.js
+++ /dev/null
@@ -1,114 +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 './gr-change-status.js';
-import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status.js';
-
-const basicFixture = fixtureFromElement('gr-change-status');
-
-const WIP_TOOLTIP = 'This change isn\'t ready to be reviewed or submitted. ' +
-    '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 PRIVATE_TOOLTIP = 'This change is only visible to its owner and ' +
-    'current reviewers (or anyone with "View Private Changes" permission).';
-
-suite('gr-change-status tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('WIP', () => {
-    element.status = 'WIP';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'Work in Progress');
-    assert.equal(element.tooltipText, WIP_TOOLTIP);
-    assert.isTrue(element.classList.contains('wip'));
-  });
-
-  test('WIP flat', () => {
-    element.flat = true;
-    element.status = 'WIP';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, 'WIP');
-    assert.isDefined(element.tooltipText);
-    assert.isTrue(element.classList.contains('wip'));
-    assert.isTrue(element.hasAttribute('flat'));
-  });
-
-  test('merged', () => {
-    element.status = 'Merged';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('merged'));
-  });
-
-  test('abandoned', () => {
-    element.status = 'Abandoned';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('abandoned'));
-  });
-
-  test('merge conflict', () => {
-    element.status = 'Merge Conflict';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
-    assert.isTrue(element.classList.contains('merge-conflict'));
-  });
-
-  test('private', () => {
-    element.status = 'Private';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
-    assert.isTrue(element.classList.contains('private'));
-  });
-
-  test('active', () => {
-    element.status = 'Active';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('active'));
-  });
-
-  test('ready to submit', () => {
-    element.status = 'Ready to submit';
-    assert.equal(element.shadowRoot
-        .querySelector('.chip').innerText, element.status);
-    assert.equal(element.tooltipText, '');
-    assert.isTrue(element.classList.contains('ready-to-submit'));
-  });
-
-  test('updating status removes the previous class', () => {
-    element.status = 'Private';
-    assert.isTrue(element.classList.contains('private'));
-    assert.isFalse(element.classList.contains('wip'));
-
-    element.status = 'WIP';
-    assert.isFalse(element.classList.contains('private'));
-    assert.isTrue(element.classList.contains('wip'));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
new file mode 100644
index 0000000..39fc7c6
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status_test.ts
@@ -0,0 +1,171 @@
+/**
+ * @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';
+import {createChange} from '../../../test/test-data-generators';
+import './gr-change-status';
+import {ChangeStates, GrChangeStatus} from './gr-change-status';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {MERGE_CONFLICT_TOOLTIP} from './gr-change-status';
+
+const basicFixture = fixtureFromElement('gr-change-status');
+
+const WIP_TOOLTIP =
+  "This change isn't ready to be reviewed or submitted. " +
+  "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 PRIVATE_TOOLTIP =
+  'This change is only visible to its owner and ' +
+  'current reviewers (or anyone with "View Private Changes" permission).';
+
+suite('gr-change-status tests', () => {
+  let element: GrChangeStatus;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('WIP', () => {
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Work in Progress'
+    );
+    assert.equal(element.tooltipText, WIP_TOOLTIP);
+    assert.isTrue(element.classList.contains('wip'));
+  });
+
+  test('WIP flat', () => {
+    element.flat = true;
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'WIP'
+    );
+    assert.isDefined(element.tooltipText);
+    assert.isTrue(element.classList.contains('wip'));
+    assert.isTrue(element.hasAttribute('flat'));
+  });
+
+  test('merged', () => {
+    element.status = ChangeStates.MERGED;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Merged'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('merged'));
+    assert.isFalse(
+      element.showResolveIcon([{url: 'http://google.com'}], ChangeStates.MERGED)
+    );
+  });
+
+  test('abandoned', () => {
+    element.status = ChangeStates.ABANDONED;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Abandoned'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('abandoned'));
+  });
+
+  test('merge conflict', () => {
+    const status = ChangeStates.MERGE_CONFLICT;
+    element.status = status;
+    flush();
+
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Merge Conflict'
+    );
+    assert.equal(element.tooltipText, MERGE_CONFLICT_TOOLTIP);
+    assert.isTrue(element.classList.contains('merge-conflict'));
+    assert.isFalse(element.hasStatusLink(undefined, [], status));
+    assert.isFalse(element.showResolveIcon([], status));
+  });
+
+  test('merge conflict with resolve link', () => {
+    const status = ChangeStates.MERGE_CONFLICT;
+    const url = 'http://google.com';
+    const weblinks = [{url}];
+
+    assert.isTrue(element.hasStatusLink(undefined, weblinks, status));
+    assert.equal(element.getStatusLink(undefined, weblinks, status), url);
+    assert.isTrue(element.showResolveIcon(weblinks, status));
+  });
+
+  test('reverted change', () => {
+    const url = 'http://google.com';
+    const status = ChangeStates.REVERT_SUBMITTED;
+    const revertedChange = createChange();
+    sinon.stub(GerritNav, 'getUrlForSearchQuery').returns(url);
+
+    assert.isTrue(element.hasStatusLink(revertedChange, [], status));
+    assert.equal(element.getStatusLink(revertedChange, [], status), url);
+  });
+
+  test('private', () => {
+    element.status = ChangeStates.PRIVATE;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Private'
+    );
+    assert.equal(element.tooltipText, PRIVATE_TOOLTIP);
+    assert.isTrue(element.classList.contains('private'));
+  });
+
+  test('active', () => {
+    element.status = ChangeStates.ACTIVE;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Active'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('active'));
+  });
+
+  test('ready to submit', () => {
+    element.status = ChangeStates.READY_TO_SUBMIT;
+    flush();
+    assert.equal(
+      element.shadowRoot!.querySelector<HTMLDivElement>('.chip')!.innerText,
+      'Ready to submit'
+    );
+    assert.equal(element.tooltipText, '');
+    assert.isTrue(element.classList.contains('ready-to-submit'));
+  });
+
+  test('updating status removes the previous class', () => {
+    element.status = ChangeStates.PRIVATE;
+    flush();
+    assert.isTrue(element.classList.contains('private'));
+    assert.isFalse(element.classList.contains('wip'));
+
+    element.status = ChangeStates.WIP;
+    flush();
+    assert.isFalse(element.classList.contains('private'));
+    assert.isTrue(element.classList.contains('wip'));
+  });
+});
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 f97146e..3f8264b 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
@@ -14,15 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-a11y-styles';
 import '../../../styles/shared-styles';
 import '../gr-comment/gr-comment';
 import '../../diff/gr-diff/gr-diff';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
+import '../gr-copy-clipboard/gr-copy-clipboard';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment-thread_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {
   computeDiffFromContext,
+  computeId,
+  DraftInfo,
   isDraft,
   isRobot,
   sortComments,
@@ -41,6 +43,7 @@
 import {computeDisplayPath} from '../../../utils/path-list-util';
 import {computed, customElement, observe, property} from '@polymer/decorators';
 import {
+  AccountDetailInfo,
   CommentRange,
   ConfigInfo,
   NumericChangeId,
@@ -50,15 +53,23 @@
 } from '../../../types/common';
 import {GrComment} from '../gr-comment/gr-comment';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
-import {CustomKeyboardEvent} from '../../../types/events';
-import {LineNumber, FILE} from '../../diff/gr-diff/gr-diff-line';
+import {FILE, LineNumber} from '../../diff/gr-diff/gr-diff-line';
 import {GrButton} from '../gr-button/gr-button';
 import {DiffInfo, DiffPreferencesInfo} from '../../../types/diff';
-import {RenderPreferences} from '../../../api/diff';
-import {check, assertIsDefined} from '../../../utils/common-util';
-import {waitForEventOnce} from '../../../utils/event-util';
+import {DiffLayer, RenderPreferences} from '../../../api/diff';
+import {
+  assertIsDefined,
+  check,
+  queryAndAssert,
+} from '../../../utils/common-util';
+import {fireAlert, waitForEventOnce} from '../../../utils/event-util';
 import {GrSyntaxLayer} from '../../diff/gr-syntax-layer/gr-syntax-layer';
 import {StorageLocation} from '../../../services/storage/gr-storage';
+import {TokenHighlightLayer} from '../../diff/gr-diff-builder/token-highlight-layer';
+import {anyLineTooLong} from '../../diff/gr-diff/gr-diff-utils';
+import {getUserName} from '../../../utils/display-name-util';
+import {generateAbsoluteUrl} from '../../../utils/url-util';
+import {addGlobalShortcut} from '../../../utils/dom-util';
 
 const UNRESOLVED_EXPAND_COUNT = 5;
 const NEWLINE_PATTERN = /\n/g;
@@ -71,26 +82,12 @@
 }
 
 @customElement('gr-comment-thread')
-export class GrCommentThread extends KeyboardShortcutMixin(PolymerElement) {
-  // KeyboardShortcutMixin Not used in this element rather other elements tests
-
+export class GrCommentThread extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
 
   /**
-   * Fired when the thread should be discarded.
-   *
-   * @event thread-discard
-   */
-
-  /**
-   * Fired when a comment in the thread is permanently modified.
-   *
-   * @event thread-changed
-   */
-
-  /**
    * gr-comment-thread exposes the following attributes that allow a
    * diff widget like gr-diff to show the thread in the right location:
    *
@@ -119,9 +116,6 @@
   @property({type: Object, reflectToAttribute: true})
   range?: CommentRange;
 
-  @property({type: Object})
-  keyEventTarget: HTMLElement = document.body;
-
   @property({type: String, reflectToAttribute: true})
   diffSide?: Side;
 
@@ -150,6 +144,9 @@
   })
   rootId?: UrlEncodedCommentId;
 
+  @property({type: Boolean, observer: 'handleShouldScrollIntoViewChanged'})
+  shouldScrollIntoView = false;
+
   @property({type: Boolean})
   showFilePath = false;
 
@@ -197,15 +194,18 @@
   @property({type: Boolean})
   showCommentContext = false;
 
-  get keyBindings() {
-    return {
-      'e shift+e': '_handleEKey',
-    };
-  }
+  @property({type: Object})
+  _selfAccount?: AccountDetailInfo;
 
-  reporting = appContext.reportingService;
+  @property({type: Array})
+  layers: DiffLayer[] = [];
 
-  flagsService = appContext.flagsService;
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  private readonly reporting = appContext.reportingService;
+
+  private readonly commentsService = appContext.commentsService;
 
   readonly storage = appContext.storageService;
 
@@ -213,16 +213,32 @@
 
   readonly restApiService = appContext.restApiService;
 
+  private readonly shortcuts = appContext.shortcutsService;
+
   constructor() {
     super();
     this.addEventListener('comment-update', e =>
       this._handleCommentUpdate(e as CustomEvent)
     );
+    appContext.restApiService.getPreferences().then(prefs => {
+      this._initLayers(!!prefs?.disable_token_highlighting);
+    });
   }
 
-  /** @override */
-  connectedCallback() {
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
     super.connectedCallback();
+    this.cleanups.push(
+      addGlobalShortcut({key: 'e'}, e => this.handleExpandShortcut(e))
+    );
+    this.cleanups.push(
+      addGlobalShortcut({key: 'E'}, e => this.handleCollapseShortcut(e))
+    );
     this._getLoggedIn().then(loggedIn => {
       this._showActions = loggedIn;
     });
@@ -236,6 +252,9 @@
       };
       this.syntaxLayer.setEnabled(!!prefs.syntax_highlighting);
     });
+    this.restApiService.getAccount().then(account => {
+      this._selfAccount = account;
+    });
     this._setInitialExpandedState();
   }
 
@@ -243,20 +262,42 @@
   get _diff() {
     if (this.comments === undefined || this.path === undefined) return;
     if (!this.comments[0]?.context_lines?.length) return;
-    waitForEventOnce(this, 'render').then(() => {
-      this.syntaxLayer.process();
-    });
     const diff = computeDiffFromContext(
       this.comments[0].context_lines,
       this.path,
       this.comments[0].source_content_type
     );
-    this.syntaxLayer.init(diff);
+    if (!anyLineTooLong(diff)) {
+      this.syntaxLayer.init(diff);
+      waitForEventOnce(this, 'render').then(() => {
+        this.syntaxLayer.process();
+      });
+    }
     return diff;
   }
 
-  _shouldShowCommentContext(diff?: DiffInfo) {
-    return this.showCommentContext && !!diff;
+  handleShouldScrollIntoViewChanged(shouldScrollIntoView?: boolean) {
+    // Wait for comment to be rendered before scrolling to it
+    if (shouldScrollIntoView) {
+      const resizeObserver = new ResizeObserver(
+        (_entries: ResizeObserverEntry[], observer: ResizeObserver) => {
+          if (this.offsetHeight > 0) {
+            queryAndAssert<HTMLDivElement>(this, '.comment-box').focus();
+            this.scrollIntoView();
+          }
+          observer.unobserve(this);
+        }
+      );
+      resizeObserver.observe(this);
+    }
+  }
+
+  _shouldShowCommentContext(
+    changeNum?: NumericChangeId,
+    showCommentContext?: boolean,
+    diff?: DiffInfo
+  ) {
+    return changeNum && showCommentContext && !!diff;
   }
 
   addOrEditDraft(lineNum?: LineNumber, rangeParam?: CommentRange) {
@@ -286,16 +327,7 @@
     const draft = this._newDraft(lineNum, range);
     draft.__editing = true;
     draft.unresolved = unresolved === false ? unresolved : true;
-    this.push('comments', draft);
-  }
-
-  fireRemoveSelf() {
-    this.dispatchEvent(
-      new CustomEvent('thread-discard', {
-        detail: {rootId: this.rootId},
-        bubbles: false,
-      })
-    );
+    this.commentsService.addDraft(draft);
   }
 
   _getDiffUrlForPath(
@@ -318,7 +350,8 @@
     return GerritNav.getUrlForComment(changeNum, projectName, id);
   }
 
-  getHighlightRange() {
+  /** The parameter is for triggering re-computation only. */
+  getHighlightRange(_: unknown) {
     const comment = this.comments?.[0];
     if (!comment) return undefined;
     if (comment.range) return comment.range;
@@ -333,20 +366,22 @@
     return undefined;
   }
 
-  _getLayers(diff?: DiffInfo) {
-    if (!diff) return [];
-    return [this.syntaxLayer];
+  _initLayers(disableTokenHighlighting: boolean) {
+    if (!disableTokenHighlighting) {
+      this.layers.push(new TokenHighlightLayer(this));
+    }
+    this.layers.push(this.syntaxLayer);
   }
 
-  _getUrlForViewDiff(comments: UIComment[]) {
-    assertIsDefined(this.changeNum, 'changeNum');
-    assertIsDefined(this.projectName, 'projectName');
+  _getUrlForViewDiff(
+    comments: UIComment[],
+    changeNum?: NumericChangeId,
+    projectName?: RepoName
+  ): string {
+    if (!changeNum) return '';
+    if (!projectName) return '';
     check(comments.length > 0, 'comment not found');
-    return GerritNav.getUrlForComment(
-      this.changeNum,
-      this.projectName,
-      comments[0].id!
-    );
+    return GerritNav.getUrlForComment(changeNum, projectName, comments[0].id!);
   }
 
   _getDiffUrlForComment(
@@ -375,7 +410,22 @@
     return GerritNav.getUrlForComment(changeNum, projectName, id);
   }
 
-  _isPatchsetLevelComment(path: string) {
+  handleCopyLink() {
+    assertIsDefined(this.changeNum, 'changeNum');
+    assertIsDefined(this.projectName, 'projectName');
+    const url = generateAbsoluteUrl(
+      GerritNav.getUrlForCommentsTab(
+        this.changeNum,
+        this.projectName,
+        this.comments[0].id!
+      )
+    );
+    navigator.clipboard.writeText(url).then(() => {
+      fireAlert(this, 'Link copied to clipboard');
+    });
+  }
+
+  _isPatchsetLevelComment(path?: string) {
     return path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
   }
 
@@ -384,7 +434,7 @@
     return this.showPortedComment && comment.id === this._orderedComments[0].id;
   }
 
-  _computeDisplayPath(path: string) {
+  _computeDisplayPath(path?: string) {
     const displayPath = computeDisplayPath(path);
     if (displayPath === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
       return 'Patchset';
@@ -392,16 +442,16 @@
     return displayPath;
   }
 
-  _computeDisplayLine() {
-    if (this.lineNum === FILE) {
+  _computeDisplayLine(lineNum?: LineNumber, range?: CommentRange) {
+    if (lineNum === FILE) {
       if (this.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) {
         return '';
       }
       return FILE;
     }
-    if (this.lineNum) return `#${this.lineNum}`;
+    if (lineNum) return `#${lineNum}`;
     // If range is set, then lineNum equals the end line of the range.
-    if (this.range) return `#${this.range.end_line}`;
+    if (range) return `#${range.end_line}`;
     return '';
   }
 
@@ -443,21 +493,14 @@
     return this._orderedComments[this._orderedComments.length - 1] || {};
   }
 
-  _handleEKey(e: CustomKeyboardEvent) {
-    if (this.shouldSuppressKeyboardShortcut(e)) {
-      return;
-    }
+  private handleExpandShortcut(e: KeyboardEvent) {
+    if (this.shortcuts.shouldSuppress(e)) return;
+    this._expandCollapseComments(false);
+  }
 
-    // Don’t preventDefault in this case because it will render the event
-    // useless for other handlers (other gr-comment-thread elements).
-    if (e.detail.keyboardEvent?.shiftKey) {
-      this._expandCollapseComments(true);
-    } else {
-      if (this.modifierPressed(e)) {
-        return;
-      }
-      this._expandCollapseComments(false);
-    }
+  private handleCollapseShortcut(e: KeyboardEvent) {
+    if (this.shortcuts.shouldSuppress(e)) return;
+    this._expandCollapseComments(true);
   }
 
   _expandCollapseComments(actionIsCollapse: boolean) {
@@ -474,11 +517,16 @@
    * - last {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
    * thread is unresolved,
    * - it's a robot comment.
+   * - it's a draft
    */
   _setInitialExpandedState() {
     if (this._orderedComments) {
       for (let i = 0; i < this._orderedComments.length; i++) {
         const comment = this._orderedComments[i];
+        if (isDraft(comment)) {
+          comment.collapsed = false;
+          continue;
+        }
         const isRobotComment = !!(comment as UIRobot).robot_id;
         // False if it's an unresolved comment under UNRESOLVED_EXPAND_COUNT.
         const resolvedThread =
@@ -503,16 +551,23 @@
 
     if (isEditing) {
       reply.__editing = true;
-    }
-
-    this.push('comments', reply);
-
-    if (!isEditing) {
-      // Allow the reply to render in the dom-repeat.
-      setTimeout(() => {
-        const commentEl = this._commentElWithDraftID(reply.__draftID);
-        if (commentEl) commentEl.save();
-      }, 1);
+      this.commentsService.addDraft(reply);
+    } else {
+      assertIsDefined(this.changeNum, 'changeNum');
+      assertIsDefined(this.patchNum, 'patchNum');
+      this.restApiService
+        .saveDiffDraft(this.changeNum, this.patchNum, reply)
+        .then(result => {
+          if (!result.ok) {
+            fireAlert(document, 'Unable to restore draft');
+            return;
+          }
+          this.restApiService.getResponseObject(result).then(obj => {
+            const resComment = obj as unknown as DraftInfo;
+            resComment.patch_set = reply.patch_set;
+            this.commentsService.addDraft(resComment);
+          });
+        });
     }
   }
 
@@ -588,7 +643,7 @@
   _newDraft(lineNum?: LineNumber, range?: CommentRange) {
     const d: UIDraft = {
       __draft: true,
-      __draftID: Math.random().toString(36),
+      __draftID: 'draft__' + Math.random().toString(36),
       __date: new Date(),
     };
     if (lineNum === 'LOST') throw new Error('invalid lineNum lost');
@@ -631,29 +686,12 @@
     if (!comments.base.length) {
       return this.rootId;
     }
-    const rootComment = comments.base[0];
-    if (rootComment.id) return rootComment.id;
-    if (isDraft(rootComment)) return rootComment.__draftID;
-    throw new Error('Missing id in root comment.');
+    return computeId(comments.base[0]);
   }
 
-  _handleCommentDiscard(e: Event) {
+  _handleCommentDiscard() {
     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);
-    if (idx === -1) {
-      throw new Error(
-        'Cannot find comment ' + JSON.stringify(diffCommentEl.comment)
-      );
-    }
-    this.splice('comments', idx, 1);
-    if (this.comments.length === 0) {
-      this.fireRemoveSelf();
-    }
-    this._handleCommentSavedOrDiscarded();
-
     // 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) {
@@ -672,21 +710,14 @@
     }
   }
 
-  _handleCommentSavedOrDiscarded() {
-    this.dispatchEvent(
-      new CustomEvent('thread-changed', {
-        detail: {rootId: this.rootId, path: this.path},
-        bubbles: false,
-      })
-    );
-  }
-
   _handleCommentUpdate(e: CustomEvent) {
     const comment = e.detail.comment;
     const index = this._indexOf(comment, this.comments);
     if (index === -1) {
       // This should never happen: comment belongs to another thread.
-      console.warn('Comment update for another comment thread.');
+      this.reporting.error(
+        new Error(`Comment update for another comment thread: ${comment}`)
+      );
       return;
     }
     this.set(['comments', index], comment);
@@ -711,7 +742,8 @@
     return -1;
   }
 
-  _computeHostClass(unresolved?: boolean) {
+  /** 2nd parameter is for triggering re-computation only. */
+  _computeHostClass(unresolved?: boolean, _?: unknown) {
     if (this.isRobotComment) {
       return 'robotComment';
     }
@@ -731,6 +763,17 @@
       this._projectConfig = config;
     });
   }
+
+  _computeAriaHeading(_orderedComments: UIComment[]) {
+    const firstComment = _orderedComments[0];
+    const author = firstComment?.author ?? this._selfAccount;
+    const lastComment = _orderedComments[_orderedComments.length - 1] || {};
+    const status = [
+      lastComment.unresolved ? 'Unresolved' : '',
+      isDraft(lastComment) ? 'Draft' : '',
+    ].join(' ');
+    return `${status} Comment thread by ${getUserName(undefined, author)}`;
+  }
 }
 
 declare global {
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 a1644e7..c3faaa5 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
@@ -17,6 +17,9 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
+  <style include="gr-a11y-styles">
+    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
+  </style>
   <style include="shared-styles">
     :host {
       font-family: var(--font-family);
@@ -80,10 +83,6 @@
       justify-content: space-between;
       padding: 0 var(--spacing-s) var(--spacing-s);
     }
-    .descriptionText {
-      margin-left: var(--spacing-m);
-      font-style: italic;
-    }
     .fileName {
       padding: var(--spacing-m) var(--spacing-s) var(--spacing-m);
     }
@@ -106,6 +105,27 @@
       border-top: 1px solid var(--border-color);
       background-color: var(--background-color-primary);
     }
+
+    /* In saved state the "reply" and "quote" buttons are 28px height.
+     * top:4px  positions the 20px icon vertically centered.
+     * Currently in draft state the "save" and "cancel" buttons are 20px
+     * height, so the link icon does not need a top:4px in gr-comment_html.
+     */
+    .link-icon {
+      position: relative;
+      top: 4px;
+      cursor: pointer;
+    }
+    .fileName gr-copy-clipboard {
+      display: inline-block;
+      visibility: hidden;
+      vertical-align: top;
+      --gr-button-padding: 0px;
+    }
+    .fileName:focus-within gr-copy-clipboard,
+    .fileName:hover gr-copy-clipboard {
+      visibility: visible;
+    }
   </style>
 
   <template is="dom-if" if="[[showFilePath]]">
@@ -120,6 +140,10 @@
           >
             [[_computeDisplayPath(path)]]
           </a>
+          <gr-copy-clipboard
+            hideInput=""
+            text="[[_computeDisplayPath(path)]]"
+          ></gr-copy-clipboard>
         </template>
       </div>
     </template>
@@ -127,13 +151,19 @@
       <template is="dom-if" if="[[!_isPatchsetLevelComment(path)]]">
         <a
           href$="[[_getDiffUrlForComment(projectName, changeNum, path, patchNum)]]"
-          >[[_computeDisplayLine()]]</a
+          >[[_computeDisplayLine(lineNum, range)]]</a
         >
       </template>
     </div>
   </template>
   <div id="container">
-    <div class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box">
+    <h3 class="assistive-tech-only">
+      [[_computeAriaHeading(_orderedComments)]]
+    </h3>
+    <div
+      class$="[[_computeHostClass(unresolved, isRobotComment)]] comment-box"
+      tabindex="0"
+    >
       <template
         id="commentList"
         is="dom-repeat"
@@ -155,7 +185,7 @@
           project-config="[[_projectConfig]]"
           on-create-fix-comment="_handleCommentFix"
           on-comment-discard="_handleCommentDiscard"
-          on-comment-save="_handleCommentSavedOrDiscarded"
+          on-copy-comment-link="handleCopyLink"
         ></gr-comment>
       </template>
       <div
@@ -164,6 +194,16 @@
       >
         <span id="unresolvedLabel">[[_getUnresolvedLabel(unresolved)]]</span>
         <div id="actions">
+          <iron-icon
+            class="link-icon"
+            on-click="handleCopyLink"
+            class="copy"
+            title="Copy link to this comment"
+            icon="gr-icons:link"
+            role="button"
+            tabindex="0"
+          >
+          </iron-icon>
           <gr-button
             id="replyBtn"
             link=""
@@ -197,13 +237,16 @@
         </div>
       </div>
     </div>
-    <template is="dom-if" if="[[_shouldShowCommentContext(_diff)]]">
+    <template
+      is="dom-if"
+      if="[[_shouldShowCommentContext(changeNum, showCommentContext, _diff)]]"
+    >
       <div class="diff-container">
         <gr-diff
           id="diff"
           change-num="[[changeNum]]"
           diff="[[_diff]]"
-          layers="[[_getLayers(_diff)]]"
+          layers="[[layers]]"
           path="[[path]]"
           prefs="[[_prefs]]"
           render-prefs="[[_renderPrefs]]"
@@ -211,10 +254,8 @@
         >
         </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 href="[[_getUrlForViewDiff(comments, changeNum, projectName)]]">
+            <gr-button link class="view-diff-button">View Diff</gr-button>
           </a>
         </div>
       </div>
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
index 64f5ad8..595de34 100644
--- 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
@@ -23,7 +23,6 @@
   sortComments,
   UIComment,
   UIRobot,
-  isDraft,
   UIDraft,
 } from '../../../utils/comment-util';
 import {GrCommentThread} from './gr-comment-thread';
@@ -44,8 +43,15 @@
   tap,
   pressAndReleaseKeyOn,
 } from '@polymer/iron-test-helpers/mock-interactions';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {stubRestApi, stubStorage} from '../../../test/test-utils';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {
+  mockPromise,
+  stubComments,
+  stubReporting,
+  stubRestApi,
+} from '../../../test/test-utils';
+import {_testOnly_resetState} from '../../../services/comments/comments-model';
+import {SinonStub} from 'sinon';
 
 const basicFixture = fixtureFromElement('gr-comment-thread');
 
@@ -57,7 +63,7 @@
 
     setup(() => {
       stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-
+      _testOnly_resetState();
       element = basicFixture.instantiate();
       element.patchNum = 3 as PatchSetNum;
       element.changeNum = 1 as NumericChangeId;
@@ -230,16 +236,14 @@
       assert.equal(element._hideActions(showActions, robotComment), true);
     });
 
-    test('setting project name loads the project config', done => {
+    test('setting project name loads the project config', async () => {
       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();
-      });
+      await flush();
+      assert.isTrue(getProjectStub.calledWithExactly(projectName as never));
     });
 
     test('optionally show file path', () => {
@@ -285,117 +289,139 @@
 
     test('_computeDisplayLine', () => {
       element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        '#5'
+      );
 
       element.path = SpecialFilePath.COMMIT_MESSAGE;
       element.lineNum = 5;
-      assert.equal(element._computeDisplayLine(), '#5');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        '#5'
+      );
 
       element.lineNum = undefined;
       element.path = SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
-      assert.equal(element._computeDisplayLine(), '');
+      assert.equal(
+        element._computeDisplayLine(element.lineNum, element.range),
+        ''
+      );
     });
   });
 });
 
 suite('comment action tests with unresolved thread', () => {
   let element: GrCommentThread;
-
+  let addDraftServiceStub: SinonStub;
+  let saveDiffDraftStub: SinonStub;
+  let comment = {
+    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',
+  };
+  const peanutButterComment = {
+    author: {
+      name: 'Mr. Peanutbutter',
+      email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
+    },
+    id: 'baf0414d_60047215' as UrlEncodedCommentId,
+    line: 5,
+    in_reply_to: 'baf0414d_60047215' as UrlEncodedCommentId,
+    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,
+  };
+  const mockResponse: Response = {
+    ...new Response(),
+    headers: {} as Headers,
+    redirected: false,
+    status: 200,
+    statusText: '',
+    type: '' as ResponseType,
+    url: '',
+    ok: true,
+    text() {
+      return Promise.resolve(")]}'\n" + JSON.stringify(comment));
+    },
+  };
+  let saveDiffDraftPromiseResolver: (value?: Response) => void;
   setup(() => {
+    addDraftServiceStub = stubComments('addDraft');
     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)
+    saveDiffDraftStub = stubRestApi('saveDiffDraft').returns(
+      new Promise<Response>(
+        resolve =>
+          (saveDiffDraftPromiseResolver = resolve as (value?: Response) => void)
+      )
     );
     stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve(({ok: true} as unknown) as Response)
+      Promise.resolve({...new Response(), ok: true})
     );
     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,
-      },
-    ];
+    element.comments = [peanutButterComment];
     flush();
   });
 
   test('reply', () => {
+    saveDiffDraftPromiseResolver(mockResponse);
+
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('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');
+    const draft = addDraftServiceStub.firstCall.args[0];
+    assert.isOk(draft);
+    assert.notOk(draft.message, 'message should be empty');
     assert.equal(
-      drafts[0].in_reply_to,
-      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+      draft.in_reply_to,
+      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
     );
     assert.isTrue(reportStub.calledOnce);
   });
 
   test('quote reply', () => {
+    saveDiffDraftPromiseResolver(mockResponse);
+
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    const reportStub = stubReporting('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');
+    const draft = addDraftServiceStub.firstCall.args[0];
+    // the quote reply is not autmatically saved so verify that id is not set
+    assert.isNotOk(draft.id);
+    // verify that the draft returned was not saved
+    assert.isNotOk(saveDiffDraftStub.called);
+    assert.equal(draft.message, '> is this a crossover episode!?\n\n');
     assert.equal(
-      drafts[0].in_reply_to,
-      ('baf0414d_60047215' as UrlEncodedCommentId) as UrlEncodedCommentId
+      draft.in_reply_to,
+      'baf0414d_60047215' as UrlEncodedCommentId as UrlEncodedCommentId
     );
     assert.isTrue(reportStub.calledOnce);
   });
 
   test('quote reply multiline', () => {
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+    saveDiffDraftPromiseResolver(mockResponse);
+    const reportStub = stubReporting('recordDraftInteraction');
     element.comments = [
       {
         author: {
           name: 'Mr. Peanutbutter',
-          email: ('tenn1sballchaser@aol.com' as EmailAddress) as EmailAddress,
+          email: 'tenn1sballchaser@aol.com' as EmailAddress as EmailAddress,
         },
         id: 'baf0414d_60047215' as UrlEncodedCommentId,
         path: 'test',
@@ -413,21 +439,26 @@
     tap(quoteBtn);
     flush();
 
-    const drafts = element._orderedComments.filter(c => isDraft(c));
-    assert.equal(drafts.length, 1);
+    const draft = addDraftServiceStub.firstCall.args[0];
     assert.equal(
-      drafts[0].message,
+      draft.message,
       '> is this a crossover episode!?\n> It might be!\n\n'
     );
-    assert.equal(
-      drafts[0].in_reply_to,
-      'baf0414d_60047215' as UrlEncodedCommentId
-    );
+    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
     assert.isTrue(reportStub.calledOnce);
   });
 
-  test('ack', done => {
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+  test('ack', async () => {
+    saveDiffDraftPromiseResolver(mockResponse);
+    comment = {
+      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: 'Ack',
+    };
+    const reportStub = stubReporting('recordDraftInteraction');
     element.changeNum = 42 as NumericChangeId;
     element.patchNum = 1 as PatchSetNum;
 
@@ -437,22 +468,26 @@
     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();
-    });
+    await flush();
+    const draft = addDraftServiceStub.firstCall.args[0];
+    assert.equal(draft.message, 'Ack');
+    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
+    assert.isNotOk(draft.unresolved);
+    assert.isTrue(reportStub.calledOnce);
   });
 
-  test('done', done => {
-    const reportStub = sinon.stub(element.reporting, 'recordDraftInteraction');
+  test('done', async () => {
+    saveDiffDraftPromiseResolver(mockResponse);
+    comment = {
+      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',
+    };
+    const reportStub = stubReporting('recordDraftInteraction');
+    assert.isFalse(saveDiffDraftStub.called);
     element.changeNum = 42 as NumericChangeId;
     element.patchNum = 1 as PatchSetNum;
     const commentEl = element.shadowRoot?.querySelector('gr-comment');
@@ -461,65 +496,61 @@
     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();
-    });
+    await flush();
+    const draft = addDraftServiceStub.firstCall.args[0];
+    // Since the reply is automatically saved, verify that draft.id is set in
+    // the model
+    assert.equal(draft.id, '7afa4931_de3d65bd');
+    assert.equal(draft.message, 'Done');
+    assert.equal(draft.in_reply_to, 'baf0414d_60047215' as UrlEncodedCommentId);
+    assert.isNotOk(draft.unresolved);
+    assert.isTrue(reportStub.calledOnce);
+    assert.isTrue(saveDiffDraftStub.called);
   });
 
-  test('save', done => {
+  test('save', async () => {
+    saveDiffDraftPromiseResolver(mockResponse);
     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();
-    });
+    await flush();
+    assert.equal(element.rootId, 'baf0414d_60047215' as UrlEncodedCommentId);
   });
 
-  test('please fix', done => {
+  test('please fix', async () => {
+    comment = peanutButterComment;
     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);
+    const promise = mockPromise();
+    commentEl!.addEventListener('create-fix-comment', async () => {
+      assert.isTrue(saveDiffDraftStub.called);
+      assert.isFalse(addDraftServiceStub.called);
+      saveDiffDraftPromiseResolver(mockResponse);
+      // flushing so the saveDiffDraftStub resolves and the draft is returned
+      await flush();
+      assert.isTrue(saveDiffDraftStub.called);
+      assert.isTrue(addDraftServiceStub.called);
+      const draft = saveDiffDraftStub.firstCall.args[2];
       assert.equal(
-        drafts[0].message,
+        draft.message,
         '> is this a crossover episode!?\n\nPlease fix.'
       );
       assert.equal(
-        drafts[0].in_reply_to,
+        draft.in_reply_to,
         'baf0414d_60047215' as UrlEncodedCommentId
       );
-      assert.isTrue(drafts[0].unresolved);
-      done();
+      assert.isTrue(draft.unresolved);
+      promise.resolve();
     });
+    assert.isFalse(saveDiffDraftStub.called);
+    assert.isFalse(addDraftServiceStub.called);
     commentEl!.dispatchEvent(
       new CustomEvent('create-fix-comment', {
         detail: {comment: commentEl!.comment},
@@ -527,13 +558,15 @@
         bubbles: false,
       })
     );
+    await promise;
   });
 
-  test('discard', done => {
+  test('discard', async () => {
     element.changeNum = 42 as NumericChangeId;
     element.patchNum = 1 as PatchSetNum;
     element.path = '/path/to/file.txt';
     assert.isOk(element.comments[0]);
+    const deleteDraftStub = stubComments('deleteDraft');
     element.push(
       'comments',
       element._newReply(
@@ -541,133 +574,45 @@
         'it’s pronouced jiff, not giff'
       )
     );
-    flush();
+    await flush();
 
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
     const draftEl = element.root?.querySelectorAll('gr-comment')[1];
     assert.ok(draftEl);
+    draftEl?._fireSave(); // tell the model about the draft
+    const promise = mockPromise();
     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();
+      assert.isTrue(deleteDraftStub.called);
+      promise.resolve();
     });
-    draftEl!.dispatchEvent(
-      new CustomEvent('comment-discard', {
-        detail: {comment: draftEl!.comment},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    draftEl!._fireDiscard();
+    await promise;
   });
 
-  test('discard with a single comment still fires event with previous rootId', done => {
+  test('discard with a single comment still fires event with previous rootId', async () => {
     element.changeNum = 42 as NumericChangeId;
     element.patchNum = 1 as PatchSetNum;
     element.path = '/path/to/file.txt';
     element.comments = [];
     element.addOrEditDraft(1 as LineNumber);
+    const draft = addDraftServiceStub.firstCall.args[0];
+    element.comments = [draft];
     flush();
     const rootId = element.rootId;
     assert.isOk(rootId);
-
-    const saveOrDiscardStub = sinon.stub();
-    element.addEventListener('thread-changed', saveOrDiscardStub);
+    flush();
     const draftEl = element.root?.querySelectorAll('gr-comment')[0];
     assert.ok(draftEl);
+    const deleteDraftStub = stubComments('deleteDraft');
+    const promise = mockPromise();
     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();
+      assert.isTrue(deleteDraftStub.called);
+      promise.resolve();
     });
-    draftEl!.dispatchEvent(
-      new CustomEvent('comment-discard', {
-        detail: {comment: draftEl!.comment},
-        composed: true,
-        bubbles: false,
-      })
-    );
+    draftEl!._fireDiscard();
+    await promise;
+    assert.isTrue(deleteDraftStub.called);
   });
 
-  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 = stubStorage('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 = {
@@ -693,6 +638,7 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession' as UrlEncodedCommentId,
           updated: '2015-12-25 15:00:20.396000000' as Timestamp,
+          path: 'abcd',
           unresolved: false,
         },
         {
@@ -700,17 +646,20 @@
           in_reply_to: 'nonexistent_comment' as UrlEncodedCommentId,
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000' as Timestamp,
+          path: 'abcd',
         },
         {
           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,
+          path: 'abcd',
         },
         {
           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,
+          path: 'abcd',
         },
       ];
     });
@@ -724,12 +673,14 @@
       pressAndReleaseKeyOn(element, 69, null, 'e');
       assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
 
-      pressAndReleaseKeyOn(element, 69, 'shift', 'e');
+      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);
+      const draft = addDraftServiceStub.firstCall.args[0];
+      element.comments = [...element.comments, draft];
       flush();
       assert.equal(element._orderedComments.length, 5);
       assert.equal(
@@ -741,6 +692,8 @@
     test('resolvable comments', () => {
       assert.isFalse(element.unresolved);
       element._createReplyComment('dummy', true, true);
+      const draft = addDraftServiceStub.firstCall.args[0];
+      element.comments = [...element.comments, draft];
       flush();
       assert.isTrue(element.unresolved);
     });
@@ -789,18 +742,23 @@
 
   test('addDraft sets unresolved state correctly', () => {
     let unresolved = true;
+    let draft;
     element.comments = [];
+    element.path = 'abcd';
     element.addDraft(undefined, undefined, unresolved);
-    assert.equal(element.comments[0].unresolved, true);
+    draft = addDraftServiceStub.lastCall.args[0];
+    assert.equal(draft.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);
+    draft = addDraftServiceStub.lastCall.args[0];
+    assert.equal(draft.unresolved, false);
 
     element.comments = [];
     element.addDraft();
-    assert.equal(element.comments[0].unresolved, true);
+    draft = addDraftServiceStub.lastCall.args[0];
+    assert.equal(draft.unresolved, true);
   });
 
   test('_newDraft with root', () => {
@@ -818,14 +776,18 @@
 
   test('new comment gets created', () => {
     element.comments = [];
+    element.path = 'abcd';
     element.addOrEditDraft(1);
+    const draft = addDraftServiceStub.firstCall.args[0];
+    element.comments = [draft];
+    flush();
     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);
+    assert.equal(addDraftServiceStub.callCount, 2);
   });
 
   test('unresolved label', () => {
@@ -921,7 +883,8 @@
   setup(() => {
     stubRestApi('getLoggedIn').returns(Promise.resolve(false));
     stubRestApi('saveDiffDraft').returns(
-      Promise.resolve(({
+      Promise.resolve({
+        ...new Response(),
         ok: true,
         text() {
           return Promise.resolve(
@@ -936,10 +899,10 @@
               })
           );
         },
-      } as unknown) as Response)
+      })
     );
     stubRestApi('deleteDiffDraft').returns(
-      Promise.resolve(({ok: true} as unknown) as Response)
+      Promise.resolve({...new Response(), ok: true})
     );
     element = withCommentFixture.instantiate();
     element.patchNum = 1 as PatchSetNum;
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 b7f1bcc..154a045 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -20,7 +20,6 @@
 import '../../plugins/gr-endpoint-param/gr-endpoint-param';
 import '../gr-button/gr-button';
 import '../gr-dialog/gr-dialog';
-import '../gr-date-formatter/gr-date-formatter';
 import '../gr-formatted-text/gr-formatted-text';
 import '../gr-icons/gr-icons';
 import '../gr-overlay/gr-overlay';
@@ -31,7 +30,6 @@
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-comment_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {getRootElement} from '../../../scripts/rootElement';
 import {appContext} from '../../../services/app-context';
 import {customElement, observe, property} from '@polymer/decorators';
@@ -48,19 +46,20 @@
 } 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 {
   isDraft,
+  isRobot,
   UIComment,
   UIDraft,
   UIRobot,
 } from '../../../utils/comment-util';
 import {OpenFixPreviewEventDetail} from '../../../types/events';
-import {fireAlert} from '../../../utils/event-util';
+import {fire, fireAlert, fireEvent} from '../../../utils/event-util';
 import {pluralize} from '../../../utils/string-util';
 import {assertIsDefined} from '../../../utils/common-util';
 import {debounce, DelayedTask} from '../../../utils/async-util';
 import {StorageLocation} from '../../../services/storage/gr-storage';
+import {addShortcut, Key, Modifier} from '../../../utils/dom-util';
 
 const STORAGE_DEBOUNCE_INTERVAL = 400;
 const TOAST_DEBOUNCE_INTERVAL = 200;
@@ -97,11 +96,12 @@
   $: {
     container: HTMLDivElement;
     resolvedCheckbox: HTMLInputElement;
+    header: HTMLDivElement;
   };
 }
 
 @customElement('gr-comment')
-export class GrComment extends KeyboardShortcutMixin(PolymerElement) {
+export class GrComment extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -125,6 +125,12 @@
    */
 
   /**
+   * Fired when this comment is edited.
+   *
+   * @event comment-edit
+   */
+
+  /**
    * Fired when this comment is saved.
    *
    * @event comment-save
@@ -172,7 +178,12 @@
   @property({type: Boolean, observer: '_editingChanged'})
   editing = false;
 
-  @property({type: Boolean, reflectToAttribute: true})
+  // Assigns a css property to the comment hiding the comment while it's being
+  // discarded
+  @property({
+    type: Boolean,
+    reflectToAttribute: true,
+  })
   discarding = false;
 
   @property({type: Boolean})
@@ -201,7 +212,7 @@
   projectConfig?: ConfigInfo;
 
   @property({type: Boolean})
-  robotButtonDisabled?: boolean;
+  robotButtonDisabled = false;
 
   @property({type: Boolean})
   _hasHumanReply?: boolean;
@@ -220,7 +231,7 @@
   side?: string;
 
   @property({type: Boolean})
-  resolved?: boolean;
+  resolved = false;
 
   // Intentional to share the object across instances.
   @property({type: Object})
@@ -259,18 +270,16 @@
   @property({type: Boolean})
   showPortedComment = false;
 
-  get keyBindings() {
-    return {
-      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
-      esc: '_handleEsc',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   private readonly restApiService = appContext.restApiService;
 
   private readonly storage = appContext.storageService;
 
-  reporting = appContext.reportingService;
+  private readonly reporting = appContext.reportingService;
+
+  private readonly commentsService = appContext.commentsService;
 
   private fireUpdateTask?: DelayedTask;
 
@@ -278,8 +287,7 @@
 
   private draftToastTask?: DelayedTask;
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.restApiService.getAccount().then(account => {
       this._selfAccount = account;
@@ -292,10 +300,21 @@
     this._getIsAdmin().then(isAdmin => {
       this._isAdmin = !!isAdmin;
     });
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+    );
+    for (const key of ['s', Key.ENTER]) {
+      for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
+        addShortcut(this, {key, modifiers: [modifier]}, e =>
+          this._handleSaveKey(e)
+        );
+      }
+    }
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     this.fireUpdateTask?.cancel();
     this.storeTask?.cancel();
     this.draftToastTask?.cancel();
@@ -305,12 +324,13 @@
     super.disconnectedCallback();
   }
 
-  _getAuthor(comment: UIComment) {
-    return comment.author || this._selfAccount;
+  /** 2nd argument is for *triggering* the computation only. */
+  _getAuthor(comment?: UIComment, _?: unknown) {
+    return comment?.author || this._selfAccount;
   }
 
-  _getUrlForComment(comment: UIComment) {
-    if (!this.changeNum || !this.projectName) return '';
+  _getUrlForComment(comment?: UIComment) {
+    if (!comment || !this.changeNum || !this.projectName) return '';
     if (!comment.id) throw new Error('comment must have an id');
     return GerritNav.getUrlForComment(
       this.changeNum as NumericChangeId,
@@ -339,7 +359,8 @@
     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;
@@ -421,6 +442,11 @@
     this._showRobotActions = showActions && isRobotComment;
   }
 
+  hasPublishedComment(comments?: UIComment[]) {
+    if (!comments?.length) return false;
+    return comments.length > 1 || !isDraft(comments[0]);
+  }
+
   @observe('comment')
   _isRobotComment(comment: UIRobot) {
     this.isRobotComment = !!comment.robot_id;
@@ -445,6 +471,10 @@
     return 'DRAFT' + (unableToSave ? '(Failed to save)' : '');
   }
 
+  handleCopyLink() {
+    fireEvent(this, 'copy-comment-link');
+  }
+
   save(opt_comment?: UIComment) {
     let comment = opt_comment;
     if (!comment) {
@@ -466,9 +496,9 @@
           return;
         }
 
-        this._eraseDraftComment();
+        this._eraseDraftCommentFromStorage();
         return this.restApiService.getResponseObject(response).then(obj => {
-          const resComment = (obj as unknown) as UIDraft;
+          const resComment = obj as unknown as UIDraft;
           if (!isDraft(this.comment)) throw new Error('Can only save drafts.');
           resComment.__draft = true;
           // Maintain the ephemeral draft ID for identification by other
@@ -476,6 +506,7 @@
           if (this.comment?.__draftID) {
             resComment.__draftID = this.comment.__draftID;
           }
+          if (!resComment.patch_set) resComment.patch_set = this.patchNum;
           this.comment = resComment;
           this._fireSave();
           return obj;
@@ -489,7 +520,7 @@
     return this._xhrPromise;
   }
 
-  _eraseDraftComment() {
+  _eraseDraftCommentFromStorage() {
     // Prevents a race condition in which removing the draft comment occurs
     // prior to it being saved.
     this.storeTask?.cancel();
@@ -508,6 +539,7 @@
   _commentChanged(comment: UIComment) {
     this.editing = isDraft(comment) && !!comment.__editing;
     this.resolved = !comment.unresolved;
+    this.discarding = false;
     if (this.editing) {
       // It's a new draft/reply, notify.
       this._fireUpdate();
@@ -531,7 +563,19 @@
     return {comment: this.comment, patchNum: this.patchNum};
   }
 
+  _fireEdit() {
+    if (this.comment) this.commentsService.editDraft(this.comment);
+    this.dispatchEvent(
+      new CustomEvent('comment-edit', {
+        detail: this._getEventPayload(),
+        composed: true,
+        bubbles: true,
+      })
+    );
+  }
+
   _fireSave() {
+    if (this.comment) this.commentsService.addDraft(this.comment);
     this.dispatchEvent(
       new CustomEvent('comment-save', {
         detail: this._getEventPayload(),
@@ -637,11 +681,21 @@
 
   @observe('comment.message')
   _commentMessageChanged(message: string) {
-    this._messageText = message || '';
+    /*
+     * Only overwrite the message text user has typed if there is no existing
+     * text typed by the user. This prevents the bug where creating another
+     * comment triggered a recomputation of comments and the text written by
+     * the user was lost.
+     */
+    if (!this._messageText || !this.editing) this._messageText = message || '';
   }
 
   _messageTextChanged(_: string, oldValue: string) {
-    if (!this.comment || (this.comment && this.comment.id)) {
+    // Only store comments that are being edited in local storage.
+    if (
+      !this.comment ||
+      (this.comment.id && (!isDraft(this.comment) || !this.comment.__editing))
+    ) {
       return;
     }
 
@@ -649,33 +703,32 @@
       ? this.comment.patch_set
       : this._getPatchNum();
     const {path, line, range} = this.comment;
-    if (path) {
-      this.storeTask = debounce(
-        this.storeTask,
-        () => {
-          const message = this._messageText;
-          if (this.changeNum === undefined) {
-            throw new Error('undefined changeNum');
-          }
-          const commentLocation: StorageLocation = {
-            changeNum: this.changeNum,
-            patchNum,
-            path,
-            line,
-            range,
-          };
+    if (!path) return;
+    this.storeTask = debounce(
+      this.storeTask,
+      () => {
+        const message = this._messageText;
+        if (this.changeNum === undefined) {
+          throw new Error('undefined changeNum');
+        }
+        const commentLocation: StorageLocation = {
+          changeNum: this.changeNum,
+          patchNum,
+          path,
+          line,
+          range,
+        };
 
-          if ((!message || !message.length) && oldValue) {
-            // If the draft has been modified to be empty, then erase the storage
-            // entry.
-            this.storage.eraseDraftComment(commentLocation);
-          } else {
-            this.storage.setDraftComment(commentLocation, message);
-          }
-        },
-        STORAGE_DEBOUNCE_INTERVAL
-      );
-    }
+        if ((!message || !message.length) && oldValue) {
+          // If the draft has been modified to be empty, then erase the storage
+          // entry.
+          this.storage.eraseDraftComment(commentLocation);
+        } else {
+          this.storage.setDraftComment(commentLocation, message);
+        }
+      },
+      STORAGE_DEBOUNCE_INTERVAL
+    );
   }
 
   _handleAnchorClick(e: Event) {
@@ -697,6 +750,7 @@
     e.preventDefault();
     if (this.comment?.message) this._messageText = this.comment.message;
     this.editing = true;
+    this._fireEdit();
     this.reporting.recordDraftInteraction();
   }
 
@@ -704,9 +758,7 @@
     e.preventDefault();
 
     // Ignore saves started while already saving.
-    if (this.disabled) {
-      return;
-    }
+    if (this.disabled) return;
     const timingLabel = this.comment?.id
       ? REPORT_UPDATE_DRAFT
       : REPORT_CREATE_DRAFT;
@@ -719,20 +771,20 @@
 
   _handleCancel(e: Event) {
     e.preventDefault();
-
-    if (
-      !this.comment?.message ||
-      this.comment.message.trim().length === 0 ||
-      !this.comment.id
-    ) {
+    if (!this.comment) return;
+    if (!this.comment.id) {
+      // Ensures we update the discarded draft message before deleting the draft
+      this.set('comment.message', this._messageText);
       this._fireDiscard();
-      return;
+    } else {
+      this.set('comment.__editing', false);
+      this.commentsService.cancelDraft(this.comment);
+      this.editing = false;
     }
-    this._messageText = this.comment.message;
-    this.editing = false;
   }
 
   _fireDiscard() {
+    if (this.comment) this.commentsService.deleteDraft(this.comment);
     this.fireUpdateTask?.cancel();
     this.dispatchEvent(
       new CustomEvent('comment-discard', {
@@ -763,7 +815,7 @@
     );
   }
 
-  _hasNoFix(comment: UIComment) {
+  _hasNoFix(comment?: UIComment) {
     return !comment || !(comment as UIRobot).fix_suggestions;
   }
 
@@ -771,26 +823,7 @@
     e.preventDefault();
     this.reporting.recordDraftInteraction();
 
-    if (!this._messageText) {
-      this._discardDraft();
-      return;
-    }
-
-    this._openOverlay(this.confirmDiscardOverlay).then(() => {
-      const dialog = this.confirmDiscardOverlay?.querySelector(
-        '#confirmDiscardDialog'
-      ) as GrDialog | null;
-      if (dialog) dialog.resetFocus();
-    });
-  }
-
-  _handleConfirmDiscard(e: Event) {
-    e.preventDefault();
-    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
-    this._closeConfirmDiscardOverlay();
-    return this._discardDraft().then(() => {
-      timer.end();
-    });
+    this._discardDraft();
   }
 
   _discardDraft() {
@@ -799,9 +832,10 @@
       return Promise.reject(new Error('Cannot discard a non-draft comment.'));
     }
     this.discarding = true;
+    const timer = this.reporting.getTimer(REPORT_DISCARD_DRAFT);
     this.editing = false;
     this.disabled = true;
-    this._eraseDraftComment();
+    this._eraseDraftCommentFromStorage();
 
     if (!this.comment.id) {
       this.disabled = false;
@@ -815,7 +849,7 @@
         if (!response.ok) {
           this.discarding = false;
         }
-
+        timer.end();
         this._fireDiscard();
         return response;
       })
@@ -827,10 +861,6 @@
     return this._xhrPromise;
   }
 
-  _closeConfirmDiscardOverlay() {
-    this._closeOverlay(this.confirmDiscardOverlay);
-  }
-
   _getSavingMessage(numPending: number, requestFailed?: boolean) {
     if (requestFailed) {
       return UNSAVED_MESSAGE;
@@ -908,18 +938,24 @@
   }
 
   _deleteDraft(draft: UIComment) {
-    if (this.changeNum === undefined || this.patchNum === undefined) {
+    const changeNum = this.changeNum;
+    const patchNum = this.patchNum;
+    if (changeNum === undefined || patchNum === undefined) {
       throw new Error('undefined changeNum or patchNum');
     }
-    this._showStartRequest();
-    if (!draft.id) throw new Error('Missing id in comment draft.');
+    fireAlert(this, 'Discarding draft...');
+    const draftID = draft.id;
+    if (!draftID) throw new Error('Missing id in comment draft.');
     return this.restApiService
-      .deleteDiffDraft(this.changeNum, this.patchNum, {id: draft.id})
+      .deleteDiffDraft(changeNum, patchNum, {id: draftID})
       .then(result => {
         if (result.ok) {
-          this._showEndRequest();
-        } else {
-          this._handleFailedDraftRequest();
+          fire(this, 'show-alert', {
+            message: 'Draft Discarded',
+            action: 'Undo',
+            callback: () =>
+              this.commentsService.restoreDraft(changeNum, patchNum, draftID),
+          });
         }
         return result;
       });
@@ -944,9 +980,15 @@
       return;
     }
 
-    // Only apply local drafts to comments that haven't been saved
-    // remotely, and haven't been given a default message already.
-    if (!comment || comment.id || comment.message || !comment.path) {
+    // Only apply local drafts to comments that are drafts and are currently
+    // being edited.
+    if (
+      !comment ||
+      !comment.path ||
+      comment.message ||
+      !isDraft(comment) ||
+      !comment.__editing
+    ) {
       return;
     }
 
@@ -959,7 +1001,7 @@
     });
 
     if (draft) {
-      this.set('comment.message', draft.message);
+      this._messageText = draft.message || '';
     }
   }
 
@@ -1002,9 +1044,10 @@
     return overlay.open();
   }
 
-  _computeHideRunDetails(comment: UIRobot, collapsed: boolean) {
+  _computeHideRunDetails(comment: UIComment | undefined, collapsed: boolean) {
     if (!comment) return true;
-    return !(comment.robot_id && comment.url && !collapsed);
+    if (!isRobot(comment)) return true;
+    return !comment.url || collapsed;
   }
 
   _closeOverlay(overlay?: GrOverlay | null) {
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 aad8c98..b77c4b2 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
@@ -89,10 +89,7 @@
       justify-content: flex-end;
     }
     .rightActions gr-button {
-      --gr-button: {
-        height: 20px;
-        padding: 0 var(--spacing-s);
-      }
+      --gr-button-padding: 0 var(--spacing-s);
     }
     .editMessage {
       display: none;
@@ -190,10 +187,8 @@
     }
     #deleteBtn {
       display: none;
-      --gr-button: {
-        color: var(--deemphasized-text-color);
-        padding: 0;
-      }
+      --gr-button-text-color: var(--deemphasized-text-color);
+      --gr-button-padding: 0;
     }
     #deleteBtn.showDeleteButtons {
       display: block;
@@ -236,18 +231,21 @@
       margin-left: var(--spacing-s);
     }
     .headerLeft gr-account-label {
-      --gr-account-label-text-style: {
-        font-weight: var(--font-weight-bold);
-      }
       --account-max-length: 130px;
       width: 150px;
     }
+    .headerLeft gr-account-label::part(gr-account-label-text) {
+      font-weight: var(--font-weight-bold);
+    }
     .draft gr-account-label {
       width: unset;
     }
     .portedMessage {
       margin: 0 var(--spacing-m);
     }
+    .link-icon {
+      cursor: pointer;
+    }
   </style>
   <div id="container" class="container">
     <div class="header" id="header" on-click="_handleToggleCollapsed">
@@ -259,7 +257,7 @@
           <gr-account-label
             account="[[_getAuthor(comment, _selfAccount)]]"
             class$="[[_computeAccountLabelClass(draft)]]"
-            hide-status=""
+            hideStatus
           >
           </gr-account-label>
         </template>
@@ -269,19 +267,13 @@
               >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=""
+          has-tooltip
           title="[[_computeDraftTooltip(_unableToSave)]]"
           max-width="20em"
-          show-icon=""
+          show-icon
         >
           <span class="draftLabel">[[_computeDraftText(_unableToSave)]]</span>
         </gr-tooltip-content>
@@ -316,7 +308,7 @@
       <template is="dom-if" if="[[comment.updated]]">
         <span class="date" tabindex="0" on-click="_handleAnchorClick">
           <gr-date-formatter
-            has-tooltip=""
+            withTooltip
             date-str="[[comment.updated]]"
           ></gr-date-formatter>
         </span>
@@ -324,7 +316,7 @@
       <div class="show-hide" tabindex="0">
         <label
           class="show-hide"
-          aria-label="[[_computeShowHideAriaLabel(collapsed)]]"
+          aria-label$="[[_computeShowHideAriaLabel(collapsed)]]"
         >
           <input
             type="checkbox"
@@ -360,7 +352,7 @@
           <div class="respectfulReviewTip">
             <div>
               <gr-tooltip-content
-                has-tooltip=""
+                has-tooltip
                 title="Tips for respectful code reviews."
               >
                 <iron-icon
@@ -411,6 +403,18 @@
         </div>
         <template is="dom-if" if="[[draft]]">
           <div class="rightActions">
+            <template is="dom-if" if="[[hasPublishedComment(comments)]]">
+              <iron-icon
+                class="link-icon"
+                on-click="handleCopyLink"
+                class="copy"
+                title="Copy link to this comment"
+                icon="gr-icons:link"
+                role="button"
+                tabindex="0"
+              >
+              </iron-icon>
+            </template>
             <gr-button
               link=""
               class="action cancel hideOnPublished"
@@ -440,6 +444,18 @@
         </template>
       </div>
       <div class="robotActions" hidden$="[[!_showRobotActions]]">
+        <template is="dom-if" if="[[hasPublishedComment(comments)]]">
+          <iron-icon
+            class="link-icon"
+            on-click="handleCopyLink"
+            class="copy"
+            title="Copy link to this comment"
+            icon="gr-icons:link"
+            role="button"
+            tabindex="0"
+          >
+          </iron-icon>
+        </template>
         <template is="dom-if" if="[[isRobotComment]]">
           <gr-endpoint-decorator name="robot-comment-controls">
             <gr-endpoint-param name="comment" value="[[comment]]">
@@ -477,19 +493,5 @@
       >
       </gr-confirm-delete-comment-dialog>
     </gr-overlay>
-    <gr-overlay id="confirmDiscardOverlay" with-backdrop="">
-      <gr-dialog
-        id="confirmDiscardDialog"
-        confirm-label="Discard"
-        confirm-on-enter=""
-        on-confirm="_handleConfirmDiscard"
-        on-cancel="_closeConfirmDiscardOverlay"
-      >
-        <div class="header" slot="header">Discard comment</div>
-        <div class="main" slot="main">
-          Are you sure you want to discard this draft comment?
-        </div>
-      </gr-dialog>
-    </gr-overlay>
   </template>
 `;
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
deleted file mode 100644
index be647ab..0000000
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.js
+++ /dev/null
@@ -1,1354 +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.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {__testOnly_UNSAVED_MESSAGE} from './gr-comment.js';
-import {SpecialFilePath, Side} from '../../../constants/constants.js';
-import {stubRestApi, stubStorage} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-comment');
-
-const draftFixture = fixtureFromTemplate(html`
-<gr-comment draft="true"></gr-comment>
-`);
-
-function isVisible(el) {
-  assert.ok(el);
-  return getComputedStyle(el).getPropertyValue('display') !== 'none';
-}
-
-suite('gr-comment tests', () => {
-  suite('basic tests', () => {
-    let element;
-
-    let openOverlaySpy;
-
-    setup(() => {
-      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: {
-          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',
-      };
-
-      openOverlaySpy = sinon.spy(element, '_openOverlay');
-    });
-
-    teardown(() => {
-      openOverlaySpy.getCalls().forEach(call => {
-        call.args[0].remove();
-      });
-    });
-
-    test('collapsible comments', () => {
-      // When a comment (not draft) is loaded, it should be collapsed
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-
-      // The header middle content is only visible when comments are collapsed.
-      // It shows the message in a condensed way, and limits to a single line.
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When the header row is clicked, the comment should expand
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('clicking on date link fires event', () => {
-      element.side = 'PARENT';
-      const stub = sinon.stub();
-      element.addEventListener('comment-anchor-tap', stub);
-      flush();
-      const dateEl = element.shadowRoot
-          .querySelector('.date');
-      assert.ok(dateEl);
-      MockInteractions.tap(dateEl);
-
-      assert.isTrue(stub.called);
-      assert.deepEqual(stub.lastCall.args[0].detail,
-          {side: element.side, number: element.comment.line});
-    });
-
-    test('message is not retrieved from storage when other edits', done => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isFalse(storageStub.called);
-        done();
-      });
-    });
-
-    test('message is retrieved from storage when no other edits', done => {
-      const storageStub = stubStorage('getDraftComment');
-      const loadSpy = sinon.spy(element, '_loadLocalDraft');
-
-      element.changeNum = 1;
-      element.patchNum = 1;
-      element.comment = {
-        author: {
-          name: 'Mr. Peanutbutter',
-          email: 'tenn1sballchaser@aol.com',
-        },
-        line: 5,
-        path: 'test',
-      };
-      flush(() => {
-        assert.isTrue(loadSpy.called);
-        assert.isTrue(storageStub.called);
-        done();
-      });
-    });
-
-    test('_getPatchNum', () => {
-      element.side = 'PARENT';
-      element.patchNum = 1;
-      assert.equal(element._getPatchNum(), 'PARENT');
-      element.side = 'REVISION';
-      assert.equal(element._getPatchNum(), 1);
-    });
-
-    test('comment expand and collapse', () => {
-      element.collapsed = true;
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      element.collapsed = false;
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-    });
-
-    suite('while editing', () => {
-      setup(() => {
-        element.editing = true;
-        element._messageText = 'test';
-        sinon.stub(element, '_handleCancel');
-        sinon.stub(element, '_handleSave');
-        flush();
-      });
-
-      suite('when text is empty', () => {
-        setup(() => {
-          element._messageText = '';
-          element.comment = {};
-        });
-
-        test('esc closes comment when text is empty', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 27); // esc
-          assert.isTrue(element._handleCancel.called);
-        });
-
-        test('ctrl+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'ctrl'); // ctrl + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('meta+enter does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 13, 'meta'); // meta + enter
-          assert.isFalse(element._handleSave.called);
-        });
-
-        test('ctrl+s does not save', () => {
-          MockInteractions.pressAndReleaseKeyOn(
-              element.textarea, 83, 'ctrl'); // ctrl + s
-          assert.isFalse(element._handleSave.called);
-        });
-      });
-
-      test('esc does not close comment that has content', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 27); // esc
-        assert.isFalse(element._handleCancel.called);
-      });
-
-      test('ctrl+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'ctrl'); // ctrl + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('meta+enter saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 13, 'meta'); // meta + enter
-        assert.isTrue(element._handleSave.called);
-      });
-
-      test('ctrl+s saves', () => {
-        MockInteractions.pressAndReleaseKeyOn(
-            element.textarea, 83, 'ctrl'); // ctrl + s
-        assert.isTrue(element._handleSave.called);
-      });
-    });
-
-    test('delete comment button for non-admins is hidden', () => {
-      element._isAdmin = false;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment button for admins with draft is hidden', () => {
-      element._isAdmin = false;
-      element.draft = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-    });
-
-    test('delete comment', done => {
-      const stub = stubRestApi('deleteComment').returns(Promise.resolve({}));
-      sinon.spy(element.confirmDeleteOverlay, 'open');
-      element.changeNum = 42;
-      element.patchNum = 0xDEADBEEF;
-      element._isAdmin = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.action.delete')
-          .classList.contains('showDeleteButtons'));
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.action.delete'));
-      flush(() => {
-        element.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
-          const dialog =
-              window.confirmDeleteOverlay
-                  .querySelector('#confirmDeleteComment');
-          dialog.message = 'removal reason';
-          element._handleConfirmDeleteComment();
-          assert.isTrue(stub.calledWith(
-              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
-          done();
-        });
-      });
-    });
-
-    suite('draft update reporting', () => {
-      let endStub;
-      let getTimerStub;
-      let mockEvent;
-
-      setup(() => {
-        mockEvent = {preventDefault() {}};
-        sinon.stub(element, 'save')
-            .returns(Promise.resolve({}));
-        sinon.stub(element, '_discardDraft')
-            .returns(Promise.resolve({}));
-        endStub = sinon.stub();
-        getTimerStub = sinon.stub(element.reporting, 'getTimer')
-            .returns({end: endStub});
-      });
-
-      test('create', () => {
-        element.patchNum = 1;
-        element.comment = {};
-        return element._handleSave(mockEvent).then(() => {
-          assert.equal(element.shadowRoot.querySelector('gr-account-label').
-              shadowRoot.querySelector('span.name').innerText.trim(),
-          'Dhruv Srivastava');
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
-        });
-      });
-
-      test('update', () => {
-        element.comment = {id: 'abc_123'};
-        return element._handleSave(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
-        });
-      });
-
-      test('discard', () => {
-        element.comment = {id: 'abc_123'};
-        sinon.stub(element, '_closeConfirmDiscardOverlay');
-        return element._handleConfirmDiscard(mockEvent).then(() => {
-          assert.isTrue(endStub.calledOnce);
-          assert.isTrue(getTimerStub.calledOnce);
-          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
-        });
-      });
-    });
-
-    test('edit reports interaction', () => {
-      const reportStub = sinon.stub(element.reporting,
-          'recordDraftInteraction');
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('discard reports interaction', () => {
-      const reportStub = sinon.stub(element.reporting,
-          'recordDraftInteraction');
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.discard'));
-      assert.isTrue(reportStub.calledOnce);
-    });
-
-    test('failed save draft request', done => {
-      element.draft = true;
-      element.changeNum = 1;
-      element.patchNum = 1;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub =
-        stubRestApi('saveDiffDraft').returns(
-            Promise.resolve({ok: false}));
-      element._saveDraft({id: 'abc_123'});
-      flush(() => {
-        let args = updateRequestStub.lastCall.args;
-        assert.deepEqual(args, [0, true]);
-        assert.equal(element._getSavingMessage(...args),
-            __testOnly_UNSAVED_MESSAGE);
-        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
-            'DRAFT(Failed to save)');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is visible');
-        diffDraftStub.returns(
-            Promise.resolve({ok: true}));
-        element._saveDraft({id: 'abc_123'});
-        flush(() => {
-          args = updateRequestStub.lastCall.args;
-          assert.deepEqual(args, [0]);
-          assert.equal(element._getSavingMessage(...args),
-              'All changes saved');
-          assert.equal(element.shadowRoot.querySelector('.draftLabel')
-              .innerText, 'DRAFT');
-          assert.isFalse(isVisible(element.shadowRoot
-              .querySelector('.save')), 'save is not visible');
-          assert.isFalse(element._unableToSave);
-          done();
-        });
-      });
-    });
-
-    test('failed save draft request with promise failure', done => {
-      element.draft = true;
-      element.changeNum = 1;
-      element.patchNum = 1;
-      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
-      const diffDraftStub =
-        stubRestApi('saveDiffDraft').returns(
-            Promise.reject(new Error()));
-      element._saveDraft({id: 'abc_123'});
-      flush(() => {
-        let args = updateRequestStub.lastCall.args;
-        assert.deepEqual(args, [0, true]);
-        assert.equal(element._getSavingMessage(...args),
-            __testOnly_UNSAVED_MESSAGE);
-        assert.equal(element.shadowRoot.querySelector('.draftLabel').innerText,
-            'DRAFT(Failed to save)');
-        assert.isTrue(isVisible(element.shadowRoot
-            .querySelector('.save')), 'save is visible');
-        diffDraftStub.returns(
-            Promise.resolve({ok: true}));
-        element._saveDraft({id: 'abc_123'});
-        flush(() => {
-          args = updateRequestStub.lastCall.args;
-          assert.deepEqual(args, [0]);
-          assert.equal(element._getSavingMessage(...args),
-              'All changes saved');
-          assert.equal(element.shadowRoot.querySelector('.draftLabel')
-              .innerText, 'DRAFT');
-          assert.isFalse(isVisible(element.shadowRoot
-              .querySelector('.save')), 'save is not visible');
-          assert.isFalse(element._unableToSave);
-          done();
-        });
-      });
-    });
-  });
-
-  suite('gr-comment draft tests', () => {
-    let element;
-
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(null));
-      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!"' +
-              '}'
-          );
-        },
-      }));
-      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 = {
-        diffSide: Side.RIGHT,
-        __draft: true,
-        __draftID: 'temp_draft_id',
-        path: '/path/to/file',
-        line: 5,
-      };
-      element.diffSide = Side.RIGHT;
-    });
-
-    test('button visibility states', () => {
-      element.showActions = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.showActions = true;
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = true;
-      flush();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.discard')), 'discard is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.resolve')), 'resolve is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.editing = true;
-      flush();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.discard')), 'discard not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.resolve')), 'resolve is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.draft = false;
-      element.editing = false;
-      flush();
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.edit')), 'edit is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.discard')),
-      'discard is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.save')), 'save is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is not visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      element.comment.id = 'foo';
-      element.draft = true;
-      element.editing = true;
-      flush();
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.cancel')), 'cancel is visible');
-      assert.isFalse(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // Delete button is not hidden by default
-      assert.isFalse(element.shadowRoot.querySelector('#deleteBtn').hidden);
-
-      element.isRobotComment = true;
-      element.draft = true;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // It is not expected to see Robot comment drafts, but if they appear,
-      // they will behave the same as non-drafts.
-      element.draft = false;
-      assert.isTrue(element.shadowRoot
-          .querySelector('.humanActions').hasAttribute('hidden'));
-      assert.isFalse(element.shadowRoot
-          .querySelector('.robotActions').hasAttribute('hidden'));
-
-      // A robot comment with run ID should display plain text.
-      element.set(['comment', 'robot_run_id'], 'text');
-      element.editing = false;
-      element.collapsed = false;
-      flush();
-      assert.isTrue(element.shadowRoot
-          .querySelector('.robotRun.link').textContent === 'Run Details');
-
-      // A robot comment with run ID and url should display a link.
-      element.set(['comment', 'url'], '/path/to/run');
-      flush();
-      assert.notEqual(getComputedStyle(element.shadowRoot
-          .querySelector('.robotRun.link')).display,
-      'none');
-
-      // Delete button is hidden for robot comments
-      assert.isTrue(element.shadowRoot.querySelector('#deleteBtn').hidden);
-    });
-
-    test('collapsible drafts', () => {
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(element.collapsed);
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isNotOk(element.textarea, 'textarea is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is is not visible');
-
-      // When the edit button is pressed, should still see the actions
-      // and also textarea
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      flush();
-      assert.isFalse(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-
-      // When toggle again, everything should be hidden except for textarea
-      // and header middle content should be visible
-      MockInteractions.tap(element.$.header);
-      assert.isTrue(element.collapsed);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are not visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-textarea')),
-      'textarea is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is visible');
-
-      // When toggle again, textarea should remain open in the state it was
-      // before
-      MockInteractions.tap(element.$.header);
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('gr-formatted-text')),
-      'gr-formatted-text is not visible');
-      assert.isTrue(isVisible(element.shadowRoot
-          .querySelector('.actions')),
-      'actions are visible');
-      assert.isTrue(isVisible(element.textarea), 'textarea is visible');
-      assert.isFalse(isVisible(element.shadowRoot
-          .querySelector('.collapsedContent')),
-      'header middle content is not visible');
-    });
-
-    test('robot comment layout', done => {
-      const comment = {robot_id: 'happy_robot_id',
-        url: '/robot/comment',
-        author: {
-          name: 'Happy Robot',
-          display_name: 'Display name Robot',
-        }, ...element.comment};
-      element.comment = comment;
-      element.collapsed = false;
-      flush(() => {
-        let runIdMessage;
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isFalse(runIdMessage.hidden);
-
-        const runDetailsLink = element.shadowRoot
-            .querySelector('.robotRunLink');
-        assert.isTrue(runDetailsLink.href.indexOf(element.comment.url) !== -1);
-
-        const robotServiceName = element.shadowRoot
-            .querySelector('.robotName');
-        assert.equal(robotServiceName.textContent.trim(), 'happy_robot_id');
-
-        const authorName = element.shadowRoot
-            .querySelector('.robotId');
-        assert.isTrue(authorName.innerText === 'Happy Robot');
-
-        element.collapsed = true;
-        flush();
-        runIdMessage = element.shadowRoot
-            .querySelector('.runIdMessage');
-        assert.isTrue(runIdMessage.hidden);
-        done();
-      });
-    });
-
-    test('author name fallback to email', done => {
-      const comment = {url: '/robot/comment',
-        author: {
-          email: 'test@test.com',
-        }, ...element.comment};
-      element.comment = comment;
-      element.collapsed = false;
-      flush(() => {
-        const authorName = element.shadowRoot
-            .querySelector('gr-account-label')
-            .shadowRoot.querySelector('span.name');
-        assert.equal(authorName.innerText.trim(), 'test@test.com');
-        done();
-      });
-    });
-
-    test('patchset level comment', done => {
-      const comment = {...element.comment,
-        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS, line: undefined,
-        range: undefined};
-      element.comment = comment;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(element.editing);
-
-      element._messageText = 'hello world';
-      const eraseMessageDraftSpy = sinon.spy(element.storage,
-          'eraseDraftComment');
-      const mockEvent = {preventDefault: sinon.stub()};
-      element._handleSave(mockEvent);
-      flush(() => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-    });
-
-    test('draft creation/cancellation', done => {
-      assert.isFalse(element.editing);
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      assert.isTrue(element.editing);
-
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
-
-      // Save should be disabled on an empty message.
-      let disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-      element._messageText = '     ';
-      disabled = element.shadowRoot
-          .querySelector('.save').hasAttribute('disabled');
-      assert.isTrue(disabled, 'save button should be disabled.');
-
-      const updateStub = sinon.stub();
-      element.addEventListener('comment-update', updateStub);
-
-      let numDiscardEvents = 0;
-      element.addEventListener('comment-discard', e => {
-        numDiscardEvents++;
-        assert.isFalse(eraseMessageDraftSpy.called);
-        if (numDiscardEvents === 2) {
-          assert.isFalse(updateStub.called);
-          done();
-        }
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.cancel'));
-      element.fireUpdateTask.flush();
-      element._messageText = '';
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(element.textarea, 27); // esc
-    });
-
-    test('draft discard removes message from storage', done => {
-      element._messageText = '';
-      const eraseMessageDraftSpy = sinon.spy(element, '_eraseDraftComment');
-      sinon.stub(element, '_closeConfirmDiscardOverlay');
-
-      element.addEventListener('comment-discard', e => {
-        assert.isTrue(eraseMessageDraftSpy.called);
-        done();
-      });
-      element._handleConfirmDiscard({preventDefault: sinon.stub()});
-    });
-
-    test('storage is cleared only after save success', () => {
-      element._messageText = 'test';
-      const eraseStub = sinon.stub(element, '_eraseDraftComment');
-      stubRestApi('getResponseObject')
-          .returns(Promise.resolve({}));
-
-      sinon.stub(element, '_saveDraft').returns(Promise.resolve({ok: false}));
-
-      const savePromise = element.save();
-      assert.isFalse(eraseStub.called);
-      return savePromise.then(() => {
-        assert.isFalse(eraseStub.called);
-
-        element._saveDraft.restore();
-        sinon.stub(element, '_saveDraft')
-            .returns(Promise.resolve({ok: true}));
-        return element.save().then(() => {
-          assert.isTrue(eraseStub.called);
-        });
-      });
-    });
-
-    test('_computeSaveDisabled', () => {
-      const comment = {unresolved: true};
-      const msgComment = {message: 'test', unresolved: true};
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-      assert.equal(element._computeSaveDisabled('test', comment, false), false);
-      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
-      assert.equal(
-          element._computeSaveDisabled('test', msgComment, false), false);
-      assert.equal(
-          element._computeSaveDisabled('test2', msgComment, false), false);
-      assert.equal(element._computeSaveDisabled('test', comment, true), false);
-      assert.equal(element._computeSaveDisabled('', comment, true), true);
-      assert.equal(element._computeSaveDisabled('', comment, false), true);
-    });
-
-    suite('confirm discard', () => {
-      let discardStub;
-      let overlayStub;
-      let mockEvent;
-
-      setup(() => {
-        discardStub = sinon.stub(element, '_discardDraft');
-        overlayStub = sinon.stub(element, '_openOverlay')
-            .returns(Promise.resolve());
-        mockEvent = {preventDefault: sinon.stub()};
-      });
-
-      test('confirms discard of comments with message text', () => {
-        element._messageText = 'test';
-        element._handleDiscard(mockEvent);
-        assert.isTrue(overlayStub.calledWith(element.confirmDiscardOverlay));
-        assert.isFalse(discardStub.called);
-      });
-
-      test('no confirmation for comments without message text', () => {
-        element._messageText = '';
-        element._handleDiscard(mockEvent);
-        assert.isFalse(overlayStub.called);
-        assert.isTrue(discardStub.calledOnce);
-      });
-    });
-
-    test('ctrl+s saves comment', done => {
-      const stub = sinon.stub(element, 'save').callsFake(() => {
-        assert.isTrue(stub.called);
-        stub.restore();
-        done();
-        return Promise.resolve();
-      });
-      element._messageText = 'is that the horse from horsing around??';
-      element.editing = true;
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(
-          element.textarea.$.textarea.textarea,
-          83, 'ctrl'); // 'ctrl + s'
-    });
-
-    test('draft saving/editing', done => {
-      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.fireUpdateTask.flush();
-      element.storeTask.flush();
-      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      element._messageText = 'good news, everyone!';
-      element.fireUpdateTask.flush();
-      element.storeTask.flush();
-      assert.isTrue(dispatchEventStub.calledTwice);
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-
-      assert.isTrue(element.disabled,
-          'Element should be disabled when creating draft.');
-
-      element._xhrPromise.then(draft => {
-        assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-save');
-        assert.isFalse(element.storeTask.isActive());
-
-        assert.deepEqual(dispatchEventStub.lastCall.args[0].detail, {
-          comment: {
-            __draft: true,
-            __draftID: 'temp_draft_id',
-            id: 'baf0414d_40572e03',
-            line: 5,
-            message: 'saved!',
-            path: '/path/to/file',
-            updated: '2015-12-08 21:52:36.177000000',
-          },
-          patchNum: 1,
-        });
-        assert.isFalse(element.disabled,
-            'Element should be enabled when done creating draft.');
-        assert.equal(draft.message, 'saved!');
-        assert.isFalse(element.editing);
-      }).then(() => {
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.edit'));
-        element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
-            'a world where humans are killed on sight.';
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.save'));
-        assert.isTrue(element.disabled,
-            'Element should be disabled when updating draft.');
-
-        element._xhrPromise.then(draft => {
-          assert.isFalse(element.disabled,
-              'Element should be enabled when done updating draft.');
-          assert.equal(draft.message, 'saved!');
-          assert.isFalse(element.editing);
-          dispatchEventStub.restore();
-          done();
-        });
-      });
-    });
-
-    test('draft prevent save when disabled', () => {
-      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
-      element.showActions = true;
-      element.draft = true;
-      flush();
-      MockInteractions.tap(element.$.header);
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.edit'));
-      element._messageText = 'good news, everyone!';
-      element.fireUpdateTask.flush();
-      element.storeTask.flush();
-
-      element.disabled = true;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isFalse(saveStub.called);
-
-      element.disabled = false;
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.save'));
-      assert.isTrue(saveStub.calledOnce);
-    });
-
-    test('proper event fires on resolve, comment is not saved', done => {
-      const save = sinon.stub(element, 'save');
-      element.addEventListener('comment-update', e => {
-        assert.isTrue(e.detail.comment.unresolved);
-        assert.isFalse(save.called);
-        done();
-      });
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.resolve input'));
-    });
-
-    test('resolved comment state indicated by checkbox', () => {
-      sinon.stub(element, 'save');
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-    });
-
-    test('resolved checkbox saves with tap when !editing', () => {
-      element.editing = false;
-      const save = sinon.stub(element, 'save');
-
-      element.comment = {unresolved: false};
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      element.comment = {unresolved: true};
-      assert.isFalse(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isFalse(save.called);
-      MockInteractions.tap(element.$.resolvedCheckbox);
-      assert.isTrue(element.shadowRoot
-          .querySelector('.resolve input').checked);
-      assert.isTrue(save.called);
-    });
-
-    suite('draft saving messages', () => {
-      test('_getSavingMessage', () => {
-        assert.equal(element._getSavingMessage(0), 'All changes saved');
-        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
-        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
-        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
-      });
-
-      test('_show{Start,End}Request', () => {
-        const updateStub = sinon.stub(element, '_updateRequestToast');
-        element._numPendingDraftRequests.number = 1;
-
-        element._showStartRequest();
-        assert.isTrue(updateStub.calledOnce);
-        assert.equal(updateStub.lastCall.args[0], 2);
-        assert.equal(element._numPendingDraftRequests.number, 2);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledTwice);
-        assert.equal(updateStub.lastCall.args[0], 1);
-        assert.equal(element._numPendingDraftRequests.number, 1);
-
-        element._showEndRequest();
-        assert.isTrue(updateStub.calledThrice);
-        assert.equal(updateStub.lastCall.args[0], 0);
-        assert.equal(element._numPendingDraftRequests.number, 0);
-      });
-    });
-
-    test('cancelling an unsaved draft discards, persists in storage', () => {
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      const eraseStub = stubStorage('eraseDraftComment');
-      element._messageText = 'test text';
-      flush();
-      element.storeTask.flush();
-
-      assert.isTrue(storeStub.called);
-      assert.equal(storeStub.lastCall.args[1], 'test text');
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-      assert.isFalse(eraseStub.called);
-    });
-
-    test('cancelling edit on a saved draft does not store', () => {
-      element.comment.id = 'foo';
-      const discardSpy = sinon.spy(element, '_fireDiscard');
-      const storeStub = stubStorage('setDraftComment');
-      element._messageText = 'test text';
-      flush();
-      if (element.storeTask) element.storeTask.flush();
-
-      assert.isFalse(storeStub.called);
-      element._handleCancel({preventDefault: () => {}});
-      assert.isTrue(discardSpy.called);
-    });
-
-    test('deleting text from saved draft and saving deletes the draft', () => {
-      element.comment = {id: 'foo', message: 'test'};
-      element._messageText = '';
-      const discardStub = sinon.stub(element, '_discardDraft');
-
-      element.save();
-      assert.isTrue(discardStub.called);
-    });
-
-    test('_handleFix fires create-fix event', done => {
-      element.addEventListener('create-fix-comment', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.isRobotComment = true;
-      element.comments = [element.comment];
-      flush();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.fix'));
-    });
-
-    test('do not show Please Fix button if human reply exists', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id',
-          robot_run_id: '5838406743490560',
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf',
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com',
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-              },
-            ],
-          },
-          patch_set: 1,
-          id: 'eb0d03fd_5e95904f',
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000',
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          diffSide: Side.RIGHT,
-          collapsed: false,
-        },
-        {
-          __draft: true,
-          __draftID: '0.wbrfbwj89sa',
-          __date: '2019-12-04T13:41:03.689Z',
-          path: 'Documentation/config-gerrit.txt',
-          patchNum: 1,
-          side: 'REVISION',
-          diffSide: Side.RIGHT,
-          line: 10,
-          in_reply_to: 'eb0d03fd_5e95904f',
-          message: '> This is a robot comment with a fix.\n\nPlease fix.',
-          unresolved: true,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNull(element.shadowRoot
-          .querySelector('robotActions gr-button'));
-    });
-
-    test('show Please Fix if no human reply', () => {
-      element.comments = [
-        {
-          robot_id: 'happy_robot_id',
-          robot_run_id: '5838406743490560',
-          fix_suggestions: [
-            {
-              fix_id: '478ff847_3bf47aaf',
-              description: 'Make the smiley happier by giving it a nose.',
-              replacements: [
-                {
-                  path: 'Documentation/config-gerrit.txt',
-                  range: {
-                    start_line: 10,
-                    start_character: 7,
-                    end_line: 10,
-                    end_character: 9,
-                  },
-                  replacement: ':-)',
-                },
-              ],
-            },
-          ],
-          author: {
-            _account_id: 1030912,
-            name: 'Alice Kober-Sotzek',
-            email: 'aliceks@google.com',
-            avatars: [
-              {
-                url: '/s32-p/photo.jpg',
-                height: 32,
-              },
-              {
-                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
-                height: 56,
-              },
-              {
-                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
-                height: 100,
-              },
-              {
-                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
-                height: 120,
-              },
-            ],
-          },
-          patch_set: 1,
-          id: 'eb0d03fd_5e95904f',
-          line: 10,
-          updated: '2017-04-04 15:36:17.000000000',
-          message: 'This is a robot comment with a fix.',
-          unresolved: false,
-          diffSide: Side.RIGHT,
-          collapsed: false,
-        },
-      ];
-      element.comment = element.comments[0];
-      flush();
-      assert.isNotNull(element.shadowRoot
-          .querySelector('.robotActions gr-button'));
-    });
-
-    test('_handleShowFix fires open-fix-preview event', done => {
-      element.addEventListener('open-fix-preview', e => {
-        assert.deepEqual(e.detail, element._getEventPayload());
-        done();
-      });
-      element.comment = {fix_suggestions: [{}]};
-      element.isRobotComment = true;
-      flush();
-
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('.show-fix'));
-    });
-  });
-
-  suite('respectful tips', () => {
-    let element;
-
-    let clock;
-    setup(() => {
-      stubRestApi('getAccount').returns(Promise.resolve(null));
-      clock = sinon.useFakeTimers();
-    });
-
-    teardown(() => {
-      clock.restore();
-      sinon.restore();
-    });
-
-    test('show tip when no cached record', done => {
-      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, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isTrue(respectfulSetStub.called);
-        assert.isTrue(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-
-    test('add 14-day delays once dismissed', done => {
-      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, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isTrue(respectfulSetStub.called);
-        assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
-        assert.isTrue(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-
-        MockInteractions.tap(element.shadowRoot
-            .querySelector('.respectfulReviewTip .close'));
-        flush();
-        assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
-        done();
-      });
-    });
-
-    test('do not show tip when fall out of probability', done => {
-      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, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-
-    test('show tip when editing changed to true', done => {
-      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};
-      flush(() => {
-        assert.isFalse(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-
-        element.editing = true;
-        flush(() => {
-          assert.isTrue(respectfulGetStub.called);
-          assert.isTrue(respectfulSetStub.called);
-          assert.isTrue(
-              !!element.shadowRoot.querySelector('.respectfulReviewTip')
-          );
-          done();
-        });
-      });
-    });
-
-    test('no tip when cached record', done => {
-      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, __draft: true};
-      flush(() => {
-        assert.isTrue(respectfulGetStub.called);
-        assert.isFalse(respectfulSetStub.called);
-        assert.isFalse(
-            !!element.shadowRoot.querySelector('.respectfulReviewTip')
-        );
-        done();
-      });
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
new file mode 100644
index 0000000..00edc07
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -0,0 +1,1601 @@
+/**
+ * @license
+ * Copyright (C) 2015 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-comment';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrComment, __testOnly_UNSAVED_MESSAGE} from './gr-comment';
+import {SpecialFilePath, CommentSide} from '../../../constants/constants';
+import {
+  queryAndAssert,
+  stubRestApi,
+  stubStorage,
+  spyStorage,
+  query,
+  isVisible,
+  stubReporting,
+  mockPromise,
+} from '../../../test/test-utils';
+import {
+  AccountId,
+  EmailAddress,
+  FixId,
+  NumericChangeId,
+  ParsedJSON,
+  PatchSetNum,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../../types/common';
+import {
+  pressAndReleaseKeyOn,
+  tap,
+} from '@polymer/iron-test-helpers/mock-interactions';
+import {
+  createComment,
+  createDraft,
+  createFixSuggestionInfo,
+} from '../../../test/test-data-generators';
+import {Timer} from '../../../services/gr-reporting/gr-reporting';
+import {SinonFakeTimers, SinonStubbedMember} from 'sinon';
+import {CreateFixCommentEvent} from '../../../types/events';
+import {DraftInfo, UIRobot} from '../../../utils/comment-util';
+import {MockTimer} from '../../../services/gr-reporting/gr-reporting_mock';
+import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
+
+const basicFixture = fixtureFromElement('gr-comment');
+
+const draftFixture = fixtureFromTemplate(html`
+  <gr-comment draft="true"></gr-comment>
+`);
+
+suite('gr-comment tests', () => {
+  suite('basic tests', () => {
+    let element: GrComment;
+
+    let openOverlaySpy: sinon.SinonSpy;
+
+    setup(() => {
+      stubRestApi('getAccount').returns(
+        Promise.resolve({
+          email: 'dhruvsri@google.com' as EmailAddress,
+          name: 'Dhruv Srivastava',
+          _account_id: 1083225 as AccountId,
+          avatars: [{url: 'abc', height: 32, width: 32}],
+          registered_on: '123' as Timestamp,
+        })
+      );
+      element = basicFixture.instantiate();
+      element.comment = {
+        ...createComment(),
+        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,
+      };
+
+      openOverlaySpy = sinon.spy(element, '_openOverlay');
+    });
+
+    teardown(() => {
+      openOverlaySpy.getCalls().forEach(call => {
+        call.args[0].remove();
+      });
+    });
+
+    test('collapsible comments', () => {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      // When the header row is clicked, the comment should expand
+      tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is not visible'
+      );
+    });
+
+    test('clicking on date link fires event', () => {
+      element.side = 'PARENT';
+      const stub = sinon.stub();
+      element.addEventListener('comment-anchor-tap', stub);
+      flush();
+      const dateEl = queryAndAssert(element, '.date');
+      assert.ok(dateEl);
+      tap(dateEl);
+
+      assert.isTrue(stub.called);
+      assert.deepEqual(stub.lastCall.args[0].detail, {
+        side: element.side,
+        number: element.comment!.line,
+      });
+    });
+
+    test('message is not retrieved from storage when missing path', async () => {
+      const storageStub = stubStorage('getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+      };
+      await flush();
+      assert.isTrue(loadSpy.called);
+      assert.isFalse(storageStub.called);
+    });
+
+    test('message is not retrieved from storage when message present', async () => {
+      const storageStub = stubStorage('getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        message: 'This is a message',
+        line: 5,
+        path: 'test',
+        __editing: true,
+        __draft: true,
+      };
+      await flush();
+      assert.isTrue(loadSpy.called);
+      assert.isFalse(storageStub.called);
+    });
+
+    test('message is retrieved from storage for drafts in edit', async () => {
+      const storageStub = stubStorage('getDraftComment');
+      const loadSpy = sinon.spy(element, '_loadLocalDraft');
+
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        __editing: true,
+        __draft: true,
+      };
+      await flush();
+      assert.isTrue(loadSpy.called);
+      assert.isTrue(storageStub.called);
+    });
+
+    test('comment message sets messageText only when empty', () => {
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element._messageText = '';
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        __editing: true,
+        __draft: true,
+        message: 'hello world',
+      };
+      // messageText was empty so overwrite the message now
+      assert.equal(element._messageText, 'hello world');
+
+      element.comment!.message = 'new message';
+      // messageText was already set so do not overwrite it
+      assert.equal(element._messageText, 'hello world');
+    });
+
+    test('comment message sets messageText when not edited', () => {
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element._messageText = 'Some text';
+      element.comment = {
+        author: {
+          name: 'Mr. Peanutbutter',
+          email: 'tenn1sballchaser@aol.com' as EmailAddress,
+        },
+        line: 5,
+        path: 'test',
+        __editing: false,
+        __draft: true,
+        message: 'hello world',
+      };
+      // messageText was empty so overwrite the message now
+      assert.equal(element._messageText, 'hello world');
+
+      element.comment!.message = 'new message';
+      // messageText was already set so do not overwrite it
+      assert.equal(element._messageText, 'hello world');
+    });
+
+    test('_getPatchNum', () => {
+      element.side = 'PARENT';
+      element.patchNum = 1 as PatchSetNum;
+      assert.equal(element._getPatchNum(), 'PARENT' as PatchSetNum);
+      element.side = 'REVISION';
+      assert.equal(element._getPatchNum(), 1 as PatchSetNum);
+    });
+
+    test('comment expand and collapse', () => {
+      element.collapsed = true;
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      assert.isTrue(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is is not visible'
+      );
+    });
+
+    suite('while editing', () => {
+      let handleCancelStub: sinon.SinonStub;
+      let handleSaveStub: sinon.SinonStub;
+      setup(() => {
+        element.editing = true;
+        element._messageText = 'test';
+        handleCancelStub = sinon.stub(element, '_handleCancel');
+        handleSaveStub = sinon.stub(element, '_handleSave');
+        flush();
+      });
+
+      suite('when text is empty', () => {
+        setup(() => {
+          element._messageText = '';
+          element.comment = {};
+        });
+
+        test('esc closes comment when text is empty', () => {
+          pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
+          assert.isTrue(handleCancelStub.called);
+        });
+
+        test('ctrl+enter does not save', () => {
+          pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
+          assert.isFalse(handleSaveStub.called);
+        });
+
+        test('meta+enter does not save', () => {
+          pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
+          assert.isFalse(handleSaveStub.called);
+        });
+
+        test('ctrl+s does not save', () => {
+          pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
+          assert.isFalse(handleSaveStub.called);
+        });
+      });
+
+      test('esc does not close comment that has content', () => {
+        pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
+        assert.isFalse(handleCancelStub.called);
+      });
+
+      test('ctrl+enter saves', () => {
+        pressAndReleaseKeyOn(element.textarea!, 13, 'ctrl', 'Enter');
+        assert.isTrue(handleSaveStub.called);
+      });
+
+      test('meta+enter saves', () => {
+        pressAndReleaseKeyOn(element.textarea!, 13, 'meta', 'Enter');
+        assert.isTrue(handleSaveStub.called);
+      });
+
+      test('ctrl+s saves', () => {
+        pressAndReleaseKeyOn(element.textarea!, 83, 'ctrl', 's');
+        assert.isTrue(handleSaveStub.called);
+      });
+    });
+
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(
+        queryAndAssert(element, '.action.delete').classList.contains(
+          'showDeleteButtons'
+        )
+      );
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(
+        queryAndAssert(element, '.action.delete').classList.contains(
+          'showDeleteButtons'
+        )
+      );
+    });
+
+    test('delete comment', async () => {
+      const stub = stubRestApi('deleteComment').returns(
+        Promise.resolve({
+          id: '1' as UrlEncodedCommentId,
+          updated: '1' as Timestamp,
+          ...createComment(),
+        })
+      );
+      const openSpy = sinon.spy(element.confirmDeleteOverlay!, 'open');
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element._isAdmin = true;
+      assert.isTrue(
+        queryAndAssert(element, '.action.delete').classList.contains(
+          'showDeleteButtons'
+        )
+      );
+      tap(queryAndAssert(element, '.action.delete'));
+      await flush();
+      await openSpy.lastCall.returnValue;
+      const dialog = element.confirmDeleteOverlay?.querySelector(
+        '#confirmDeleteComment'
+      ) as GrConfirmDeleteCommentDialog;
+      dialog.message = 'removal reason';
+      element._handleConfirmDeleteComment();
+      assert.isTrue(
+        stub.calledWith(
+          42 as NumericChangeId,
+          1 as PatchSetNum,
+          'baf0414d_60047215' as UrlEncodedCommentId,
+          'removal reason'
+        )
+      );
+    });
+
+    suite('draft update reporting', () => {
+      let endStub: SinonStubbedMember<() => Timer>;
+      let getTimerStub: sinon.SinonStub;
+      const mockEvent = {...new Event('click'), preventDefault() {}};
+
+      setup(() => {
+        sinon.stub(element, 'save').returns(Promise.resolve({}));
+        endStub = sinon.stub();
+        const mockTimer = new MockTimer();
+        mockTimer.end = endStub;
+        getTimerStub = stubReporting('getTimer').returns(mockTimer);
+      });
+
+      test('create', async () => {
+        element.patchNum = 1 as PatchSetNum;
+        element.comment = {};
+        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
+        await element._handleSave(mockEvent);
+        await flush();
+        const grAccountLabel = queryAndAssert(element, 'gr-account-label');
+        const spanName = queryAndAssert<HTMLSpanElement>(
+          grAccountLabel,
+          'span.name'
+        );
+        assert.equal(spanName.innerText.trim(), 'Dhruv Srivastava');
+        assert.isTrue(endStub.calledOnce);
+        assert.isTrue(getTimerStub.calledOnce);
+        assert.equal(getTimerStub.lastCall.args[0], 'CreateDraftComment');
+      });
+
+      test('update', () => {
+        element.comment = {
+          ...createComment(),
+          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
+        };
+        sinon.stub(element, '_discardDraft').returns(Promise.resolve({}));
+        return element._handleSave(mockEvent)!.then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'UpdateDraftComment');
+        });
+      });
+
+      test('discard', () => {
+        element.comment = {
+          ...createComment(),
+          id: 'abc_123' as UrlEncodedCommentId as UrlEncodedCommentId,
+        };
+        element.comment = createDraft();
+        sinon.stub(element, '_fireDiscard');
+        sinon.stub(element, '_eraseDraftCommentFromStorage');
+        sinon
+          .stub(element, '_deleteDraft')
+          .returns(Promise.resolve(new Response()));
+        return element._discardDraft().then(() => {
+          assert.isTrue(endStub.calledOnce);
+          assert.isTrue(getTimerStub.calledOnce);
+          assert.equal(getTimerStub.lastCall.args[0], 'DiscardDraftComment');
+        });
+      });
+    });
+
+    test('edit reports interaction', () => {
+      const reportStub = stubReporting('recordDraftInteraction');
+      sinon.stub(element, '_fireEdit');
+      element.draft = true;
+      flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('discard reports interaction', () => {
+      const reportStub = stubReporting('recordDraftInteraction');
+      sinon.stub(element, '_eraseDraftCommentFromStorage');
+      sinon.stub(element, '_fireDiscard');
+      sinon
+        .stub(element, '_deleteDraft')
+        .returns(Promise.resolve(new Response()));
+      element.draft = true;
+      element.comment = createDraft();
+      flush();
+      tap(queryAndAssert(element, '.discard'));
+      assert.isTrue(reportStub.calledOnce);
+    });
+
+    test('failed save draft request', async () => {
+      element.draft = true;
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
+        Promise.resolve({...new Response(), ok: false})
+      );
+      element._saveDraft({
+        ...createComment(),
+        id: 'abc_123' as UrlEncodedCommentId,
+      });
+      await flush();
+      let args = updateRequestStub.lastCall.args;
+      assert.deepEqual(args, [0, true]);
+      assert.equal(
+        element._getSavingMessage(...args),
+        __testOnly_UNSAVED_MESSAGE
+      );
+      assert.equal(
+        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
+        'DRAFT(Failed to save)'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is visible'
+      );
+      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
+      element._saveDraft({
+        ...createComment(),
+        id: 'abc_123' as UrlEncodedCommentId,
+      });
+      await flush();
+      args = updateRequestStub.lastCall.args;
+      assert.deepEqual(args, [0]);
+      assert.equal(element._getSavingMessage(...args), 'All changes saved');
+      assert.equal(
+        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
+        'DRAFT'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is not visible'
+      );
+      assert.isFalse(element._unableToSave);
+    });
+
+    test('failed save draft request with promise failure', async () => {
+      element.draft = true;
+      element.changeNum = 1 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      const updateRequestStub = sinon.stub(element, '_updateRequestToast');
+      const diffDraftStub = stubRestApi('saveDiffDraft').returns(
+        Promise.reject(new Error())
+      );
+      element._saveDraft({
+        ...createComment(),
+        id: 'abc_123' as UrlEncodedCommentId,
+      });
+      await flush();
+      let args = updateRequestStub.lastCall.args;
+      assert.deepEqual(args, [0, true]);
+      assert.equal(
+        element._getSavingMessage(...args),
+        __testOnly_UNSAVED_MESSAGE
+      );
+      assert.equal(
+        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
+        'DRAFT(Failed to save)'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is visible'
+      );
+      diffDraftStub.returns(Promise.resolve({...new Response(), ok: true}));
+      element._saveDraft({
+        ...createComment(),
+        id: 'abc_123' as UrlEncodedCommentId,
+      });
+      await flush();
+      args = updateRequestStub.lastCall.args;
+      assert.deepEqual(args, [0]);
+      assert.equal(element._getSavingMessage(...args), 'All changes saved');
+      assert.equal(
+        (queryAndAssert(element, '.draftLabel') as HTMLSpanElement).innerText,
+        'DRAFT'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is not visible'
+      );
+      assert.isFalse(element._unableToSave);
+    });
+  });
+
+  suite('gr-comment draft tests', () => {
+    let element: GrComment;
+
+    setup(() => {
+      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+      stubRestApi('saveDiffDraft').returns(
+        Promise.resolve({
+          ...new Response(),
+          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!",' +
+                '"side": "REVISION",' +
+                '"unresolved": false,' +
+                '"patch_set": 1' +
+                '}'
+            );
+          },
+        })
+      );
+      stubRestApi('removeChangeReviewer').returns(
+        Promise.resolve({...new Response(), ok: true})
+      );
+      element = draftFixture.instantiate() as GrComment;
+      stubStorage('getDraftComment').returns(null);
+      element.changeNum = 42 as NumericChangeId;
+      element.patchNum = 1 as PatchSetNum;
+      element.editing = false;
+      element.comment = {
+        ...createComment(),
+        __draft: true,
+        __draftID: 'temp_draft_id',
+        path: '/path/to/file',
+        line: 5,
+        id: undefined,
+      };
+    });
+
+    test('button visibility states', async () => {
+      element.showActions = false;
+      assert.isTrue(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.showActions = true;
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.draft = true;
+      await flush();
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.edit')),
+        'edit is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.discard')),
+        'discard is visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.resolve')),
+        'resolve is visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.editing = true;
+      await flush();
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.edit')),
+        'edit is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.discard')),
+        'discard not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.resolve')),
+        'resolve is visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.draft = false;
+      element.editing = false;
+      await flush();
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.edit')),
+        'edit is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.discard')),
+        'discard is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.save')),
+        'save is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is not visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      element.comment!.id = 'foo' as UrlEncodedCommentId;
+      element.draft = true;
+      element.editing = true;
+      await flush();
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.cancel')),
+        'cancel is visible'
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isTrue(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      // Delete button is not hidden by default
+      assert.isFalse(
+        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
+      );
+
+      element.isRobotComment = true;
+      element.draft = true;
+      assert.isTrue(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      // It is not expected to see Robot comment drafts, but if they appear,
+      // they will behave the same as non-drafts.
+      element.draft = false;
+      assert.isTrue(
+        queryAndAssert(element, '.humanActions').hasAttribute('hidden')
+      );
+      assert.isFalse(
+        queryAndAssert(element, '.robotActions').hasAttribute('hidden')
+      );
+
+      // A robot comment with run ID should display plain text.
+      element.set(['comment', 'robot_run_id'], 'text');
+      element.editing = false;
+      element.collapsed = false;
+      await flush();
+      assert.isTrue(
+        queryAndAssert(element, '.robotRun.link').textContent === 'Run Details'
+      );
+
+      // A robot comment with run ID and url should display a link.
+      element.set(['comment', 'url'], '/path/to/run');
+      await flush();
+      assert.notEqual(
+        getComputedStyle(queryAndAssert(element, '.robotRun.link')).display,
+        'none'
+      );
+
+      // Delete button is hidden for robot comments
+      assert.isTrue(
+        (queryAndAssert(element, '#deleteBtn') as HTMLElement).hidden
+      );
+    });
+
+    test('collapsible drafts', async () => {
+      const fireEditStub = sinon.stub(element, '_fireEdit');
+      assert.isTrue(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      assert.isTrue(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isNotOk(element.textarea, 'textarea is not visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is is not visible'
+      );
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      element.draft = true;
+      await flush();
+      tap(queryAndAssert(element, '.edit'));
+      await flush();
+      assert.isTrue(fireEditStub.called);
+      assert.isFalse(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is not visible'
+      );
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      tap(element.$.header);
+      assert.isTrue(element.collapsed);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are not visible'
+      );
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-textarea')),
+        'textarea is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is visible'
+      );
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      tap(element.$.header);
+      assert.isFalse(
+        isVisible(queryAndAssert(element, 'gr-formatted-text')),
+        'gr-formatted-text is not visible'
+      );
+      assert.isTrue(
+        isVisible(queryAndAssert(element, '.actions')),
+        'actions are visible'
+      );
+      assert.isTrue(isVisible(element.textarea!), 'textarea is visible');
+      assert.isFalse(
+        isVisible(queryAndAssert(element, '.collapsedContent')),
+        'header middle content is not visible'
+      );
+    });
+
+    test('robot comment layout', async () => {
+      const comment = {
+        robot_id: 'happy_robot_id' as RobotId,
+        url: '/robot/comment',
+        author: {
+          name: 'Happy Robot',
+          display_name: 'Display name Robot',
+        },
+        ...element.comment,
+      };
+      element.comment = comment;
+      element.collapsed = false;
+      await flush;
+      let runIdMessage;
+      runIdMessage = queryAndAssert(element, '.runIdMessage') as HTMLElement;
+      assert.isFalse((runIdMessage as HTMLElement).hidden);
+
+      const runDetailsLink = queryAndAssert(
+        element,
+        '.robotRunLink'
+      ) as HTMLAnchorElement;
+      assert.isTrue(
+        runDetailsLink.href.indexOf((element.comment as UIRobot).url!) !== -1
+      );
+
+      const robotServiceName = queryAndAssert(element, '.robotName');
+      assert.equal(robotServiceName.textContent?.trim(), 'happy_robot_id');
+
+      const authorName = queryAndAssert(element, '.robotId');
+      assert.isTrue((authorName as HTMLDivElement).innerText === 'Happy Robot');
+
+      element.collapsed = true;
+      await flush();
+      runIdMessage = queryAndAssert(element, '.runIdMessage');
+      assert.isTrue((runIdMessage as HTMLDivElement).hidden);
+    });
+
+    test('author name fallback to email', async () => {
+      const comment = {
+        url: '/robot/comment',
+        author: {
+          email: 'test@test.com' as EmailAddress,
+        },
+        ...element.comment,
+      };
+      element.comment = comment;
+      element.collapsed = false;
+      await flush();
+      const authorName = queryAndAssert(
+        queryAndAssert(element, 'gr-account-label'),
+        'span.name'
+      ) as HTMLSpanElement;
+      assert.equal(authorName.innerText.trim(), 'test@test.com');
+    });
+
+    test('patchset level comment', async () => {
+      const fireEditStub = sinon.stub(element, '_fireEdit');
+      const comment = {
+        ...element.comment,
+        path: SpecialFilePath.PATCHSET_LEVEL_COMMENTS,
+        line: undefined,
+        range: undefined,
+      };
+      element.comment = comment;
+      await flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(fireEditStub.called);
+      assert.isTrue(element.editing);
+
+      element._messageText = 'hello world';
+      const eraseMessageDraftSpy = spyStorage('eraseDraftComment');
+      const mockEvent = {...new Event('click'), preventDefault: sinon.stub()};
+      element._handleSave(mockEvent);
+      await flush();
+      assert.isTrue(eraseMessageDraftSpy.called);
+    });
+
+    test('draft creation/cancellation', async () => {
+      const fireEditStub = sinon.stub(element, '_fireEdit');
+      assert.isFalse(element.editing);
+      element.draft = true;
+      await flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(fireEditStub.called);
+      assert.isTrue(element.editing);
+
+      element.comment!.message = '';
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(
+        element,
+        '_eraseDraftCommentFromStorage'
+      );
+
+      // Save should be disabled on an empty message.
+      let disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+      element._messageText = '     ';
+      disabled = queryAndAssert(element, '.save').hasAttribute('disabled');
+      assert.isTrue(disabled, 'save button should be disabled.');
+
+      const updateStub = sinon.stub();
+      element.addEventListener('comment-update', updateStub);
+
+      let numDiscardEvents = 0;
+      const promise = mockPromise();
+      element.addEventListener('comment-discard', () => {
+        numDiscardEvents++;
+        assert.isFalse(eraseMessageDraftSpy.called);
+        if (numDiscardEvents === 2) {
+          assert.isFalse(updateStub.called);
+          promise.resolve();
+        }
+      });
+      tap(queryAndAssert(element, '.cancel'));
+      await flush();
+      element._messageText = '';
+      element.editing = true;
+      await flush();
+      pressAndReleaseKeyOn(element.textarea!, 27, null, 'Escape');
+      await promise;
+    });
+
+    test('draft discard removes message from storage', async () => {
+      element._messageText = '';
+      const eraseMessageDraftSpy = sinon.spy(
+        element,
+        '_eraseDraftCommentFromStorage'
+      );
+
+      const promise = mockPromise();
+      element.addEventListener('comment-discard', () => {
+        assert.isTrue(eraseMessageDraftSpy.called);
+        promise.resolve();
+      });
+      element._handleDiscard({
+        ...new Event('click'),
+        preventDefault: sinon.stub(),
+      });
+      await promise;
+    });
+
+    test('storage is cleared only after save success', () => {
+      element._messageText = 'test';
+      const eraseStub = sinon.stub(element, '_eraseDraftCommentFromStorage');
+      stubRestApi('getResponseObject').returns(
+        Promise.resolve({...(createDraft() as ParsedJSON)})
+      );
+      const saveDraftStub = sinon
+        .stub(element, '_saveDraft')
+        .returns(Promise.resolve({...new Response(), ok: false}));
+
+      const savePromise = element.save();
+      assert.isFalse(eraseStub.called);
+      return savePromise.then(() => {
+        assert.isFalse(eraseStub.called);
+
+        saveDraftStub.restore();
+        sinon
+          .stub(element, '_saveDraft')
+          .returns(Promise.resolve({...new Response(), ok: true}));
+        return element.save().then(() => {
+          assert.isTrue(eraseStub.called);
+        });
+      });
+    });
+
+    test('_computeSaveDisabled', () => {
+      const comment = {unresolved: true};
+      const msgComment = {message: 'test', unresolved: true};
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+      assert.equal(element._computeSaveDisabled('test', comment, false), false);
+      assert.equal(element._computeSaveDisabled('', msgComment, false), true);
+      assert.equal(
+        element._computeSaveDisabled('test', msgComment, false),
+        false
+      );
+      assert.equal(
+        element._computeSaveDisabled('test2', msgComment, false),
+        false
+      );
+      assert.equal(element._computeSaveDisabled('test', comment, true), false);
+      assert.equal(element._computeSaveDisabled('', comment, true), true);
+      assert.equal(element._computeSaveDisabled('', comment, false), true);
+    });
+
+    test('ctrl+s saves comment', async () => {
+      const promise = mockPromise();
+      const stub = sinon.stub(element, 'save').callsFake(() => {
+        assert.isTrue(stub.called);
+        stub.restore();
+        promise.resolve();
+        return Promise.resolve();
+      });
+      element._messageText = 'is that the horse from horsing around??';
+      element.editing = true;
+      await flush();
+      pressAndReleaseKeyOn(
+        element.textarea!.$.textarea.textarea,
+        83,
+        'ctrl',
+        's'
+      );
+      await promise;
+    });
+
+    test('draft saving/editing', async () => {
+      const dispatchEventStub = sinon.stub(element, 'dispatchEvent');
+      const fireEditStub = sinon.stub(element, '_fireEdit');
+      const clock: SinonFakeTimers = sinon.useFakeTimers();
+      const tickAndFlush = async (repetitions: number) => {
+        for (let i = 1; i <= repetitions; i++) {
+          clock.tick(1000);
+          await flush();
+        }
+      };
+
+      element.draft = true;
+      await flush();
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(fireEditStub.called);
+      tickAndFlush(1);
+      element._messageText = 'good news, everyone!';
+      tickAndFlush(1);
+      assert.equal(dispatchEventStub.lastCall.args[0].type, 'comment-update');
+      assert.isTrue(dispatchEventStub.calledTwice);
+
+      element._messageText = 'good news, everyone!';
+      await flush();
+      assert.isTrue(dispatchEventStub.calledTwice);
+
+      tap(queryAndAssert(element, '.save'));
+
+      assert.isTrue(
+        element.disabled,
+        'Element should be disabled when creating draft.'
+      );
+
+      let draft = await element._xhrPromise!;
+      const evt = dispatchEventStub.lastCall.args[0] as CustomEvent<{
+        comment: DraftInfo;
+      }>;
+      assert.equal(evt.type, 'comment-save');
+
+      const expectedDetail = {
+        comment: {
+          ...createComment(),
+          __draft: true,
+          __draftID: 'temp_draft_id',
+          id: 'baf0414d_40572e03' as UrlEncodedCommentId,
+          line: 5,
+          message: 'saved!',
+          path: '/path/to/file',
+          updated: '2015-12-08 21:52:36.177000000' as Timestamp,
+        },
+        patchNum: 1 as PatchSetNum,
+      };
+
+      assert.deepEqual(evt.detail, expectedDetail);
+      assert.isFalse(
+        element.disabled,
+        'Element should be enabled when done creating draft.'
+      );
+      assert.equal(draft.message, 'saved!');
+      assert.isFalse(element.editing);
+      tap(queryAndAssert(element, '.edit'));
+      assert.isTrue(fireEditStub.calledTwice);
+      element._messageText =
+        'You’ll be delivering a package to Chapek 9, ' +
+        'a world where humans are killed on sight.';
+      tap(queryAndAssert(element, '.save'));
+      assert.isTrue(
+        element.disabled,
+        'Element should be disabled when updating draft.'
+      );
+      draft = await element._xhrPromise!;
+      assert.isFalse(
+        element.disabled,
+        'Element should be enabled when done updating draft.'
+      );
+      assert.equal(draft.message, 'saved!');
+      assert.isFalse(element.editing);
+      dispatchEventStub.restore();
+    });
+
+    test('draft prevent save when disabled', async () => {
+      const saveStub = sinon.stub(element, 'save').returns(Promise.resolve());
+      element.showActions = true;
+      element.draft = true;
+      await flush();
+      tap(element.$.header);
+      tap(queryAndAssert(element, '.edit'));
+      element._messageText = 'good news, everyone!';
+      await flush();
+
+      element.disabled = true;
+      tap(queryAndAssert(element, '.save'));
+      assert.isFalse(saveStub.called);
+
+      element.disabled = false;
+      tap(queryAndAssert(element, '.save'));
+      assert.isTrue(saveStub.calledOnce);
+    });
+
+    test('proper event fires on resolve, comment is not saved', async () => {
+      const save = sinon.stub(element, 'save');
+      const promise = mockPromise();
+      element.addEventListener('comment-update', e => {
+        assert.isTrue(e.detail.comment.unresolved);
+        assert.isFalse(save.called);
+        promise.resolve();
+      });
+      tap(queryAndAssert(element, '.resolve input'));
+      await promise;
+    });
+
+    test('resolved comment state indicated by checkbox', () => {
+      sinon.stub(element, 'save');
+      element.comment = {unresolved: false};
+      assert.isTrue(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      element.comment = {unresolved: true};
+      assert.isFalse(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+    });
+
+    test('resolved checkbox saves with tap when !editing', () => {
+      element.editing = false;
+      const save = sinon.stub(element, 'save');
+
+      element.comment = {unresolved: false};
+      assert.isTrue(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      element.comment = {unresolved: true};
+      assert.isFalse(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      assert.isFalse(save.called);
+      tap(element.$.resolvedCheckbox);
+      assert.isTrue(
+        (queryAndAssert(element, '.resolve input') as HTMLInputElement).checked
+      );
+      assert.isTrue(save.called);
+    });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sinon.stub(element, '_updateRequestToast');
+        element._numPendingDraftRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDraftRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDraftRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDraftRequests.number, 0);
+      });
+    });
+
+    test('cancelling an unsaved draft discards, persists in storage', async () => {
+      const clock: SinonFakeTimers = sinon.useFakeTimers();
+      const tickAndFlush = async (repetitions: number) => {
+        for (let i = 1; i <= repetitions; i++) {
+          clock.tick(1000);
+          await flush();
+        }
+      };
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = stubStorage('setDraftComment');
+      const eraseStub = stubStorage('eraseDraftComment');
+      element.comment!.id = undefined; // set id undefined for draft
+      element._messageText = 'test text';
+      tickAndFlush(1);
+
+      assert.isTrue(storeStub.called);
+      assert.equal(storeStub.lastCall.args[1], 'test text');
+      element._handleCancel({
+        ...new Event('click'),
+        preventDefault: sinon.stub(),
+      });
+      await flush();
+      assert.isTrue(discardSpy.called);
+      assert.isFalse(eraseStub.called);
+    });
+
+    test('cancelling edit on a saved draft does not store', () => {
+      element.comment!.id = 'foo' as UrlEncodedCommentId;
+      const discardSpy = sinon.spy(element, '_fireDiscard');
+      const storeStub = stubStorage('setDraftComment');
+      element.comment!.id = undefined; // set id undefined for draft
+      element._messageText = 'test text';
+      flush();
+
+      assert.isFalse(storeStub.called);
+      element._handleCancel({...new Event('click'), preventDefault: () => {}});
+      assert.isTrue(discardSpy.called);
+    });
+
+    test('deleting text from saved draft and saving deletes the draft', () => {
+      element.comment = {
+        ...createComment(),
+        id: 'foo' as UrlEncodedCommentId,
+        message: 'test',
+      };
+      element._messageText = '';
+      const discardStub = sinon.stub(element, '_discardDraft');
+
+      element.save();
+      assert.isTrue(discardStub.called);
+    });
+
+    test('_handleFix fires create-fix event', async () => {
+      const promise = mockPromise();
+      element.addEventListener(
+        'create-fix-comment',
+        (e: CreateFixCommentEvent) => {
+          assert.deepEqual(e.detail, element._getEventPayload());
+          promise.resolve();
+        }
+      );
+      element.isRobotComment = true;
+      element.comments = [element.comment!];
+      await flush();
+
+      tap(queryAndAssert(element, '.fix'));
+      await promise;
+    });
+
+    test('do not show Please Fix button if human reply exists', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id' as RobotId,
+          robot_run_id: '5838406743490560' as RobotRunId,
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf' as FixId,
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912 as AccountId,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com' as EmailAddress,
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+                width: 32,
+              },
+            ],
+          },
+          patch_set: 1 as PatchSetNum,
+          ...createComment(),
+          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          collapsed: false,
+        },
+        {
+          __draft: true,
+          __draftID: '0.wbrfbwj89sa',
+          __date: new Date(),
+          path: 'Documentation/config-gerrit.txt',
+          side: CommentSide.REVISION,
+          line: 10,
+          in_reply_to: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
+          message: '> This is a robot comment with a fix.\n\nPlease fix.',
+          unresolved: true,
+        },
+      ];
+      element.comment = element.comments[0];
+      flush();
+      assert.isNull(
+        element.shadowRoot?.querySelector('robotActions gr-button')
+      );
+    });
+
+    test('show Please Fix if no human reply', () => {
+      element.comments = [
+        {
+          robot_id: 'happy_robot_id' as RobotId,
+          robot_run_id: '5838406743490560' as RobotRunId,
+          fix_suggestions: [
+            {
+              fix_id: '478ff847_3bf47aaf' as FixId,
+              description: 'Make the smiley happier by giving it a nose.',
+              replacements: [
+                {
+                  path: 'Documentation/config-gerrit.txt',
+                  range: {
+                    start_line: 10,
+                    start_character: 7,
+                    end_line: 10,
+                    end_character: 9,
+                  },
+                  replacement: ':-)',
+                },
+              ],
+            },
+          ],
+          author: {
+            _account_id: 1030912 as AccountId,
+            name: 'Alice Kober-Sotzek',
+            email: 'aliceks@google.com' as EmailAddress,
+            avatars: [
+              {
+                url: '/s32-p/photo.jpg',
+                height: 32,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s56-p/photo.jpg',
+                height: 56,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s100-p/photo.jpg',
+                height: 100,
+                width: 32,
+              },
+              {
+                url: '/AaAdOFzPlFI/s120-p/photo.jpg',
+                height: 120,
+                width: 32,
+              },
+            ],
+          },
+          patch_set: 1 as PatchSetNum,
+          ...createComment(),
+          id: 'eb0d03fd_5e95904f' as UrlEncodedCommentId,
+          line: 10,
+          updated: '2017-04-04 15:36:17.000000000' as Timestamp,
+          message: 'This is a robot comment with a fix.',
+          unresolved: false,
+          collapsed: false,
+        },
+      ];
+      element.comment = element.comments[0];
+      flush();
+      queryAndAssert(element, '.robotActions gr-button');
+    });
+
+    test('_handleShowFix fires open-fix-preview event', async () => {
+      const promise = mockPromise();
+      element.addEventListener('open-fix-preview', e => {
+        assert.deepEqual(e.detail, element._getEventPayload());
+        promise.resolve();
+      });
+      element.comment = {
+        ...createComment(),
+        fix_suggestions: [{...createFixSuggestionInfo()}],
+      };
+      element.isRobotComment = true;
+      await flush();
+
+      tap(queryAndAssert(element, '.show-fix'));
+      await promise;
+    });
+  });
+
+  suite('respectful tips', () => {
+    let element: GrComment;
+
+    let clock: sinon.SinonFakeTimers;
+    setup(() => {
+      stubRestApi('getAccount').returns(Promise.resolve(undefined));
+      clock = sinon.useFakeTimers();
+    });
+
+    teardown(() => {
+      clock.restore();
+      sinon.restore();
+    });
+
+    test('show tip when no cached record', async () => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true, __draft: true};
+      await flush();
+      assert.isTrue(respectfulGetStub.called);
+      assert.isTrue(respectfulSetStub.called);
+      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    });
+
+    test('add 14-day delays once dismissed', async () => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true, __draft: true};
+      await flush();
+      assert.isTrue(respectfulGetStub.called);
+      assert.isTrue(respectfulSetStub.called);
+      assert.isTrue(respectfulSetStub.lastCall.args[0] === undefined);
+      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+
+      tap(queryAndAssert(element, '.respectfulReviewTip .close'));
+      flush();
+      assert.isTrue(respectfulSetStub.lastCall.args[0] === 14);
+    });
+
+    test('do not show tip when fall out of probability', async () => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 3;
+      element.comment = {__editing: true, __draft: true};
+      await flush();
+      assert.isTrue(respectfulGetStub.called);
+      assert.isFalse(respectfulSetStub.called);
+      assert.isNotOk(query(element, '.respectfulReviewTip'));
+    });
+
+    test('show tip when editing changed to true', async () => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns(null);
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: false};
+      await flush();
+      assert.isFalse(respectfulGetStub.called);
+      assert.isFalse(respectfulSetStub.called);
+      assert.isNotOk(query(element, '.respectfulReviewTip'));
+
+      element.editing = true;
+      await flush();
+      assert.isTrue(respectfulGetStub.called);
+      assert.isTrue(respectfulSetStub.called);
+      assert.isTrue(!!queryAndAssert(element, '.respectfulReviewTip'));
+    });
+
+    test('no tip when cached record', async () => {
+      element = draftFixture.instantiate() as GrComment;
+      const respectfulGetStub = stubStorage('getRespectfulTipVisibility');
+      const respectfulSetStub = stubStorage('setRespectfulTipVisibility');
+      respectfulGetStub.returns({updated: 0});
+      // fake random
+      element.getRandomNum = () => 0;
+      element.comment = {__editing: true, __draft: true};
+      await flush();
+      assert.isTrue(respectfulGetStub.called);
+      assert.isFalse(respectfulSetStub.called);
+      assert.isNotOk(query(element, '.respectfulReviewTip'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
index 0f071abb..0a9b9c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.ts
@@ -54,13 +54,13 @@
    */
 
   @property({type: String})
-  message?: string;
+  message = '';
 
   resetFocus() {
     this.$.messageInput.textarea.focus();
   }
 
-  _handleConfirmTap(e: MouseEvent) {
+  _handleConfirmTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -72,7 +72,7 @@
     );
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  _handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
index 110c242..01422a9 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.ts
@@ -15,15 +15,15 @@
  * limitations under the License.
  */
 import '@polymer/iron-input/iron-input';
-import '../../../styles/shared-styles';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-copy-clipboard_html';
-import {GrButton} from '../gr-button/gr-button';
-import {customElement, property} from '@polymer/decorators';
 import {IronIconElement} from '@polymer/iron-icon';
+import {assertIsDefined, queryAndAssert} from '../../../utils/common-util';
+import {classMap} from 'lit/directives/class-map';
+import {ifDefined} from 'lit/directives/if-defined';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {GrButton} from '../gr-button/gr-button';
 
 const COPY_TIMEOUT_MS = 1000;
 
@@ -32,17 +32,8 @@
     'gr-copy-clipboard': GrCopyClipboard;
   }
 }
-
-export interface GrCopyClipboard {
-  $: {button: GrButton; icon: IronIconElement; input: HTMLInputElement};
-}
-
 @customElement('gr-copy-clipboard')
-export class GrCopyClipboard extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrCopyClipboard extends LitElement {
   @property({type: String})
   text: string | undefined;
 
@@ -55,36 +46,114 @@
   @property({type: Boolean})
   hideInput = false;
 
-  focusOnCopy() {
-    this.$.button.focus();
+  static override get styles() {
+    return [
+      css`
+        .text {
+          align-items: center;
+          display: flex;
+          flex-wrap: wrap;
+        }
+        .copyText {
+          flex-grow: 1;
+          margin-right: var(--spacing-s);
+        }
+        .hideInput {
+          display: none;
+        }
+        input#input {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-mono);
+          line-height: var(--line-height-mono);
+          width: 100%;
+        }
+        /*
+         * Typically icons are 20px, which is the normal line-height.
+         * The copy icon is too prominent at 20px, so we choose 16px
+         * here, but add 2x2px padding below, so the entire
+         * component should still fit nicely into a normal inline
+         * layout flow.
+         */
+        #icon {
+          height: 16px;
+          width: 16px;
+        }
+        iron-icon {
+          color: var(--deemphasized-text-color);
+          vertical-align: top;
+          --iron-icon-height: 20px;
+          --iron-icon-width: 20px;
+        }
+        gr-button {
+          display: block;
+          --gr-button-padding: 2px;
+        }
+      `,
+    ];
   }
 
-  _computeInputClass(hideInput: boolean) {
-    return hideInput ? 'hideInput' : '';
+  override render() {
+    return html`
+      <div class="text">
+        <iron-input
+          class="copyText"
+          @click="${this._handleInputClick}"
+          .bindValue=${this.text ?? ''}
+        >
+          <input
+            id="input"
+            is="iron-input"
+            class="${classMap({hideInput: this.hideInput})}"
+            type="text"
+            @click="${this._handleInputClick}"
+            readonly=""
+            .value=${this.text ?? ''}
+            part="text-container-style"
+          />
+        </iron-input>
+        <gr-tooltip-content
+          ?has-tooltip=${this.hasTooltip}
+          title="${ifDefined(this.buttonTitle)}"
+        >
+          <gr-button
+            id="copy-clipboard-button"
+            link=""
+            class="copyToClipboard"
+            @click="${this._copyToClipboard}"
+            aria-label="Click to copy to clipboard"
+          >
+            <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
+          </gr-button>
+        </gr-tooltip-content>
+      </div>
+    `;
+  }
+
+  focusOnCopy() {
+    queryAndAssert<GrButton>(this, '#copy-clipboard-button').focus();
   }
 
   _handleInputClick(e: MouseEvent) {
     e.preventDefault();
-    ((dom(e) as EventApi).rootTarget as HTMLInputElement).select();
+    const rootTarget = e.composedPath()[0];
+    (rootTarget as HTMLInputElement).select();
   }
 
   _copyToClipboard(e: MouseEvent) {
     e.preventDefault();
     e.stopPropagation();
 
-    if (this.hideInput) {
-      this.$.input.style.display = 'block';
-    }
-    this.$.input.focus();
-    this.$.input.select();
-    document.execCommand('copy');
-    if (this.hideInput) {
-      this.$.input.style.display = 'none';
-    }
-    this.$.icon.icon = 'gr-icons:check';
+    this.text = queryAndAssert<HTMLInputElement>(this, '#input').value;
+    assertIsDefined(this.text, 'text');
+    this.iconEl.icon = 'gr-icons:check';
+    navigator.clipboard.writeText(this.text);
     setTimeout(
-      () => (this.$.icon.icon = 'gr-icons:content-copy'),
+      () => (this.iconEl.icon = 'gr-icons:content-copy'),
       COPY_TIMEOUT_MS
     );
   }
+
+  private get iconEl(): IronIconElement {
+    return queryAndAssert<IronIconElement>(this, '#icon');
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
deleted file mode 100644
index 3ccc46f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_html.ts
+++ /dev/null
@@ -1,93 +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>
-    .text {
-      align-items: center;
-      display: flex;
-      flex-wrap: wrap;
-    }
-    .copyText {
-      flex-grow: 1;
-      margin-right: var(--spacing-s);
-    }
-    .hideInput {
-      display: none;
-    }
-    input#input {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-mono);
-      line-height: var(--line-height-mono);
-      @apply --text-container-style;
-      width: 100%;
-    }
-    /*
-       * Typically icons are 20px, which is the normal line-height.
-       * The copy icon is too prominent at 20px, so we choose 16px
-       * here, but add 2x2px padding below, so the entire
-       * component should still fit nicely into a normal inline
-       * layout flow.
-       */
-    #icon {
-      height: 16px;
-      width: 16px;
-    }
-    iron-icon {
-      color: var(--deemphasized-text-color);
-      vertical-align: top;
-      --iron-icon-height: 20px;
-      --iron-icon-width: 20px;
-    }
-    gr-button {
-      --gr-button: {
-        padding: 2px;
-      }
-    }
-  </style>
-  <div class="text">
-    <iron-input
-      class="copyText"
-      type="text"
-      bind-value="[[text]]"
-      on-click="_handleInputClick"
-      readonly=""
-    >
-      <input
-        id="input"
-        is="iron-input"
-        class$="[[_computeInputClass(hideInput)]]"
-        type="text"
-        bind-value="[[text]]"
-        on-click="_handleInputClick"
-        readonly=""
-      />
-    </iron-input>
-    <gr-button
-      id="button"
-      link=""
-      has-tooltip="[[hasTooltip]]"
-      class="copyToClipboard"
-      title="[[buttonTitle]]"
-      on-click="_copyToClipboard"
-      aria-label="Click to copy to clipboard"
-    >
-      <iron-icon id="icon" icon="gr-icons:content-copy"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
similarity index 61%
rename from polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
rename to polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
index 55b2483..ef62fe9 100644
--- a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.ts
@@ -15,14 +15,16 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-copy-clipboard.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import '../../../test/common-test-setup-karma';
+import './gr-copy-clipboard';
+import {GrCopyClipboard} from './gr-copy-clipboard';
+import {queryAndAssert} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 
 const basicFixture = fixtureFromElement('gr-copy-clipboard');
 
 suite('gr-copy-clipboard tests', () => {
-  let element;
+  let element: GrCopyClipboard;
 
   setup(async () => {
     element = basicFixture.instantiate();
@@ -32,42 +34,42 @@
   });
 
   test('copy to clipboard', () => {
-    const clipboardSpy = sinon.spy(element, '_copyToClipboard');
-    const copyBtn = element.shadowRoot
-        .querySelector('.copyToClipboard');
-    MockInteractions.tap(copyBtn);
+    const clipboardSpy = sinon.spy(navigator.clipboard, 'writeText');
+    const copyBtn = queryAndAssert(element, '.copyToClipboard');
+    MockInteractions.click(copyBtn);
     assert.isTrue(clipboardSpy.called);
   });
 
   test('focusOnCopy', () => {
     element.focusOnCopy();
-    assert.deepEqual(dom(element.root).activeElement,
-        element.shadowRoot
-            .querySelector('.copyToClipboard'));
+    const activeElement = element.shadowRoot!.activeElement;
+    const button = queryAndAssert(element, '.copyToClipboard');
+    assert.deepEqual(activeElement, button);
   });
 
   test('_handleInputClick', () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    const inputElement = element.shadowRoot.querySelector('input');
+    const inputElement = queryAndAssert(element, 'input') as HTMLInputElement;
     MockInteractions.tap(inputElement);
     assert.equal(inputElement.selectionStart, 0);
-    assert.equal(inputElement.selectionEnd, element.text.length - 1);
+    assert.equal(inputElement.selectionEnd, element.text!.length! - 1);
   });
 
-  test('hideInput', () => {
+  test('hideInput', async () => {
     // iron-input as parent should never be hidden as copy won't work
     // on nested hidden elements
-    const ironInputElement = element.shadowRoot.querySelector('iron-input');
+    const ironInputElement = queryAndAssert(element, 'iron-input');
     assert.notEqual(getComputedStyle(ironInputElement).display, 'none');
 
-    assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+    const input = queryAndAssert(element, 'input');
+    assert.notEqual(getComputedStyle(input).display, 'none');
     element.hideInput = true;
-    flush();
-    assert.equal(getComputedStyle(element.$.input).display, 'none');
+    await flush();
+    assert.equal(getComputedStyle(input).display, 'none');
   });
 
   test('stop events propagation', () => {
@@ -75,10 +77,8 @@
     divParent.appendChild(element);
     const clickStub = sinon.stub();
     divParent.addEventListener('click', clickStub);
-    element.stopPropagation = true;
-    const copyBtn = element.shadowRoot.querySelector('.copyToClipboard');
+    const copyBtn = queryAndAssert(element, '.copyToClipboard');
     MockInteractions.tap(copyBtn);
     assert.isFalse(clickStub.called);
   });
 });
-
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 8408c78..9f65dd4 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
@@ -15,31 +15,10 @@
  * limitations under the License.
  */
 import {BehaviorSubject} from 'rxjs';
+import {AbortStop, CursorMoveResult, Stop} from '../../../api/core';
 import {ScrollMode} from '../../../constants/constants';
 
 /**
- * Return type for cursor moves, that indicate whether a move was possible.
- */
-export enum CursorMoveResult {
-  /** The cursor was successfully moved. */
-  MOVED,
-  /** There were no stops - the cursor was reset. */
-  NO_STOPS,
-  /**
-   * There was no more matching stop to move to - the cursor was clipped to the
-   * end.
-   */
-  CLIPPED,
-  /** The abort condition would have been fulfilled for the new target. */
-  ABORTED,
-}
-
-/** A sentinel that can be inserted to disallow moving across. */
-export class AbortStop {}
-
-export type Stop = HTMLElement | AbortStop;
-
-/**
  * Type guard and checker to check if a stop can be targeted.
  * Abort stops cannot be targeted.
  */
@@ -108,31 +87,44 @@
   }
 
   /**
-   * Move the cursor forward. Clipped to the ends of the stop list.
+   * Move the cursor forward. Clipped to the end of the stop list.
    *
-   * @param options.filter Will keep going and skip any stops for which this
-   *    condition is not met.
+   * @param options.filter Skips any stops for which filter returns false.
    * @param options.getTargetHeight Optional function to calculate the
    *    height of the target's 'section'. The height of the target itself is
    *    sometimes different, used by the diff cursor.
    * @param options.clipToTop When none of the next indices match, move
    *     back to first instead of to last.
+   * @param options.circular When on last element, you get to first element.
    * @return If a move was performed or why not.
-   * @private
    */
   next(
     options: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(1, options);
   }
 
+  /**
+   * Move the cursor backward. Clipped to the beginning of stop list.
+   *
+   * @param options.filter Skips any stops for which filter returns false.
+   * @param options.getTargetHeight Optional function to calculate the
+   *    height of the target's 'section'. The height of the target itself is
+   *    sometimes different, used by the diff cursor.
+   * @param options.clipToTop When none of the next indices match, move
+   * back to first instead of to last.
+   * @param options.circular When on first element, you get to last element.
+   * @return  If a move was performed or why not.
+   */
   previous(
     options: {
       filter?: (stop: HTMLElement) => boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     return this._moveCursor(-1, options);
@@ -144,72 +136,83 @@
    * The method uses IntersectionObservers API. If browser
    * doesn't support this API the method does nothing
    *
-   * @param condition Optional condition. If a condition
-   * is passed only stops which meet conditions are taken into account.
+   * @param filter Skips any stops for which filter returns false.
    */
-  moveToVisibleArea(condition?: (el: Element) => boolean) {
-    if (!this.stops || !this._isIntersectionObserverSupported()) {
-      return;
+  async moveToVisibleArea(filter?: (el: Element) => boolean) {
+    const centerMostStop = await this.getCenterMostStop(filter);
+    // In most cases the target is visible, so scroll is not
+    // needed. But in rare cases the target can become invisible
+    // at this point (due to some scrolling in window).
+    // To avoid jumps set noScroll options.
+    if (centerMostStop) {
+      this.setCursor(centerMostStop, true);
     }
-    const filteredStops = condition
-      ? this.targetableStops.filter(condition)
-      : this.targetableStops;
-    const dims = this._getWindowDims();
-    const windowCenter = Math.round(dims.innerHeight / 2);
-
-    let closestToTheCenter: HTMLElement | null = null;
-    let minDistanceToCenter: number | null = null;
-    let unobservedCount = filteredStops.length;
-
-    const observer = new IntersectionObserver(entries => {
-      // This callback is called for the first time immediately.
-      // Typically it gets all observed stops at once, but
-      // sometimes can get them in several chunks.
-      entries.forEach(entry => {
-        observer.unobserve(entry.target);
-
-        // In Edge it is recommended to use intersectionRatio instead of
-        // isIntersecting.
-        const isInsideViewport =
-          entry.isIntersecting || entry.intersectionRatio > 0;
-        if (!isInsideViewport) {
-          return;
-        }
-        const center =
-          entry.boundingClientRect.top +
-          Math.round(entry.boundingClientRect.height / 2);
-        const distanceToWindowCenter = Math.abs(center - windowCenter);
-        if (
-          minDistanceToCenter === null ||
-          distanceToWindowCenter < minDistanceToCenter
-        ) {
-          // entry.target comes from the filteredStops array,
-          // hence it is an HTMLElement
-          closestToTheCenter = entry.target as HTMLElement;
-          minDistanceToCenter = distanceToWindowCenter;
-        }
-      });
-      unobservedCount -= entries.length;
-      if (unobservedCount === 0 && closestToTheCenter) {
-        // set cursor when all stops were observed.
-        // In most cases the target is visible, so scroll is not
-        // needed. But in rare cases the target can become invisible
-        // at this point (due to some scrolling in window).
-        // To avoid jumps set noScroll options.
-        this.setCursor(closestToTheCenter, true);
-      }
-    });
-    filteredStops.forEach(stop => {
-      observer.observe(stop);
-    });
   }
 
-  _isIntersectionObserverSupported() {
-    // The copy of this method exists in gr-app-element.js under the
-    // name _isCursorManagerSupportMoveToVisibleLine
-    // If you update this method, you must update gr-app-element.js
-    // as well.
-    return 'IntersectionObserver' in window;
+  private async getCenterMostStop(
+    filter?: (el: Element) => boolean
+  ): Promise<HTMLElement | undefined> {
+    const visibleEntries = await this.getVisibleEntries(filter);
+    const windowCenter = Math.round(window.innerHeight / 2);
+
+    let centerMostStop: HTMLElement | undefined = undefined;
+    let minDistanceToCenter = Number.MAX_VALUE;
+
+    for (const entry of visibleEntries) {
+      // We are just using the entries here, because entry.boundingClientRect
+      // is already computed, but entry.target.getBoundingClientRect() should
+      // actually yield the same result.
+      const center =
+        entry.boundingClientRect.top +
+        Math.round(entry.boundingClientRect.height / 2);
+      const distanceToWindowCenter = Math.abs(center - windowCenter);
+      if (distanceToWindowCenter < minDistanceToCenter) {
+        // entry.target comes from the filteredStops array,
+        // hence it is an HTMLElement
+        centerMostStop = entry.target as HTMLElement;
+        minDistanceToCenter = distanceToWindowCenter;
+      }
+    }
+    return centerMostStop;
+  }
+
+  private async getVisibleEntries(
+    filter?: (el: Element) => boolean
+  ): Promise<IntersectionObserverEntry[]> {
+    if (!this.stops) {
+      return [];
+    }
+    const filteredStops = filter
+      ? this.targetableStops.filter(filter)
+      : this.targetableStops;
+    return new Promise(resolve => {
+      let unobservedCount = filteredStops.length;
+      const visibleEntries: IntersectionObserverEntry[] = [];
+      const observer = new IntersectionObserver(entries => {
+        visibleEntries.push(
+          ...entries
+            // In Edge it is recommended to use intersectionRatio instead of
+            // isIntersecting.
+            .filter(
+              entry => entry.isIntersecting || entry.intersectionRatio > 0
+            )
+        );
+
+        // This callback is called for the first time immediately.
+        // Typically it gets all observed stops at once, but
+        // sometimes can get them in several chunks.
+        for (const entry of entries) {
+          observer.unobserve(entry.target);
+        }
+        unobservedCount -= entries.length;
+        if (unobservedCount === 0) {
+          resolve(visibleEntries);
+        }
+      });
+      for (const stop of filteredStops) {
+        observer.observe(stop);
+      }
+    });
   }
 
   /**
@@ -276,34 +279,18 @@
     }
   }
 
-  /**
-   * Move the cursor forward or backward by delta. Clipped to the beginning or
-   * end of stop list.
-   *
-   * @param delta either -1 or 1.
-   * @param options.abort Will abort moving the cursor when encountering a
-   *    stop for which this condition is met. Will abort even if the stop
-   *    would have been filtered
-   * @param options.filter Will keep going and skip any stops for which this
-   *    condition is not met.
-   * @param options.getTargetHeight Optional function to calculate the
-   * height of the target's 'section'. The height of the target itself is
-   * sometimes different, used by the diff cursor.
-   * @param options.clipToTop When none of the next indices match, move
-   * back to first instead of to last.
-   * @return  If a move was performed or why not.
-   * @private
-   */
   _moveCursor(
     delta: number,
     {
       filter,
       getTargetHeight,
       clipToTop,
+      circular,
     }: {
       filter?: (stop: HTMLElement) => boolean;
       getTargetHeight?: (target: HTMLElement) => number;
       clipToTop?: boolean;
+      circular?: boolean;
     } = {}
   ): CursorMoveResult {
     if (!this.stops.length) {
@@ -326,7 +313,10 @@
         (delta > 0 && newIndex >= this.stops.length) ||
         (delta < 0 && newIndex < 0)
       ) {
-        newIndex = delta < 0 || clipToTop ? 0 : this.stops.length - 1;
+        newIndex =
+          (delta < 0 && !circular) || (delta > 0 && circular) || clipToTop
+            ? 0
+            : this.stops.length - 1;
         newStop = this.stops[newIndex];
         clipped = true;
         break;
@@ -405,17 +395,15 @@
   }
 
   _targetIsVisible(top: number) {
-    const dims = this._getWindowDims();
     return (
       this.scrollMode === ScrollMode.KEEP_VISIBLE &&
-      top > dims.pageYOffset &&
-      top < dims.pageYOffset + dims.innerHeight
+      top > window.pageYOffset &&
+      top < window.pageYOffset + window.innerHeight
     );
   }
 
   _calculateScrollToValue(top: number, target: HTMLElement) {
-    const dims = this._getWindowDims();
-    return top + -dims.innerHeight / 3 + target.offsetHeight / 2;
+    return top + -window.innerHeight / 3 + target.offsetHeight / 2;
   }
 
   _scrollToTarget() {
@@ -423,7 +411,6 @@
       return;
     }
 
-    const dims = this._getWindowDims();
     const top = this._getTop(this.target);
     const bottomIsVisible = this._targetHeight
       ? this._targetIsVisible(top + this._targetHeight)
@@ -435,7 +422,7 @@
       // would get scrolled to is higher up than the current position. This
       // would cause less of the target content to be displayed than is
       // already.
-      if (bottomIsVisible || scrollToValue < dims.scrollY) {
+      if (bottomIsVisible || scrollToValue < window.scrollY) {
         return;
       }
     }
@@ -444,15 +431,6 @@
     // instead of half the inner height feels a bit better otherwise the
     // element appears to be below the center of the window even when it
     // isn't.
-    window.scrollTo(dims.scrollX, scrollToValue);
-  }
-
-  _getWindowDims() {
-    return {
-      scrollX: window.scrollX,
-      scrollY: window.scrollY,
-      innerHeight: window.innerHeight,
-      pageYOffset: window.pageYOffset,
-    };
+    window.scrollTo(window.scrollX, scrollToValue);
   }
 }
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 e51d190..d0bd420 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.js
@@ -18,7 +18,8 @@
 import '../../../test/common-test-setup-karma.js';
 import './gr-cursor-manager.js';
 import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {AbortStop, CursorMoveResult, GrCursorManager} from './gr-cursor-manager.js';
+import {AbortStop, CursorMoveResult} from '../../../api/core.js';
+import {GrCursorManager} from './gr-cursor-manager.js';
 
 const basicTestFixutre = fixtureFromTemplate(html`
     <ul>
@@ -254,6 +255,25 @@
     assert.isTrue(cursor.target.focus.called);
   });
 
+  suite('circular options', () => {
+    const options = {circular: true};
+    setup(() => {
+      cursor.stops = [...list.querySelectorAll('li')];
+    });
+
+    test('previous() on first element goes to last element', () => {
+      cursor.setCursor(list.children[0]);
+      cursor.previous(options);
+      assert.equal(cursor.index, list.children.length - 1);
+    });
+
+    test('next() on last element goes to first element', () => {
+      cursor.setCursor(list.children[list.children.length - 1]);
+      cursor.next(options);
+      assert.equal(cursor.index, 0);
+    });
+  });
+
   suite('_scrollToTarget', () => {
     let scrollStub;
     setup(() => {
@@ -282,12 +302,10 @@
     test('Called when top is visible, bottom is not, scroll is lower', () => {
       const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
           () => visibleStub.callCount === 2);
-      sinon.stub(cursor, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 15,
-        innerHeight: 1000,
-        pageYOffset: 0,
-      });
+      window.scrollX = 123;
+      window.scrollY = 15;
+      window.innerHeight = 1000;
+      window.pageYOffset = 0;
       sinon.stub(cursor, '_calculateScrollToValue').returns(20);
       cursor._scrollToTarget();
       assert.isTrue(scrollStub.called);
@@ -298,12 +316,10 @@
     test('Called when top is visible, bottom not, scroll is higher', () => {
       const visibleStub = sinon.stub(cursor, '_targetIsVisible').callsFake(
           () => visibleStub.callCount === 2);
-      sinon.stub(cursor, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 25,
-        innerHeight: 1000,
-        pageYOffset: 0,
-      });
+      window.scrollX = 123;
+      window.scrollY = 25;
+      window.innerHeight = 1000;
+      window.pageYOffset = 0;
       sinon.stub(cursor, '_calculateScrollToValue').returns(20);
       cursor._scrollToTarget();
       assert.isFalse(scrollStub.called);
@@ -311,12 +327,10 @@
     });
 
     test('_calculateScrollToValue', () => {
-      sinon.stub(cursor, '_getWindowDims').returns({
-        scrollX: 123,
-        scrollY: 25,
-        innerHeight: 300,
-        pageYOffset: 0,
-      });
+      window.scrollX = 123;
+      window.scrollY = 25;
+      window.innerHeight = 300;
+      window.pageYOffset = 0;
       assert.equal(cursor._calculateScrollToValue(1000, {offsetHeight: 10}),
           905);
     });
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 489b489..99f9265 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,11 +14,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-date-formatter_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {property, customElement} from '@polymer/decorators';
+import '../gr-tooltip-content/gr-tooltip-content';
+import {css, html, LitElement} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {
   parseDate,
   fromNow,
@@ -76,13 +74,9 @@
 }
 
 @customElement('gr-date-formatter')
-export class GrDateFormatter extends TooltipMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, notify: true})
-  dateStr: string | null = null;
+export class GrDateFormatter extends LitElement {
+  @property({type: String})
+  dateStr: string | undefined = undefined;
 
   @property({type: Boolean})
   showDateAndTime = false;
@@ -92,30 +86,20 @@
    * native browser tooltip.
    */
   @property({type: Boolean})
-  hasTooltip = false;
+  withTooltip = false;
 
   @property({type: Boolean})
   showYesterday = false;
 
-  /**
-   * The title to be used as the native tooltip or by the tooltip behavior.
-   */
-  @property({
-    type: String,
-    reflectToAttribute: true,
-    computed: '_computeFullDateStr(dateStr, _timeFormat, _dateFormat)',
-  })
-  title = '';
-
   /** @type {?{short: string, full: string}} */
   @property({type: Object})
-  _dateFormat?: DateFormatPair;
+  private dateFormat?: DateFormatPair;
 
   @property({type: String})
-  _timeFormat?: string;
+  private timeFormat?: string;
 
   @property({type: Boolean})
-  _relative = false;
+  private relative = false;
 
   @property({type: Boolean})
   forceRelative = false;
@@ -129,77 +113,110 @@
     super();
   }
 
-  /** @override */
-  connectedCallback() {
+  static override get styles() {
+    return [
+      css`
+        host {
+          color: inherit;
+          display: inline;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.withTooltip) {
+      return this.renderDateString();
+    }
+
+    const fullDateStr = this.computeFullDateStr();
+    if (!fullDateStr) {
+      return this.renderDateString();
+    }
+    return html`
+      <gr-tooltip-content has-tooltip title=${fullDateStr}>
+        ${this.renderDateString()}
+      </gr-tooltip-content>
+    `;
+  }
+
+  private renderDateString() {
+    return html` <span>${this._computeDateStr()}</span>`;
+  }
+
+  override connectedCallback() {
     super.connectedCallback();
     this._loadPreferences();
   }
 
+  // private but used by tests
   _getUtcOffsetString() {
     return utcOffsetString();
   }
 
+  // private but used by tests
   _loadPreferences() {
     return this._getLoggedIn().then(loggedIn => {
       if (!loggedIn) {
-        this._timeFormat = TimeFormats.TIME_24;
-        this._dateFormat = DateFormats.STD;
-        this._relative = this.forceRelative;
+        this.timeFormat = TimeFormats.TIME_24;
+        this.dateFormat = DateFormats.STD;
+        this.relative = this.forceRelative;
         return;
       }
-      return Promise.all([this._loadTimeFormat(), this._loadRelative()]);
+      return Promise.all([this._loadTimeFormat(), this.loadRelative()]);
     });
   }
 
+  // private but used in gr/file-list_test.js
   _loadTimeFormat() {
-    return this._getPreferences().then(preferences => {
+    return this.getPreferences().then(preferences => {
       if (!preferences) {
         throw Error('Preferences is not set');
       }
-      this._decideTimeFormat(preferences.time_format);
-      this._decideDateFormat(preferences.date_format);
+      this.decideTimeFormat(preferences.time_format);
+      this.decideDateFormat(preferences.date_format);
     });
   }
 
-  _decideTimeFormat(timeFormat: TimeFormat) {
+  private decideTimeFormat(timeFormat: TimeFormat) {
     switch (timeFormat) {
       case TimeFormat.HHMM_12:
-        this._timeFormat = TimeFormats.TIME_12;
+        this.timeFormat = TimeFormats.TIME_12;
         break;
       case TimeFormat.HHMM_24:
-        this._timeFormat = TimeFormats.TIME_24;
+        this.timeFormat = TimeFormats.TIME_24;
         break;
       default:
         assertNever(timeFormat, `Invalid time format: ${timeFormat}`);
     }
   }
 
-  _decideDateFormat(dateFormat: DateFormat) {
+  private decideDateFormat(dateFormat: DateFormat) {
     switch (dateFormat) {
       case DateFormat.STD:
-        this._dateFormat = DateFormats.STD;
+        this.dateFormat = DateFormats.STD;
         break;
       case DateFormat.US:
-        this._dateFormat = DateFormats.US;
+        this.dateFormat = DateFormats.US;
         break;
       case DateFormat.ISO:
-        this._dateFormat = DateFormats.ISO;
+        this.dateFormat = DateFormats.ISO;
         break;
       case DateFormat.EURO:
-        this._dateFormat = DateFormats.EURO;
+        this.dateFormat = DateFormats.EURO;
         break;
       case DateFormat.UK:
-        this._dateFormat = DateFormats.UK;
+        this.dateFormat = DateFormats.UK;
         break;
       default:
         assertNever(dateFormat, `Invalid date format: ${dateFormat}`);
     }
   }
 
-  _loadRelative() {
-    return this._getPreferences().then(prefs => {
+  private loadRelative() {
+    return this.getPreferences().then(prefs => {
       // prefs.relative_date_in_change_table is not set when false.
-      this._relative =
+      this.relative =
         this.forceRelative || !!(prefs && prefs.relative_date_in_change_table);
     });
   }
@@ -208,70 +225,60 @@
     return this.restApiService.getLoggedIn();
   }
 
-  _getPreferences() {
+  private getPreferences() {
     return this.restApiService.getPreferences();
   }
 
-  _computeDateStr(
-    dateStr?: Timestamp,
-    timeFormat?: string,
-    dateFormat?: DateFormatPair,
-    relative?: boolean,
-    showDateAndTime?: boolean,
-    showYesterday?: boolean
-  ) {
-    if (!dateStr || !timeFormat || !dateFormat) {
+  // private but used by tests
+  _computeDateStr() {
+    if (!this.dateStr || !this.timeFormat || !this.dateFormat) {
       return '';
     }
-    const date = parseDate(dateStr);
+    const date = parseDate(this.dateStr as Timestamp);
     if (!isValidDate(date)) {
       return '';
     }
-    if (relative) {
+    if (this.relative) {
       return fromNow(date, this.relativeOptionNoAgo);
     }
     const now = new Date();
-    let format = dateFormat.full;
+    let format = this.dateFormat.full;
     if (isWithinDay(now, date)) {
-      format = timeFormat;
-    } else if (showYesterday && wasYesterday(now, date)) {
-      return `Yesterday at ${formatDate(date, timeFormat)}`;
+      format = this.timeFormat;
+    } else if (this.showYesterday && wasYesterday(now, date)) {
+      return `Yesterday at ${formatDate(date, this.timeFormat)}`;
     } else {
       if (isWithinHalfYear(now, date)) {
-        format = dateFormat.short;
+        format = this.dateFormat.short;
       }
-      if (this.showDateAndTime || showDateAndTime) {
-        format = `${format} ${timeFormat}`;
+      if (this.showDateAndTime || this.showDateAndTime) {
+        format = `${format} ${this.timeFormat}`;
       }
     }
     return formatDate(date, format);
   }
 
-  _timeToSecondsFormat(timeFormat: string | undefined) {
-    return timeFormat === TimeFormats.TIME_12
-      ? TimeFormats.TIME_12_WITH_SEC
-      : TimeFormats.TIME_24_WITH_SEC;
-  }
-
-  _computeFullDateStr(
-    dateStr?: Timestamp,
-    timeFormat?: string,
-    dateFormat?: DateFormatPair
-  ) {
+  private computeFullDateStr() {
     // Polymer 2: check for undefined
-    if ([dateStr, timeFormat].includes(undefined) || !dateFormat) {
+    if (
+      [this.dateStr, this.timeFormat].includes(undefined) ||
+      !this.dateFormat
+    ) {
       return undefined;
     }
 
-    if (!dateStr) {
+    if (!this.dateStr) {
       return '';
     }
-    const date = parseDate(dateStr);
+    const date = parseDate(this.dateStr as Timestamp);
     if (!isValidDate(date)) {
       return '';
     }
-    let format = dateFormat.full + ', ';
-    format += this._timeToSecondsFormat(timeFormat);
+    let format = this.dateFormat.full + ', ';
+    format +=
+      this.timeFormat === TimeFormats.TIME_12
+        ? TimeFormats.TIME_12_WITH_SEC
+        : TimeFormats.TIME_24_WITH_SEC;
     return formatDate(date, format) + this._getUtcOffsetString();
   }
 }
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
deleted file mode 100644
index 4808832..0000000
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_html.ts
+++ /dev/null
@@ -1,30 +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>
-    :host {
-      color: inherit;
-      display: inline;
-    }
-  </style>
-  <span>
-    [[_computeDateStr(dateStr, _timeFormat, _dateFormat, _relative,
-    showDateAndTime, showYesterday)]]
-  </span>
-`;
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 9a96c2d..860a7e7 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
@@ -22,14 +22,18 @@
 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>
+<gr-date-formatter withTooltip dateStr="2015-09-24 23:30:17.033000000">
+</gr-date-formatter>
+`);
+
+const lightFixture = fixtureFromTemplate(html`
+<gr-date-formatter dateStr="2015-09-24 23:30:17.033000000"></gr-date-formatter>
 `);
 
 suite('gr-date-formatter tests', () => {
   let element;
 
   setup(() => {
-
   });
 
   /**
@@ -41,7 +45,7 @@
     return d;
   }
 
-  function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
+  async function testDates(nowStr, dateStr, expected, expectedWithDateAndTime,
       expectedTooltip) {
     // Normalize and convert the date to mimic server response.
     dateStr = normalizedDate(dateStr)
@@ -50,13 +54,13 @@
         .slice(0, -1);
     sinon.useFakeTimers(normalizedDate(nowStr).getTime());
     element.dateStr = dateStr;
-    flush();
-    const span = element.shadowRoot
-        .querySelector('span');
+    await element.updateComplete;
+    const span = element.shadowRoot.querySelector('span');
+    const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
     assert.equal(span.textContent.trim(), expected);
-    assert.equal(element.title, expectedTooltip);
+    assert.equal(tooltip.title, expectedTooltip);
     element.showDateAndTime = true;
-    flush();
+    await element.updateComplete;
     assert.equal(span.textContent.trim(), expectedWithDateAndTime);
   }
 
@@ -81,35 +85,37 @@
 
     test('invalid dates are quietly rejected', () => {
       assert.notOk((new Date('foo')).valueOf());
-      assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
+      element.dateStr = 'foo';
+      element.timeFormat = 'h:mm A';
+      assert.equal(element._computeDateStr(), '');
     });
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           'Jul 29, 2015, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           'Jul 28',
           'Jul 28 20:25',
           'Jul 28, 2015, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           'Jun 15',
           'Jun 15 03:25',
           'Jun 15, 2015, 03:25:14');
     });
 
-    test('More than six months', () => {
-      testDates('2015-09-15 20:34:00.000000000',
+    test('More than six months', async () => {
+      await testDates('2015-09-15 20:34:00.000000000',
           '2015-01-15 03:25:00.000000000',
           'Jan 15, 2015',
           'Jan 15, 2015 03:25',
@@ -128,24 +134,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '07/29/15, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '07/28',
           '07/28 20:25',
           '07/28/15, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '06/15',
           '06/15 03:25',
@@ -164,24 +170,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '2015-07-29, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '07-28',
           '07-28 20:25',
           '2015-07-28, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '06-15',
           '06-15 03:25',
@@ -200,24 +206,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '29.07.2015, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '28. Jul',
           '28. Jul 20:25',
           '28.07.2015, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '15. Jun',
           '15. Jun 03:25',
@@ -236,24 +242,24 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '15:34',
           '15:34',
           '29/07/2015, 15:34:14');
     });
 
-    test('Within 24 hours on different days', () => {
-      testDates('2015-07-29 03:34:14.985000000',
+    test('Within 24 hours on different days', async () => {
+      await testDates('2015-07-29 03:34:14.985000000',
           '2015-07-28 20:25:14.985000000',
           '28/07',
           '28/07 20:25',
           '28/07/2015, 20:25:14');
     });
 
-    test('More than 24 hours but less than six months', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('More than 24 hours but less than six months', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-06-15 03:25:14.985000000',
           '15/06',
           '15/06 03:25',
@@ -273,8 +279,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -294,8 +300,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -315,8 +321,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -336,8 +342,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -357,8 +363,8 @@
       })
     );
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '3:34 PM',
           '3:34 PM',
@@ -377,16 +383,16 @@
       return element._loadPreferences();
     }));
 
-    test('Within 24 hours on same day', () => {
-      testDates('2015-07-29 20:34:14.985000000',
+    test('Within 24 hours on same day', async () => {
+      await testDates('2015-07-29 20:34:14.985000000',
           '2015-07-29 15:34:14.985000000',
           '5 hours ago',
           '5 hours ago',
           'Jul 29, 2015, 3:34:14 PM');
     });
 
-    test('More than six months', () => {
-      testDates('2015-09-15 20:34:00.000000000',
+    test('More than six months', async () => {
+      await testDates('2015-09-15 20:34:00.000000000',
           '2015-01-15 03:25:00.000000000',
           '8 months ago',
           '8 months ago',
@@ -405,10 +411,10 @@
     }));
 
     test('Preferences are respected', () => {
-      assert.equal(element._timeFormat, 'h:mm A');
-      assert.equal(element._dateFormat.short, 'MM/DD');
-      assert.equal(element._dateFormat.full, 'MM/DD/YY');
-      assert.isTrue(element._relative);
+      assert.equal(element.timeFormat, 'h:mm A');
+      assert.equal(element.dateFormat.short, 'MM/DD');
+      assert.equal(element.dateFormat.full, 'MM/DD/YY');
+      assert.isTrue(element.relative);
     });
   });
 
@@ -419,10 +425,38 @@
     }));
 
     test('Default preferences are respected', () => {
-      assert.equal(element._timeFormat, 'HH:mm');
-      assert.equal(element._dateFormat.short, 'MMM DD');
-      assert.equal(element._dateFormat.full, 'MMM DD, YYYY');
-      assert.isFalse(element._relative);
+      assert.equal(element.timeFormat, 'HH:mm');
+      assert.equal(element.dateFormat.short, 'MMM DD');
+      assert.equal(element.dateFormat.full, 'MMM DD, YYYY');
+      assert.isFalse(element.relative);
+    });
+  });
+
+  suite('with tooltip', () => {
+    setup(async () => {
+      await stubRestAPI(null);
+      element = basicFixture.instantiate();
+      await element._loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is present', () => {
+      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
+      assert.isOk(tooltip);
+    });
+  });
+
+  suite('without tooltip', () => {
+    setup(async () => {
+      await stubRestAPI(null);
+      element = lightFixture.instantiate();
+      await element._loadPreferences();
+      await element.updateComplete;
+    });
+
+    test('Tooltip is absent', () => {
+      const tooltip = element.shadowRoot.querySelector('gr-tooltip-content');
+      assert.isNotOk(tooltip);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
index 7022a39..97ee39e 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog.ts
@@ -15,11 +15,11 @@
  * limitations under the License.
  */
 import '../gr-button/gr-button';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-dialog_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property, query} from 'lit/decorators';
 import {GrButton} from '../gr-button/gr-button';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {fontStyles} from '../../../styles/gr-font-styles';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,18 +27,8 @@
   }
 }
 
-export interface GrDialog {
-  $: {
-    confirm: GrButton;
-  };
-}
-
 @customElement('gr-dialog')
-export class GrDialog extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrDialog extends LitElement {
   /**
    * Fired when the confirm button is pressed.
    *
@@ -51,38 +41,136 @@
    * @event cancel
    */
 
-  @property({type: String})
+  @query('#confirm')
+  confirmButton?: GrButton;
+
+  @property({type: String, attribute: 'confirm-label'})
   confirmLabel = 'Confirm';
 
   // Supplying an empty cancel label will hide the button completely.
-  @property({type: String})
+  @property({type: String, attribute: 'cancel-label'})
   cancelLabel = 'Cancel';
 
   @property({type: Boolean})
   disabled = false;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'confirm-on-enter'})
   confirmOnEnter = false;
 
-  @property({type: String})
+  @property({type: String, attribute: 'confirm-tooltip'})
   confirmTooltip?: string;
 
-  /** @override */
-  ready() {
-    super.ready();
-    this._ensureAttribute('role', 'dialog');
+  override firstUpdated(changedProperties: PropertyValues) {
+    super.firstUpdated(changedProperties);
+    if (!this.getAttribute('role')) this.setAttribute('role', 'dialog');
   }
 
-  @observe('confirmTooltip')
-  _handleConfirmTooltipUpdate(confirmTooltip?: string) {
-    if (confirmTooltip) {
-      this.$.confirm.setAttribute('has-tooltip', 'true');
-    } else {
-      this.$.confirm.removeAttribute('has-tooltip');
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      css`
+        :host {
+          color: var(--primary-text-color);
+          display: block;
+          max-height: 90vh;
+          overflow: auto;
+        }
+        .container {
+          display: flex;
+          flex-direction: column;
+          max-height: 90vh;
+          padding: var(--spacing-xl);
+        }
+        header {
+          flex-shrink: 0;
+          padding-bottom: var(--spacing-xl);
+        }
+        main {
+          display: flex;
+          flex-shrink: 1;
+          width: 100%;
+          flex: 1;
+          /* IMPORTANT: required for firefox */
+          min-height: 0px;
+        }
+        main .overflow-container {
+          flex: 1;
+          overflow: auto;
+        }
+        footer {
+          display: flex;
+          flex-shrink: 0;
+          justify-content: flex-end;
+          padding-top: var(--spacing-xl);
+        }
+        gr-button {
+          margin-left: var(--spacing-l);
+        }
+        .hidden {
+          display: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // Note that we are using (e: Event) => this._handleKeyDown because the
+    // tests mock out _handleKeydown so the lookup needs to be dynamic, not
+    // bound statically here.
+    return html`
+      <div
+        class="container"
+        @keydown=${(e: KeyboardEvent) => this._handleKeydown(e)}
+      >
+        <header class="heading-3"><slot name="header"></slot></header>
+        <main>
+          <div class="overflow-container">
+            <slot name="main"></slot>
+          </div>
+        </main>
+        <footer>
+          <slot name="footer"></slot>
+          <gr-button
+            id="cancel"
+            class="${this.cancelLabel.length ? '' : 'hidden'}"
+            link
+            @click=${(e: Event) => this.handleCancelTap(e)}
+          >
+            ${this.cancelLabel}
+          </gr-button>
+          <gr-button
+            id="confirm"
+            link
+            primary
+            @click=${(e: Event) => this._handleConfirm(e)}
+            ?disabled=${this.disabled}
+            title=${this.confirmTooltip ?? ''}
+          >
+            ${this.confirmLabel}
+          </gr-button>
+        </footer>
+      </div>
+    `;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('confirmTooltip')) {
+      this.updateTooltip();
     }
   }
 
-  _handleConfirm(e: KeyboardEvent) {
+  private updateTooltip() {
+    const confirmButton = this.confirmButton;
+    if (!confirmButton) return;
+    if (this.confirmTooltip) {
+      confirmButton.setAttribute('has-tooltip', 'true');
+    } else {
+      confirmButton.removeAttribute('has-tooltip');
+    }
+  }
+
+  _handleConfirm(e: Event) {
     if (this.disabled) {
       return;
     }
@@ -97,7 +185,7 @@
     );
   }
 
-  _handleCancelTap(e: MouseEvent) {
+  private handleCancelTap(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     this.dispatchEvent(
@@ -115,10 +203,6 @@
   }
 
   resetFocus() {
-    this.$.confirm.focus();
-  }
-
-  _computeCancelClass(cancelLabel: string) {
-    return cancelLabel.length ? '' : 'hidden';
+    this.confirmButton!.focus();
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts
deleted file mode 100644
index f8ddcfd..0000000
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_html.ts
+++ /dev/null
@@ -1,91 +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">
-    :host {
-      color: var(--primary-text-color);
-      display: block;
-      max-height: 90vh;
-      overflow: auto;
-    }
-    .container {
-      display: flex;
-      flex-direction: column;
-      max-height: 90vh;
-      padding: var(--spacing-xl);
-    }
-    header {
-      flex-shrink: 0;
-      padding-bottom: var(--spacing-xl);
-    }
-    main {
-      display: flex;
-      flex-shrink: 1;
-      width: 100%;
-      flex: 1;
-      /* IMPORTANT: required for firefox */
-      min-height: 0px;
-    }
-    main .overflow-container {
-      flex: 1;
-      overflow: auto;
-    }
-    footer {
-      display: flex;
-      flex-shrink: 0;
-      justify-content: flex-end;
-      padding-top: var(--spacing-xl);
-    }
-    gr-button {
-      margin-left: var(--spacing-l);
-    }
-    .hidden {
-      display: none;
-    }
-  </style>
-  <div class="container" on-keydown="_handleKeydown">
-    <header class="heading-3"><slot name="header"></slot></header>
-    <main>
-      <div class="overflow-container">
-        <slot name="main"></slot>
-      </div>
-    </main>
-    <footer>
-      <slot name="footer"></slot>
-      <gr-button
-        id="cancel"
-        class$="[[_computeCancelClass(cancelLabel)]]"
-        link=""
-        on-click="_handleCancelTap"
-      >
-        [[cancelLabel]]
-      </gr-button>
-      <gr-button
-        id="confirm"
-        link=""
-        primary=""
-        on-click="_handleConfirm"
-        disabled="[[disabled]]"
-        title$="[[confirmTooltip]]"
-      >
-        [[confirmLabel]]
-      </gr-button>
-    </footer>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
index e7b7130..171fc6c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dialog/gr-dialog_test.ts
@@ -17,6 +17,7 @@
 
 import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
 import '../../../test/common-test-setup-karma';
+import './gr-dialog';
 import {GrDialog} from './gr-dialog';
 import {isHidden, queryAndAssert} from '../../../test/test-utils';
 
@@ -25,8 +26,9 @@
 suite('gr-dialog tests', () => {
   let element: GrDialog;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
   test('events', () => {
@@ -42,56 +44,59 @@
     assert.equal(cancel.callCount, 1);
   });
 
-  test('confirmOnEnter', () => {
+  test('confirmOnEnter', async () => {
     element.confirmOnEnter = false;
+    await element.updateComplete;
     const handleConfirmStub = sinon.stub(element, '_handleConfirm');
     const handleKeydownSpy = sinon.spy(element, '_handleKeydown');
-    MockInteractions.pressAndReleaseKeyOn(
+    MockInteractions.keyDownOn(
       queryAndAssert(element, 'main'),
       13,
       null,
       'enter'
     );
-    flush();
+    await flush();
 
     assert.isTrue(handleKeydownSpy.called);
     assert.isFalse(handleConfirmStub.called);
 
     element.confirmOnEnter = true;
-    MockInteractions.pressAndReleaseKeyOn(
+    await element.updateComplete;
+
+    MockInteractions.keyDownOn(
       queryAndAssert(element, 'main'),
       13,
       null,
       'enter'
     );
-    flush();
+    await flush();
 
     assert.isTrue(handleConfirmStub.called);
   });
 
   test('resetFocus', () => {
-    const focusStub = sinon.stub(element.$.confirm, 'focus');
+    const focusStub = sinon.stub(element.confirmButton!, 'focus');
     element.resetFocus();
     assert.isTrue(focusStub.calledOnce);
   });
 
   suite('tooltip', () => {
     test('tooltip not added by default', () => {
-      assert.isNull(element.$.confirm.getAttribute('has-tooltip'));
+      assert.isNull(element.confirmButton!.getAttribute('has-tooltip'));
     });
 
-    test('tooltip added if confirm tooltip is passed', () => {
+    test('tooltip added if confirm tooltip is passed', async () => {
       element.confirmTooltip = 'confirm tooltip';
-      flush();
-      assert(element.$.confirm.getAttribute('has-tooltip'));
+      await element.updateComplete;
+      assert(element.confirmButton!.getAttribute('has-tooltip'));
     });
   });
 
-  test('empty cancel label hides cancel btn', () => {
+  test('empty cancel label hides cancel btn', async () => {
     const cancelButton = queryAndAssert(element, '#cancel');
     assert.isFalse(isHidden(cancelButton));
     element.cancelLabel = '';
-    flush();
+    await element.updateComplete;
 
     assert.isTrue(isHidden(cancelButton));
   });
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 70a7bf3..e560773 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
@@ -21,18 +21,23 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-diff-preferences_html';
 import {customElement, property} from '@polymer/decorators';
-import {DiffPreferencesInfo} from '../../../types/diff';
+import {DiffPreferencesInfo, IgnoreWhitespaceType} from '../../../types/diff';
 import {GrSelect} from '../gr-select/gr-select';
 import {appContext} from '../../../services/app-context';
 
 export interface GrDiffPreferences {
   $: {
+    contextLineSelect: HTMLInputElement;
+    columnsInput: HTMLInputElement;
+    tabSizeInput: HTMLInputElement;
+    fontSizeInput: HTMLInputElement;
     lineWrappingInput: HTMLInputElement;
     showTabsInput: HTMLInputElement;
     showTrailingWhitespaceInput: HTMLInputElement;
     automaticReviewInput: HTMLInputElement;
     syntaxHighlightInput: HTMLInputElement;
     contextSelect: GrSelect;
+    ignoreWhiteSpace: HTMLInputElement;
   };
   save(): Promise<void>;
 }
@@ -61,11 +66,31 @@
     this.hasUnsavedChanges = true;
   }
 
+  _handleDiffContextChanged() {
+    this.set('diffPrefs.context', Number(this.$.contextLineSelect.value));
+    this._handleDiffPrefsChanged();
+  }
+
   _handleLineWrappingTap() {
     this.set('diffPrefs.line_wrapping', this.$.lineWrappingInput.checked);
     this._handleDiffPrefsChanged();
   }
 
+  _handleDiffLineLengthChanged() {
+    this.set('diffPrefs.line_length', Number(this.$.columnsInput.value));
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleDiffTabSizeChanged() {
+    this.set('diffPrefs.tab_size', Number(this.$.tabSizeInput.value));
+    this._handleDiffPrefsChanged();
+  }
+
+  _handleDiffFontSizeChanged() {
+    this.set('diffPrefs.font_size', Number(this.$.fontSizeInput.value));
+    this._handleDiffPrefsChanged();
+  }
+
   _handleShowTabsTap() {
     this.set('diffPrefs.show_tabs', this.$.showTabsInput.checked);
     this._handleDiffPrefsChanged();
@@ -92,6 +117,14 @@
     this._handleDiffPrefsChanged();
   }
 
+  _handleDiffIgnoreWhitespaceChanged() {
+    this.set(
+      'diffPrefs.ignore_whitespace',
+      this.$.ignoreWhiteSpace.value as IgnoreWhitespaceType
+    );
+    this._handleDiffPrefsChanged();
+  }
+
   save() {
     if (!this.diffPrefs)
       return Promise.reject(new Error('Missing diff preferences'));
@@ -99,6 +132,27 @@
       this.hasUnsavedChanges = false;
     });
   }
+
+  /**
+   * bind-value has type string so we have to convert
+   * anything inputed to string.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToString(key?: number | IgnoreWhitespaceType) {
+    return key !== undefined ? String(key) : '';
+  }
+
+  /**
+   * input 'checked' does not allow undefined,
+   * so we make sure the value is boolean
+   * by returning false if undefined.
+   *
+   * This is so typescript checker doesn't fail.
+   */
+  _convertToBoolean(key?: boolean) {
+    return key !== undefined ? key : false;
+  }
 }
 
 declare global {
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 ed3d695..51867c8 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
@@ -27,12 +27,12 @@
     <section>
       <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"
-          >
+        <gr-select
+          id="contextSelect"
+          bind-value="[[_convertToString(diffPrefs.context)]]"
+          on-change="_handleDiffContextChanged"
+        >
+          <select id="contextLineSelect">
             <option value="3">3 lines</option>
             <option value="10">10 lines</option>
             <option value="25">25 lines</option>
@@ -50,7 +50,7 @@
         <input
           id="lineWrappingInput"
           type="checkbox"
-          checked="[[diffPrefs.line_wrapping]]"
+          checked="[[_convertToBoolean(diffPrefs.line_wrapping)]]"
           on-change="_handleLineWrappingTap"
         />
       </span>
@@ -59,23 +59,11 @@
       <label for="columnsInput" class="title">Diff width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.line_length}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
+          bind-value="[[_convertToString(diffPrefs.line_length)]]"
+          on-change="_handleDiffLineLengthChanged"
         >
-          <input
-            is="iron-input"
-            type="number"
-            id="columnsInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.line_length}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
+          <input id="columnsInput" type="number" />
         </iron-input>
       </span>
     </section>
@@ -83,23 +71,11 @@
       <label for="tabSizeInput" class="title">Tab width</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.tab_size}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
+          bind-value="[[_convertToString(diffPrefs.tab_size)]]"
+          on-change="_handleDiffTabSizeChanged"
         >
-          <input
-            is="iron-input"
-            type="number"
-            id="tabSizeInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.tab_size}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
+          <input id="tabSizeInput" type="number" />
         </iron-input>
       </span>
     </section>
@@ -107,23 +83,11 @@
       <label for="fontSizeInput" class="title">Font size</label>
       <span class="value">
         <iron-input
-          type="number"
-          prevent-invalid-input=""
           allowed-pattern="[0-9]"
-          bind-value="{{diffPrefs.font_size}}"
-          on-keypress="_handleDiffPrefsChanged"
-          on-change="_handleDiffPrefsChanged"
+          bind-value="[[_convertToString(diffPrefs.font_size)]]"
+          on-change="_handleDiffFontSizeChanged"
         >
-          <input
-            is="iron-input"
-            type="number"
-            id="fontSizeInput"
-            prevent-invalid-input=""
-            allowed-pattern="[0-9]"
-            bind-value="{{diffPrefs.font_size}}"
-            on-keypress="_handleDiffPrefsChanged"
-            on-change="_handleDiffPrefsChanged"
-          />
+          <input id="fontSizeInput" type="number" />
         </iron-input>
       </span>
     </section>
@@ -133,7 +97,7 @@
         <input
           id="showTabsInput"
           type="checkbox"
-          checked="[[diffPrefs.show_tabs]]"
+          checked="[[_convertToBoolean(diffPrefs.show_tabs)]]"
           on-change="_handleShowTabsTap"
         />
       </span>
@@ -146,7 +110,7 @@
         <input
           id="showTrailingWhitespaceInput"
           type="checkbox"
-          checked="[[diffPrefs.show_whitespace_errors]]"
+          checked="[[_convertToBoolean(diffPrefs.show_whitespace_errors)]]"
           on-change="_handleShowTrailingWhitespaceTap"
         />
       </span>
@@ -159,7 +123,7 @@
         <input
           id="syntaxHighlightInput"
           type="checkbox"
-          checked="[[diffPrefs.syntax_highlighting]]"
+          checked="[[_convertToBoolean(diffPrefs.syntax_highlighting)]]"
           on-change="_handleSyntaxHighlightTap"
         />
       </span>
@@ -172,7 +136,7 @@
         <input
           id="automaticReviewInput"
           type="checkbox"
-          checked="[[!diffPrefs.manual_review]]"
+          checked="[[!_convertToBoolean(diffPrefs.manual_review)]]"
           on-change="_handleAutomaticReviewTap"
         />
       </span>
@@ -181,12 +145,11 @@
       <div class="pref">
         <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"
-            >
+          <gr-select
+            bind-value="[[_convertToString(diffPrefs.ignore_whitespace)]]"
+            on-change="_handleDiffIgnoreWhitespaceChanged"
+          >
+            <select id="ignoreWhiteSpace">
               <option value="IGNORE_NONE">None</option>
               <option value="IGNORE_TRAILING">Trailing</option>
               <option value="IGNORE_LEADING_AND_TRAILING">
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
deleted file mode 100644
index 716ef2f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.js
+++ /dev/null
@@ -1,105 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-diff-preferences.js';
-import {stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-diff-preferences');
-
-suite('gr-diff-preferences tests', () => {
-  let element;
-
-  let diffPreferences;
-
-  function valueOf(title, fieldsetid) {
-    const sections = element.$[fieldsetid].querySelectorAll('section');
-    let titleEl;
-    for (let i = 0; i < sections.length; i++) {
-      titleEl = sections[i].querySelector('.title');
-      if (titleEl.textContent.trim() === title) {
-        return sections[i].querySelector('.value');
-      }
-    }
-  }
-
-  setup(() => {
-    diffPreferences = {
-      context: 10,
-      line_wrapping: false,
-      line_length: 100,
-      tab_size: 8,
-      font_size: 12,
-      show_tabs: true,
-      show_whitespace_errors: true,
-      syntax_highlighting: true,
-      manual_review: false,
-      ignore_whitespace: 'IGNORE_NONE',
-    };
-
-    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
-
-    element = basicFixture.instantiate();
-
-    return element.loadData();
-  });
-
-  test('renders', () => {
-    // Rendered with the expected preferences selected.
-    assert.equal(valueOf('Context', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.context);
-    assert.equal(valueOf('Fit to screen', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.line_wrapping);
-    assert.equal(valueOf('Diff width', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.line_length);
-    assert.equal(valueOf('Tab width', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.tab_size);
-    assert.equal(valueOf('Font size', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.font_size);
-    assert.equal(valueOf('Show tabs', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.show_tabs);
-    assert.equal(valueOf('Show trailing whitespace', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.show_whitespace_errors);
-    assert.equal(valueOf('Syntax highlighting', 'diffPreferences')
-        .firstElementChild.checked, diffPreferences.syntax_highlighting);
-    assert.equal(
-        valueOf('Automatically mark viewed files reviewed', 'diffPreferences')
-            .firstElementChild.checked, !diffPreferences.manual_review);
-    assert.equal(valueOf('Ignore Whitespace', 'diffPreferences')
-        .firstElementChild.bindValue, diffPreferences.ignore_whitespace);
-
-    assert.isFalse(element.hasUnsavedChanges);
-  });
-
-  test('save changes', () => {
-    stubRestApi('saveDiffPreferences')
-        .returns(Promise.resolve());
-    const showTrailingWhitespaceCheckbox =
-        valueOf('Show trailing whitespace', 'diffPreferences')
-            .firstElementChild;
-    showTrailingWhitespaceCheckbox.checked = false;
-    element._handleShowTrailingWhitespaceTap();
-
-    assert.isTrue(element.hasUnsavedChanges);
-
-    // Save the change.
-    return element.save().then(() => {
-      assert.isFalse(element.hasUnsavedChanges);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
new file mode 100644
index 0000000..6c1404e
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-diff-preferences/gr-diff-preferences_test.ts
@@ -0,0 +1,134 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-diff-preferences';
+import {GrDiffPreferences} from './gr-diff-preferences';
+import {stubRestApi} from '../../../test/test-utils';
+import {DiffPreferencesInfo} from '../../../types/diff';
+import {createDefaultDiffPrefs} from '../../../constants/constants';
+import {IronInputElement} from '@polymer/iron-input';
+import {GrSelect} from '../gr-select/gr-select';
+
+const basicFixture = fixtureFromElement('gr-diff-preferences');
+
+suite('gr-diff-preferences tests', () => {
+  let element: GrDiffPreferences;
+
+  let diffPreferences: DiffPreferencesInfo;
+
+  function valueOf(title: string, id: string) {
+    const sections = element.root?.querySelectorAll(`#${id} section`) ?? [];
+    let titleEl;
+    for (let i = 0; i < sections.length; i++) {
+      titleEl = sections[i].querySelector('.title');
+      if (titleEl?.textContent?.trim() === title) {
+        const el = sections[i].querySelector('.value');
+        if (el) return el;
+      }
+    }
+    assert.fail(`element with title ${title} not found`);
+  }
+
+  setup(async () => {
+    diffPreferences = createDefaultDiffPrefs();
+
+    stubRestApi('getDiffPreferences').returns(Promise.resolve(diffPreferences));
+
+    element = basicFixture.instantiate();
+
+    await element.loadData();
+    await flush();
+  });
+
+  test('renders', () => {
+    // Rendered with the expected preferences selected.
+    const contextInput = valueOf('Context', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(contextInput.bindValue, `${diffPreferences.context}`);
+
+    const lineWrappingInput = valueOf('Fit to screen', 'diffPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(lineWrappingInput.checked, diffPreferences.line_wrapping);
+
+    const lineLengthInput = valueOf('Diff width', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(lineLengthInput.bindValue, `${diffPreferences.line_length}`);
+
+    const tabSizeInput = valueOf('Tab width', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(tabSizeInput.bindValue, `${diffPreferences.tab_size}`);
+
+    const fontSizeInput = valueOf('Font size', 'diffPreferences')
+      .firstElementChild as IronInputElement;
+    assert.equal(fontSizeInput.bindValue, `${diffPreferences.font_size}`);
+
+    const showTabsInput = valueOf('Show tabs', 'diffPreferences')
+      .firstElementChild as HTMLInputElement;
+    assert.equal(showTabsInput.checked, diffPreferences.show_tabs);
+
+    const showWhitespaceErrorsInput = valueOf(
+      'Show trailing whitespace',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    assert.equal(
+      showWhitespaceErrorsInput.checked,
+      diffPreferences.show_whitespace_errors
+    );
+
+    const syntaxHighlightingInput = valueOf(
+      'Syntax highlighting',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    assert.equal(
+      syntaxHighlightingInput.checked,
+      diffPreferences.syntax_highlighting
+    );
+
+    const manualReviewInput = valueOf(
+      'Automatically mark viewed files reviewed',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    assert.equal(manualReviewInput.checked, !diffPreferences.manual_review);
+
+    const ignoreWhitespaceInput = valueOf(
+      'Ignore Whitespace',
+      'diffPreferences'
+    ).firstElementChild as GrSelect;
+    assert.equal(
+      ignoreWhitespaceInput.bindValue,
+      diffPreferences.ignore_whitespace
+    );
+
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+
+  test('save changes', async () => {
+    const showTrailingWhitespaceCheckbox = valueOf(
+      'Show trailing whitespace',
+      'diffPreferences'
+    ).firstElementChild as HTMLInputElement;
+    showTrailingWhitespaceCheckbox.checked = false;
+    element._handleShowTrailingWhitespaceTap();
+
+    assert.isTrue(element.hasUnsavedChanges);
+
+    // Save the change.
+    await element.save();
+    assert.isFalse(element.hasUnsavedChanges);
+  });
+});
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 19cbb22..8322682 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
@@ -14,16 +14,25 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '@polymer/paper-tabs/paper-tab';
 import '@polymer/paper-tabs/paper-tabs';
 import '../gr-shell-command/gr-shell-command';
 import '../../../styles/shared-styles';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-download-commands_html';
-import {customElement, property, observe} from '@polymer/decorators';
+import {customElement, property} from '@polymer/decorators';
 import {PaperTabsElement} from '@polymer/paper-tabs/paper-tabs';
 import {appContext} from '../../../services/app-context';
+import {queryAndAssert} from '../../../utils/common-util';
+import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
+import {preferences$} from '../../../services/user/user-model';
+import {takeUntil} from 'rxjs/operators';
+import {Subject} from 'rxjs';
 
 declare global {
+  interface HTMLElementEventMap {
+    'selected-changed': CustomEvent<{value: number}>;
+  }
   interface HTMLElementTagNameMap {
     'gr-download-commands': GrDownloadCommands;
   }
@@ -59,31 +68,21 @@
   @property({type: String, notify: true})
   selectedScheme?: string;
 
+  @property({type: Boolean})
+  showKeyboardShortcutTooltips = false;
+
   private readonly restApiService = appContext.restApiService;
 
-  /** @override */
-  connectedCallback() {
+  private readonly userService = appContext.userService;
+
+  disconnected$ = new Subject();
+
+  override connectedCallback() {
     super.connectedCallback();
     this._getLoggedIn().then(loggedIn => {
       this._loggedIn = loggedIn;
     });
-  }
-
-  focusOnCopy() {
-    // TODO(TS): remove ! assertion later
-    this.shadowRoot!.querySelector('gr-shell-command')!.focusOnCopy();
-  }
-
-  _getLoggedIn() {
-    return this.restApiService.getLoggedIn();
-  }
-
-  @observe('_loggedIn')
-  _loggedInChanged(loggedIn: boolean) {
-    if (!loggedIn) {
-      return;
-    }
-    return this.restApiService.getPreferences().then(prefs => {
+    preferences$.pipe(takeUntil(this.disconnected$)).subscribe(prefs => {
       if (prefs?.download_scheme) {
         // Note (issue 5180): normalize the download scheme with lower-case.
         this.selectedScheme = prefs.download_scheme.toLowerCase();
@@ -91,12 +90,25 @@
     });
   }
 
+  override disconnectedCallback() {
+    this.disconnected$.next();
+    super.disconnectedCallback();
+  }
+
+  focusOnCopy() {
+    queryAndAssert<GrShellCommand>(this, 'gr-shell-command').focusOnCopy();
+  }
+
+  _getLoggedIn() {
+    return this.restApiService.getLoggedIn();
+  }
+
   _handleTabChange(e: CustomEvent<{value: number}>) {
     const scheme = this.schemes[e.detail.value];
     if (scheme && scheme !== this.selectedScheme) {
       this.set('selectedScheme', scheme);
       if (this._loggedIn) {
-        this.restApiService.savePreferences({
+        this.userService.updatePreferences({
           download_scheme: this.selectedScheme,
         });
       }
@@ -111,6 +123,12 @@
     return schemes.length > 1 ? '' : 'hidden';
   }
 
+  _computeTooltip(showKeyboardShortcutTooltips: boolean, index: number) {
+    return index <= 4 && showKeyboardShortcutTooltips
+      ? `Keyboard shortcut: ${index + 1}`
+      : '';
+  }
+
   // TODO: maybe unify with strToClassName from dom-util
   _computeClass(title: string) {
     // Only retain [a-z] chars, so "Cherry Pick" becomes "cherrypick".
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 63e1cba..5a75c13 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
@@ -63,11 +63,12 @@
     </paper-tabs>
   </div>
   <div class="commands" hidden$="[[!schemes.length]]" hidden="">
-    <template is="dom-repeat" items="[[commands]]" as="command">
+    <template is="dom-repeat" items="[[commands]]" as="command" indexAs="index">
       <gr-shell-command
         class$="[[_computeClass(command.title)]]"
         label="[[command.title]]"
         command="[[command.command]]"
+        tooltip="[[_computeTooltip(showKeyboardShortcutTooltips, index)]]"
       ></gr-shell-command>
     </template>
   </div>
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
deleted file mode 100644
index 6427c6b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.js
+++ /dev/null
@@ -1,125 +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 './gr-download-commands.js';
-import {isHidden, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-download-commands');
-
-suite('gr-download-commands', () => {
-  let element;
-
-  const SCHEMES = ['http', 'repo', 'ssh'];
-  const COMMANDS = [{
-    title: 'Checkout',
-    command: `git fetch http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git checkout FETCH_HEAD`,
-  }, {
-    title: 'Cherry Pick',
-    command: `git fetch http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
-  }, {
-    title: 'Format Patch',
-    command: `git fetch http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
-  }, {
-    title: 'Pull',
-    command: `git pull http://andybons@localhost:8080/a/test-project
-        refs/changes/05/5/1`,
-  }];
-  const SELECTED_SCHEME = 'http';
-
-  setup(() => {
-
-  });
-
-  suite('unauthenticated', () => {
-    setup(async () => {
-      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-      element = basicFixture.instantiate();
-      element.schemes = SCHEMES;
-      element.commands = COMMANDS;
-      element.selectedScheme = SELECTED_SCHEME;
-      await flush();
-    });
-
-    test('focusOnCopy', () => {
-      const focusStub = sinon.stub(element.shadowRoot
-          .querySelector('gr-shell-command'),
-      'focusOnCopy');
-      element.focusOnCopy();
-      assert.isTrue(focusStub.called);
-    });
-
-    test('element visibility', () => {
-      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')));
-    });
-
-    test('tab selection', () => {
-      assert.equal(element.$.downloadTabs.selected, '0');
-      MockInteractions.tap(element.shadowRoot
-          .querySelector('[data-scheme="ssh"]'));
-      flush();
-      assert.equal(element.selectedScheme, 'ssh');
-      assert.equal(element.$.downloadTabs.selected, '2');
-    });
-
-    test('loads scheme from preferences', async () => {
-      const stub = stubRestApi('getPreferences').returns(
-          Promise.resolve({download_scheme: 'repo'}));
-      element._loggedIn = true;
-      await flush();
-      assert.isTrue(stub.called);
-      await stub.lastCall.returnValue;
-      assert.equal(element.selectedScheme, 'repo');
-    });
-
-    test('normalize scheme from preferences', async () => {
-      const stub = stubRestApi('getPreferences').returns(
-          Promise.resolve({download_scheme: 'REPO'}));
-      element._loggedIn = true;
-      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 = stubRestApi('savePreferences').returns(
-          Promise.resolve());
-
-      flush();
-
-      const repoTab = element.shadowRoot
-          .querySelector('paper-tab[data-scheme="repo"]');
-
-      MockInteractions.tap(repoTab);
-
-      assert.isTrue(savePrefsStub.called);
-      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-          repoTab.getAttribute('data-scheme'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
new file mode 100644
index 0000000..ef712ac
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.ts
@@ -0,0 +1,136 @@
+/**
+ * @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';
+import './gr-download-commands';
+import {GrDownloadCommands} from './gr-download-commands';
+import {isHidden, queryAndAssert, stubRestApi} from '../../../test/test-utils';
+import {updatePreferences} from '../../../services/user/user-model';
+import {createPreferences} from '../../../test/test-data-generators';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrShellCommand} from '../gr-shell-command/gr-shell-command';
+import {createDefaultPreferences} from '../../../constants/constants';
+
+const basicFixture = fixtureFromElement('gr-download-commands');
+
+suite('gr-download-commands', () => {
+  let element: GrDownloadCommands;
+
+  const SCHEMES = ['http', 'repo', 'ssh'];
+  const COMMANDS = [
+    {
+      title: 'Checkout',
+      command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git checkout FETCH_HEAD`,
+    },
+    {
+      title: 'Cherry Pick',
+      command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
+    },
+    {
+      title: 'Format Patch',
+      command: `git fetch http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
+    },
+    {
+      title: 'Pull',
+      command: `git pull http://andybons@localhost:8080/a/test-project
+        refs/changes/05/5/1`,
+    },
+  ];
+  const SELECTED_SCHEME = 'http';
+
+  setup(() => {});
+
+  suite('unauthenticated', () => {
+    setup(async () => {
+      stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+      element = basicFixture.instantiate();
+      element.schemes = SCHEMES;
+      element.commands = COMMANDS;
+      element.selectedScheme = SELECTED_SCHEME;
+      await flush();
+    });
+
+    test('focusOnCopy', () => {
+      const focusStub = sinon.stub(
+        queryAndAssert<GrShellCommand>(element, 'gr-shell-command'),
+        'focusOnCopy'
+      );
+      element.focusOnCopy();
+      assert.isTrue(focusStub.called);
+    });
+
+    test('element visibility', () => {
+      assert.isFalse(isHidden(queryAndAssert(element, 'paper-tabs')));
+      assert.isFalse(isHidden(queryAndAssert(element, '.commands')));
+
+      element.schemes = [];
+      assert.isTrue(isHidden(queryAndAssert(element, 'paper-tabs')));
+      assert.isTrue(isHidden(queryAndAssert(element, '.commands')));
+    });
+
+    test('tab selection', () => {
+      assert.equal(element.$.downloadTabs.selected, '0');
+      MockInteractions.tap(queryAndAssert(element, '[data-scheme="ssh"]'));
+      flush();
+      assert.equal(element.selectedScheme, 'ssh');
+      assert.equal(element.$.downloadTabs.selected, '2');
+    });
+
+    test('saves scheme to preferences', () => {
+      element._loggedIn = true;
+      const savePrefsStub = stubRestApi('savePreferences').returns(
+        Promise.resolve(createDefaultPreferences())
+      );
+
+      flush();
+
+      const repoTab = queryAndAssert(element, 'paper-tab[data-scheme="repo"]');
+
+      MockInteractions.tap(repoTab);
+
+      assert.isTrue(savePrefsStub.called);
+      assert.equal(
+        savePrefsStub.lastCall.args[0].download_scheme,
+        repoTab.getAttribute('data-scheme')
+      );
+    });
+  });
+  suite('authenticated', () => {
+    test('loads scheme from preferences', async () => {
+      updatePreferences({
+        ...createPreferences(),
+        download_scheme: 'repo',
+      });
+      const element = basicFixture.instantiate();
+      await flush();
+      assert.equal(element.selectedScheme, 'repo');
+    });
+
+    test('normalize scheme from preferences', async () => {
+      updatePreferences({
+        ...createPreferences(),
+        download_scheme: 'REPO',
+      });
+      const element = basicFixture.instantiate();
+      await flush();
+      assert.equal(element.selectedScheme, 'repo');
+    });
+  });
+});
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 ef46cec..6180f35 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
@@ -88,7 +88,7 @@
   disabled = false;
 
   @property({type: String, notify: true})
-  value?: string;
+  value: string | number = '';
 
   @property({type: Boolean})
   showCopyForTriggerText = false;
@@ -122,6 +122,10 @@
     return item.mobileText ? item.mobileText : item.text;
   }
 
+  computeStringValue(val: string | number) {
+    return String(val);
+  }
+
   @observe('value', 'items')
   _handleValueChange(value?: string, items?: DropdownItem[]) {
     if (!value || !items) {
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 c163924..3875871 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
@@ -78,9 +78,8 @@
       width: 100%;
     }
     gr-button {
-      --gr-button: {
-        @apply --trigger-style;
-      }
+      font-family: var(--trigger-style-font-family);
+      --gr-button-text-color: var(--trigger-style-text-color);
     }
     gr-date-formatter {
       color: var(--deemphasized-text-color);
@@ -123,11 +122,12 @@
     class="dropdown-trigger"
     on-click="_showDropdownTapHandler"
     slot="dropdown-trigger"
+    no-uppercase
   >
     <span id="triggerText">[[text]]</span>
     <gr-copy-clipboard
       hidden="[[!showCopyForTriggerText]]"
-      hide-input=""
+      hideInput=""
       text="[[text]]"
     ></gr-copy-clipboard>
   </gr-button>
@@ -173,7 +173,10 @@
   <gr-select bind-value="{{value}}">
     <select>
       <template is="dom-repeat" items="[[items]]">
-        <option disabled$="[[item.disabled]]" value="[[item.value]]">
+        <option
+          disabled$="[[item.disabled]]"
+          value="[[computeStringValue(item.value)]]"
+        >
           [[_computeMobileText(item)]]
         </option>
       </template>
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 a4a6fe3..2b56de6 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.ts
@@ -16,6 +16,7 @@
  */
 import '@polymer/iron-dropdown/iron-dropdown';
 import '../gr-button/gr-button';
+import {GrButton} from '../gr-button/gr-button';
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../gr-tooltip-content/gr-tooltip-content';
 import '../../../styles/shared-styles';
@@ -23,15 +24,18 @@
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-dropdown_html';
 import {getBaseUrl} from '../../../utils/url-util';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
 import {GrCursorManager} from '../gr-cursor-manager/gr-cursor-manager';
 import {property, customElement, observe} from '@polymer/decorators';
+import {addShortcut, Key} from '../../../utils/dom-util';
 
 const REL_NOOPENER = 'noopener';
 const REL_EXTERNAL = 'external';
 
 declare global {
+  interface HTMLElementEventMap {
+    'opened-changed': CustomEvent;
+  }
   interface HTMLElementTagNameMap {
     'gr-dropdown': GrDropdown;
   }
@@ -40,6 +44,7 @@
 export interface GrDropdown {
   $: {
     dropdown: IronDropdownElement;
+    trigger: GrButton;
   };
 }
 
@@ -57,13 +62,13 @@
   base: string[];
 }
 
-interface Content {
+export interface DropdownContent {
   text: string;
   bold?: boolean;
 }
 
 @customElement('gr-dropdown')
-export class GrDropdown extends KeyboardShortcutMixin(PolymerElement) {
+export class GrDropdown extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -84,10 +89,10 @@
   items?: DropdownLink[];
 
   @property({type: Boolean})
-  downArrow?: boolean;
+  downArrow = false;
 
   @property({type: Array})
-  topContent?: Content[];
+  topContent?: DropdownContent[];
 
   @property({type: String})
   horizontalAlign = 'left';
@@ -113,16 +118,11 @@
   @property({type: Array})
   disabledIds: string[] = [];
 
-  get keyBindings() {
-    return {
-      down: '_handleDown',
-      'enter space': '_handleEnter',
-      tab: '_handleTab',
-      up: '_handleUp',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
-  private cursor = new GrCursorManager();
+  // Used within the tests so needs to be non-private.
+  cursor = new GrCursorManager();
 
   constructor() {
     super();
@@ -130,16 +130,36 @@
     this.cursor.focusOnMove = true;
   }
 
-  /** @override */
-  disconnectedCallback() {
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this._handleUp(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDown(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this._handleTab(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.SPACE}, e => this._handleEnter(e))
+    );
+  }
+
+  override disconnectedCallback() {
     this.cursor.unsetCursor();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
     super.disconnectedCallback();
   }
 
   /**
    * Handle the up key.
    */
-  _handleUp(e: MouseEvent) {
+  _handleUp(e: Event) {
     if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
@@ -152,7 +172,7 @@
   /**
    * Handle the down key.
    */
-  _handleDown(e: MouseEvent) {
+  _handleDown(e: Event) {
     if (this.$.dropdown.opened) {
       e.preventDefault();
       e.stopPropagation();
@@ -165,7 +185,7 @@
   /**
    * Handle the tab key.
    */
-  _handleTab(e: MouseEvent) {
+  _handleTab(e: Event) {
     if (this.$.dropdown.opened) {
       // Tab in a native select is a no-op. Emulate this.
       e.preventDefault();
@@ -176,7 +196,7 @@
   /**
    * Handle the enter key.
    */
-  _handleEnter(e: MouseEvent) {
+  _handleEnter(e: Event) {
     e.preventDefault();
     e.stopPropagation();
     if (this.$.dropdown.opened) {
@@ -242,7 +262,7 @@
    * @param bold Whether the item is bold.
    * @return The class for the top-content item.
    */
-  _getClassIfBold(bold: boolean) {
+  _getClassIfBold(bold?: boolean) {
     return bold ? 'bold-text' : '';
   }
 
@@ -329,8 +349,8 @@
    *     list.
    * @return The class for the item button.
    */
-  _computeDisabledClass(id: string, disabledIdsRecord: DisableIdsRecord) {
-    return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
+  _computeDisabledClass(disabledIdsRecord: DisableIdsRecord, id?: string) {
+    return id && disabledIdsRecord.base.includes(id) ? 'disabled' : '';
   }
 
   /**
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 e754c9c..082a10b 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
@@ -33,7 +33,6 @@
     }
     gr-button {
       vertical-align: top;
-      @apply --gr-button;
     }
     gr-avatar {
       height: 2em;
@@ -59,6 +58,7 @@
       padding: var(--spacing-m) var(--spacing-l);
     }
     li .itemAction {
+      color: var(--gr-dropdown-item-color);
       @apply --gr-dropdown-item;
     }
     li .itemAction.disabled {
@@ -84,6 +84,7 @@
     .topContent {
       display: block;
       padding: var(--spacing-m) var(--spacing-l);
+      color: var(--gr-dropdown-item-color);
       @apply --gr-dropdown-item;
     }
     .bold-text {
@@ -139,7 +140,7 @@
               title$="[[link.tooltip]]"
             >
               <span
-                class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
+                class$="itemAction [[_computeDisabledClass(disabledIds.*, link.id)]]"
                 data-id$="[[link.id]]"
                 on-click="_handleItemTap"
                 hidden$="[[link.url]]"
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.ts
similarity index 68%
rename from polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
rename to polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
index c271b41..393f44e 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.ts
@@ -15,29 +15,36 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-dropdown.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
+import '../../../test/common-test-setup-karma';
+import './gr-dropdown';
+import {DropdownLink, GrDropdown} from './gr-dropdown';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAll, queryAndAssert} from '../../../test/test-utils';
+import {GrTooltipContent} from '../gr-tooltip-content/gr-tooltip-content';
 
 const basicFixture = fixtureFromElement('gr-dropdown');
 
 suite('gr-dropdown tests', () => {
-  let element;
+  let element: GrDropdown;
 
   setup(() => {
     element = basicFixture.instantiate();
   });
 
   test('_computeIsDownload', () => {
-    assert.isTrue(element._computeIsDownload({download: true}));
-    assert.isFalse(element._computeIsDownload({download: false}));
+    assert.isTrue(element._computeIsDownload({download: true} as DropdownLink));
+    assert.isFalse(
+      element._computeIsDownload({download: false} as DropdownLink)
+    );
   });
 
   test('tap on trigger opens menu, then closes', () => {
-    sinon.stub(element, '_open')
-        .callsFake(() => { element.$.dropdown.open(); });
-    sinon.stub(element, '_close')
-        .callsFake(() => { element.$.dropdown.close(); });
+    sinon.stub(element, '_open').callsFake(() => {
+      element.$.dropdown.open();
+    });
+    sinon.stub(element, '_close').callsFake(() => {
+      element.$.dropdown.close();
+    });
     assert.isFalse(element.$.dropdown.opened);
     MockInteractions.tap(element.$.trigger);
     assert.isTrue(element.$.dropdown.opened);
@@ -54,21 +61,25 @@
 
   test('link URLs', () => {
     assert.equal(
-        element._computeLinkURL({url: 'http://example.com/test'}),
-        'http://example.com/test');
+      element._computeLinkURL({url: 'http://example.com/test'}),
+      'http://example.com/test'
+    );
     assert.equal(
-        element._computeLinkURL({url: 'https://example.com/test'}),
-        'https://example.com/test');
+      element._computeLinkURL({url: 'https://example.com/test'}),
+      'https://example.com/test'
+    );
     assert.equal(
-        element._computeLinkURL({url: '/test'}),
-        '//' + window.location.host + '/test');
+      element._computeLinkURL({url: '/test'}),
+      '//' + window.location.host + '/test'
+    );
     assert.equal(
-        element._computeLinkURL({url: '/test', target: '_blank'}),
-        '/test');
+      element._computeLinkURL({url: '/test', target: '_blank'}),
+      '/test'
+    );
   });
 
   test('link rel', () => {
-    let link = {url: '/test'};
+    let link: DropdownLink = {url: '/test'};
     assert.isNull(element._computeLinkRel(link));
 
     link = {url: '/test', target: '_blank'};
@@ -92,7 +103,7 @@
   test('Top text exists and is bolded correctly', () => {
     element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
     flush();
-    const topItems = element.root.querySelectorAll('.top-item');
+    const topItems = queryAll<HTMLDivElement>(element, '.top-item');
     assert.equal(topItems.length, 2);
     assert.isTrue(topItems[0].classList.contains('bold-text'));
     assert.isFalse(topItems[1].classList.contains('bold-text'));
@@ -106,8 +117,9 @@
     element.addEventListener('tap-item-foo', fooTapped);
     element.addEventListener('tap-item', tapped);
     flush();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.itemAction'));
+    MockInteractions.tap(
+      queryAndAssert<HTMLSpanElement>(element, '.itemAction')
+    );
     assert.isTrue(fooTapped.called);
     assert.isTrue(tapped.called);
     assert.deepEqual(tapped.lastCall.args[0].detail, item0);
@@ -122,8 +134,9 @@
     element.addEventListener('tap-item-foo', stub);
     element.addEventListener('tap-item', tapped);
     flush();
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('.itemAction'));
+    MockInteractions.tap(
+      queryAndAssert<HTMLSpanElement>(element, '.itemAction')
+    );
     assert.isFalse(stub.called);
     assert.isFalse(tapped.called);
   });
@@ -135,8 +148,10 @@
     ];
     element.disabledIds = [];
     flush();
-    const tooltipContents = dom(element.root)
-        .querySelectorAll('iron-dropdown li gr-tooltip-content');
+    const tooltipContents = queryAll<GrTooltipContent>(
+      element,
+      'iron-dropdown li gr-tooltip-content'
+    );
     assert.equal(tooltipContents.length, 2);
     assert.isTrue(tooltipContents[0].hasTooltip);
     assert.equal(tooltipContents[0].getAttribute('title'), 'hello');
@@ -155,18 +170,18 @@
     test('down', () => {
       const stub = sinon.stub(element.cursor, 'next');
       assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
       assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'ArrowDown');
       assert.isTrue(stub.called);
     });
 
     test('up', () => {
       const stub = sinon.stub(element.cursor, 'previous');
       assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
       assert.isTrue(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'ArrowUp');
       assert.isTrue(stub.called);
     });
 
@@ -174,14 +189,16 @@
       // Because enter and space are handled by the same fn, we need only to
       // test one.
       assert.isFalse(element.$.dropdown.opened);
-      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
       assert.isTrue(element.$.dropdown.opened);
 
-      const el = element.cursor.target.querySelector(':not([hidden]) a');
+      const el = queryAndAssert<HTMLAnchorElement>(
+        element.cursor.target as HTMLElement,
+        ':not([hidden]) a'
+      );
       const stub = sinon.stub(el, 'click');
-      MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+      MockInteractions.pressAndReleaseKeyOn(element, 32, null, ' ');
       assert.isTrue(stub.called);
     });
   });
 });
-
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 eb44a60..866ee5a 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
@@ -23,6 +23,9 @@
 import {fireAlert, fireEvent} from '../../../utils/event-util';
 import {appContext} from '../../../services/app-context';
 import {debounce, DelayedTask} from '../../../utils/async-util';
+import {queryAndAssert} from '../../../utils/common-util';
+import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
+import {Interaction} from '../../../constants/reporting';
 
 const RESTORED_MESSAGE = 'Content restored from a previous edit.';
 const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
@@ -103,21 +106,16 @@
   _saveDisabled!: boolean;
 
   @property({type: String, observer: '_newContentChanged'})
-  _newContent?: string;
+  _newContent = '';
 
   private readonly storage = appContext.storageService;
 
   private readonly reporting = appContext.reportingService;
 
-  private storeTask?: DelayedTask;
+  // Tests use this so needs to be non private
+  storeTask?: DelayedTask;
 
-  /** @override */
-  ready() {
-    super.ready();
-  }
-
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.storeTask?.cancel();
     super.disconnectedCallback();
   }
@@ -131,7 +129,10 @@
   }
 
   focusTextarea() {
-    this.shadowRoot!.querySelector('iron-autogrow-textarea')!.textarea.focus();
+    queryAndAssert<IronAutogrowTextareaElement>(
+      this,
+      'iron-autogrow-textarea'
+    ).textarea.focus();
   }
 
   _newContentChanged(newContent: string) {
@@ -227,7 +228,7 @@
 
   _toggleCommitCollapsed() {
     this._commitCollapsed = !this._commitCollapsed;
-    this.reporting.reportInteraction('toggle show all button', {
+    this.reporting.reportInteraction(Interaction.TOGGLE_SHOW_ALL_BUTTON, {
       sectionName: 'Commit message',
       toState: !this._commitCollapsed ? 'Show all' : 'Show less',
     });
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 c6ff903..7877a1f 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
@@ -69,7 +69,7 @@
       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. 
+      /* To make this bar pop over editor, since editor has relative position.
       */
       position: relative;
     }
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.ts
similarity index 73%
rename from polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.js
rename to polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.ts
index 94a7b96..074678e 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.ts
@@ -15,13 +15,17 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-editable-content.js';
+import '../../../test/common-test-setup-karma';
+import './gr-editable-content';
+import {GrEditableContent} from './gr-editable-content';
+import {queryAndAssert, stubStorage} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrButton} from '../gr-button/gr-button';
 
 const basicFixture = fixtureFromElement('gr-editable-content');
 
 suite('gr-editable-content tests', () => {
-  let element;
+  let element: GrEditableContent;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -33,8 +37,7 @@
     const handler = sinon.spy();
     element.addEventListener('editable-content-save', handler);
 
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button[primary]'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button[primary]'));
 
     assert.isTrue(handler.called);
     assert.equal(handler.lastCall.args[0].detail.content, 'foo');
@@ -44,8 +47,7 @@
     const handler = sinon.spy();
     element.addEventListener('editable-content-cancel', handler);
 
-    MockInteractions.tap(element.shadowRoot
-        .querySelector('gr-button.cancel-button'));
+    MockInteractions.tap(queryAndAssert(element, 'gr-button.cancel-button'));
 
     assert.isTrue(handler.called);
   });
@@ -79,19 +81,22 @@
     });
 
     test('save button is disabled initially', () => {
-      assert.isTrue(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
+      assert.isTrue(
+        queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
+      );
     });
 
     test('save button is enabled when content changes', () => {
       element._newContent = 'new content';
-      assert.isFalse(element.shadowRoot
-          .querySelector('gr-button[primary]').disabled);
+      assert.isFalse(
+        queryAndAssert<GrButton>(element, 'gr-button[primary]').disabled
+      );
     });
   });
 
   suite('storageKey and related behavior', () => {
-    let dispatchSpy;
+    let dispatchSpy: sinon.SinonSpy;
+
     setup(() => {
       element.content = 'current content';
       element.storageKey = 'test';
@@ -99,8 +104,10 @@
     });
 
     test('editing toggled to true, has stored data', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({message: 'stored content'});
+      stubStorage('getEditableContentItem').returns({
+        message: 'stored content',
+        updated: 0,
+      });
       element.editing = true;
 
       assert.equal(element._newContent, 'stored content');
@@ -109,8 +116,7 @@
     });
 
     test('editing toggled to true, has no stored data', () => {
-      sinon.stub(element.storage, 'getEditableContentItem')
-          .returns({});
+      stubStorage('getEditableContentItem').returns(null);
       element.editing = true;
 
       assert.equal(element._newContent, 'current content');
@@ -118,28 +124,26 @@
     });
 
     test('edits are cached', () => {
-      const storeStub =
-          sinon.stub(element.storage, 'setEditableContentItem');
-      const eraseStub =
-          sinon.stub(element.storage, 'eraseEditableContentItem');
+      const storeStub = stubStorage('setEditableContentItem');
+      const eraseStub = stubStorage('eraseEditableContentItem');
       element.editing = true;
 
       element._newContent = 'new content';
       flush();
-      element.storeTask.flush();
+      element.storeTask?.flush();
 
       assert.isTrue(storeStub.called);
       assert.deepEqual(
-          [element.storageKey, element._newContent],
-          storeStub.lastCall.args);
+        [element.storageKey, element._newContent],
+        storeStub.lastCall.args
+      );
 
       element._newContent = '';
       flush();
-      element.storeTask.flush();
+      element.storeTask?.flush();
 
       assert.isTrue(eraseStub.called);
       assert.deepEqual([element.storageKey], eraseStub.lastCall.args);
     });
   });
 });
-
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 c758455..e0d1d15 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
@@ -20,17 +20,16 @@
 import '../gr-button/gr-button';
 import '../../shared/gr-autocomplete/gr-autocomplete';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {customElement, property} from '@polymer/decorators';
 import {htmlTemplate} from './gr-editable-label_html';
 import {IronDropdownElement} from '@polymer/iron-dropdown/iron-dropdown';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PaperInputElementExt} from '../../../types/types';
-import {CustomKeyboardEvent} from '../../../types/events';
 import {
   AutocompleteQuery,
   GrAutocomplete,
 } from '../gr-autocomplete/gr-autocomplete';
+import {addShortcut, Key} from '../../../utils/dom-util';
+import {queryAndAssert} from '../../../utils/common-util';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -48,7 +47,7 @@
 }
 
 @customElement('gr-editable-label')
-export class GrEditableLabel extends KeyboardShortcutMixin(PolymerElement) {
+export class GrEditableLabel extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -60,13 +59,13 @@
    */
 
   @property({type: String})
-  labelText?: string;
+  labelText = '';
 
   @property({type: Boolean})
   editing = false;
 
   @property({type: String, notify: true, observer: '_updateTitle'})
-  value = '';
+  value?: string;
 
   @property({type: String})
   placeholder = '';
@@ -81,7 +80,7 @@
   maxLength?: number;
 
   @property({type: String})
-  _inputText?: string;
+  _inputText = '';
 
   // This is used to push the iron-input element up on the page, so
   // the input is placed in approximately the same position as the
@@ -96,19 +95,30 @@
   autocomplete = false;
 
   @property({type: Object})
-  query?: AutocompleteQuery;
+  query: AutocompleteQuery = () => Promise.resolve([]);
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._ensureAttribute('tabindex', '0');
   }
 
-  get keyBindings() {
-    return {
-      enter: '_handleEnter',
-      esc: '_handleEsc',
-    };
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
+
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnter(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this._handleEsc(e))
+    );
   }
 
   _usePlaceholder(value?: string, placeholder?: string) {
@@ -192,7 +202,7 @@
     }
     this.$.dropdown.close();
     this.editing = false;
-    this._inputText = this.value;
+    this._inputText = this.value || '';
   }
 
   get _nativeInput(): HTMLInputElement {
@@ -202,20 +212,24 @@
       this.getGrAutocomplete()) as HTMLInputElement;
   }
 
-  _handleEnter(e: CustomKeyboardEvent) {
-    e = this.getKeyboardEvent(e);
-    const target = (dom(e) as EventApi).rootTarget;
-    if (target === this._nativeInput) {
-      e.preventDefault();
+  _handleEnter(event: KeyboardEvent) {
+    const inputContainer = queryAndAssert(this, '.inputContainer');
+    const isEventFromInput = event
+      .composedPath()
+      .some(element => element === inputContainer);
+    if (isEventFromInput) {
+      event.preventDefault();
       this._save();
     }
   }
 
-  _handleEsc(e: CustomKeyboardEvent) {
-    e = this.getKeyboardEvent(e);
-    const target = (dom(e) as EventApi).rootTarget;
-    if (target === this._nativeInput) {
-      e.preventDefault();
+  _handleEsc(event: KeyboardEvent) {
+    const inputContainer = queryAndAssert(this, '.inputContainer');
+    const isEventFromInput = event
+      .composedPath()
+      .some(element => element === inputContainer);
+    if (isEventFromInput) {
+      event.preventDefault();
       this._cancel();
     }
   }
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 a227482..e711e9d 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
@@ -35,7 +35,6 @@
       overflow: hidden;
       text-overflow: ellipsis;
       white-space: nowrap;
-      @apply --label-style;
     }
     label.editable {
       color: var(--link-color);
@@ -47,7 +46,6 @@
     .inputContainer {
       background-color: var(--dialog-background-color);
       padding: var(--spacing-m);
-      @apply --input-style;
     }
     .buttons {
       display: flex;
@@ -74,7 +72,7 @@
       --iron-icon-width: 18px;
     }
     gr-button.pencil {
-      --padding: 0px 0px;
+      --gr-button-padding: 0px 0px;
     }
   </style>
   <template is="dom-if" if="[[!showAsEditPencil]]">
@@ -83,6 +81,7 @@
       title$="[[_computeLabel(value, placeholder)]]"
       aria-label$="[[_computeLabel(value, placeholder)]]"
       on-click="_showDropdown"
+      part="label"
       >[[_computeLabel(value, placeholder)]]</label
     >
   </template>
@@ -104,7 +103,7 @@
     on-iron-overlay-canceled="_cancel"
   >
     <div class="dropdown-content" slot="dropdown-content">
-      <div class="inputContainer">
+      <div class="inputContainer" part="input-container">
         <template is="dom-if" if="[[!autocomplete]]">
           <paper-input
             id="input"
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 3e217f0..b6bb87b 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
@@ -101,7 +101,7 @@
 
     element._inputText = 'new text';
     // Press enter:
-    MockInteractions.keyDownOn(input, 13);
+    MockInteractions.keyDownOn(input, 13, null, 'Enter');
     flush();
 
     assert.isTrue(editedSpy.called);
@@ -122,7 +122,7 @@
 
     element._inputText = 'new text';
     // Press enter:
-    MockInteractions.tap(element.$.saveBtn, 13);
+    MockInteractions.tap(element.$.saveBtn, 13, null, 'Enter');
     flush();
 
     assert.isTrue(editedSpy.called);
@@ -143,7 +143,7 @@
 
     element._inputText = 'new text';
     // Press escape:
-    MockInteractions.keyDownOn(input, 27);
+    MockInteractions.keyDownOn(input, 27, null, 'Escape');
     flush();
 
     assert.isFalse(editedSpy.called);
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
index cfa67fd..3f759b5 100644
--- 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
@@ -14,13 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
-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';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 const FileStatus = {
   A: 'Added',
@@ -33,14 +32,50 @@
 };
 
 @customElement('gr-file-status-chip')
-export class GrFileStatusChip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrFileStatusChip extends LitElement {
   @property({type: Object})
   file?: NormalizedFileInfo;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .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(--file-status-added);
+        }
+        .status.invisible,
+        .status.M {
+          display: none;
+        }
+        .status.D,
+        .status.R,
+        .status.W {
+          background-color: var(--file-status-changed);
+        }
+        .status.U {
+          background-color: var(--file-status-unchanged);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html` <span
+      class="${this._computeStatusClass(this.file)}"
+      tabindex="0"
+      title="${this._computeFileStatusLabel(this.file?.status)}"
+      aria-label="${this._computeFileStatusLabel(this.file?.status)}"
+    >
+      ${this._computeFileStatusLabel(this.file?.status)}
+    </span>`;
+  }
+
   /**
    * Get a descriptive label for use in the status indicator's tooltip and
    * ARIA label.
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
deleted file mode 100644
index 6c7f3e0a..0000000
--- a/polygerrit-ui/app/elements/shared/gr-file-status-chip/gr-file-status-chip_html.ts
+++ /dev/null
@@ -1,51 +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">
-    .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(--file-status-added);
-    }
-    .status.invisible,
-    .status.M {
-      display: none;
-    }
-    .status.D,
-    .status.R,
-    .status.W {
-      background-color: var(--file-status-changed);
-    }
-    .status.U {
-      background-color: var(--file-status-unchanged);
-    }
-  </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-formatted-text/gr-formatted-text.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
index 17b659f..17621c3 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.ts
@@ -15,19 +15,24 @@
  * limitations under the License.
  */
 import '../gr-linked-text/gr-linked-text';
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement, property} from '@polymer/decorators';
-import {htmlTemplate} from './gr-formatted-text_html';
 import {CommentLinks} from '../../../types/common';
+import {LitElement, css, html, TemplateResult} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 const CODE_MARKER_PATTERN = /^(`{1,3})([^`]+?)\1$/;
 
-interface Block {
-  type: string;
-  text?: string;
-  blocks?: Block[];
-  items?: string[];
+export type Block = ListBlock | QuoteBlock | TextBlock;
+export interface ListBlock {
+  type: 'list';
+  items: string[];
+}
+export interface QuoteBlock {
+  type: 'quote';
+  blocks: Block[];
+}
+export interface TextBlock {
+  type: 'paragraph' | 'code' | 'pre';
+  text: string;
 }
 
 declare global {
@@ -35,63 +40,72 @@
     'gr-formatted-text': GrFormattedText;
   }
 }
-
-export interface GrFormattedText {
-  $: {
-    container: HTMLElement;
-  };
-}
-
 @customElement('gr-formatted-text')
-export class GrFormattedText extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
-  @property({type: String, observer: '_contentChanged'})
+export class GrFormattedText extends LitElement {
+  @property({type: String})
   content?: string;
 
   @property({type: Object})
   config?: CommentLinks;
 
-  @property({type: Boolean})
+  @property({type: Boolean, reflect: true})
   noTrailingMargin = false;
 
-  static get observers() {
-    return ['_contentOrConfigChanged(content, config)'];
+  static override get styles() {
+    return [
+      css`
+        :host {
+          display: block;
+          font-family: var(--font-family);
+        }
+        p,
+        ul,
+        code,
+        blockquote,
+        gr-linked-text.pre {
+          margin: 0 0 var(--spacing-m) 0;
+        }
+        p,
+        ul,
+        code,
+        blockquote {
+          max-width: var(--gr-formatted-text-prose-max-width, none);
+        }
+        :host([noTrailingMargin]) p:last-child,
+        :host([noTrailingMargin]) ul:last-child,
+        :host([noTrailingMargin]) blockquote:last-child,
+        :host([noTrailingMargin]) gr-linked-text.pre:last-child {
+          margin: 0;
+        }
+        code,
+        blockquote {
+          border-left: 1px solid #aaa;
+          padding: 0 var(--spacing-m);
+        }
+        code {
+          display: block;
+          white-space: pre-wrap;
+          color: var(--deemphasized-text-color);
+        }
+        li {
+          list-style-type: disc;
+          margin-left: var(--spacing-xl);
+        }
+        code,
+        gr-linked-text.pre {
+          font-family: var(--monospace-font-family);
+          font-size: var(--font-size-code);
+          /* usually 16px = 12px + 4px */
+          line-height: calc(var(--font-size-code) + var(--spacing-s));
+        }
+      `,
+    ];
   }
 
-  /** @override */
-  ready() {
-    super.ready();
-    if (this.noTrailingMargin) {
-      this.classList.add('noTrailingMargin');
-    }
-  }
-
-  _contentChanged(content: string) {
-    // In the case where the config may not be set (perhaps due to the
-    // request for it still being in flight), set the content anyway to
-    // prevent waiting on the config to display the text.
-    if (this.config) return;
-    this._contentOrConfigChanged(content);
-  }
-
-  /**
-   * Given a source string, update the DOM inside #container.
-   */
-  _contentOrConfigChanged(content?: string) {
-    const container = this.$.container;
-
-    // Remove existing content.
-    while (container.firstChild) {
-      container.removeChild(container.firstChild);
-    }
-
-    // Add new content.
-    for (const node of this._computeNodes(this._computeBlocks(content))) {
-      if (node) container.appendChild(node);
-    }
+  override render() {
+    if (!this.content) return;
+    const blocks = this._computeBlocks(this.content);
+    return html`${blocks.map(block => this.renderBlock(block))}`;
   }
 
   /**
@@ -114,10 +128,8 @@
    *
    * NOTE: Strings appearing in all block objects are NOT escaped.
    */
-  _computeBlocks(content?: string): Block[] {
-    if (!content) return [];
-
-    const result = [];
+  _computeBlocks(content: string): Block[] {
+    const result: Block[] = [];
     const lines = content.replace(/[\s\n\r\t]+$/g, '').split('\n');
 
     for (let i = 0; i < lines.length; i++) {
@@ -125,80 +137,87 @@
         continue;
       }
 
-      if (this._isCodeMarkLine(lines[i])) {
-        // handle multi-line code
-        let nextI = i + 1;
-        while (!this._isCodeMarkLine(lines[nextI]) && nextI < lines.length) {
-          nextI++;
-        }
-
-        if (this._isCodeMarkLine(lines[nextI])) {
+      if (this.isCodeMarkLine(lines[i])) {
+        const startOfCode = i + 1;
+        const endOfCode = this.getEndOfSection(
+          lines,
+          startOfCode,
+          line => !this.isCodeMarkLine(line)
+        );
+        // If the code extends to the end then there is no closing``` and the
+        // opening``` should not be counted as a multiline code block.
+        const lineAfterCode = lines[endOfCode];
+        if (lineAfterCode && this.isCodeMarkLine(lineAfterCode)) {
           result.push({
             type: 'code',
-            text: lines.slice(i + 1, nextI).join('\n'),
+            // Does not include either of the ``` lines
+            text: lines.slice(startOfCode, endOfCode).join('\n'),
           });
-          i = nextI;
+          i = endOfCode; // advances past the closing```
           continue;
         }
-
-        // otherwise treat it as regular line and continue
-        // check for other cases
       }
-
-      if (this._isSingleLineCode(lines[i])) {
+      if (this.isSingleLineCode(lines[i])) {
         // no guard check as _isSingleLineCode tested on the pattern
         const codeContent = lines[i].match(CODE_MARKER_PATTERN)![2];
         result.push({type: 'code', text: codeContent});
-      } else if (this._isList(lines[i])) {
-        let nextI = i + 1;
-        while (this._isList(lines[nextI])) {
-          nextI++;
-        }
-        result.push(this._makeList(lines.slice(i, nextI)));
-        i = nextI - 1;
-      } else if (this._isQuote(lines[i])) {
-        let nextI = i + 1;
-        while (this._isQuote(lines[nextI])) {
-          nextI++;
-        }
+      } else if (this.isList(lines[i])) {
+        const endOfList = this.getEndOfSection(lines, i + 1, line =>
+          this.isList(line)
+        );
+        result.push(this.makeList(lines.slice(i, endOfList)));
+        i = endOfList - 1;
+      } else if (this.isQuote(lines[i])) {
+        const endOfQuote = this.getEndOfSection(lines, i + 1, line =>
+          this.isQuote(line)
+        );
         const blockLines = lines
-          .slice(i, nextI)
+          .slice(i, endOfQuote)
           .map(l => l.replace(/^[ ]?>[ ]?/, ''));
         result.push({
           type: 'quote',
           blocks: this._computeBlocks(blockLines.join('\n')),
         });
-        i = nextI - 1;
-      } else if (this._isPreFormat(lines[i])) {
-        let nextI = i + 1;
+        i = endOfQuote - 1;
+      } else if (this.isPreFormat(lines[i])) {
         // include pre or all regular lines but stop at next new line
-        while (
-          this._isPreFormat(lines[nextI]) ||
-          (this._isRegularLine(lines[nextI]) && lines[nextI].length)
-        ) {
-          nextI++;
-        }
+        const predicate = (line: string) =>
+          this.isPreFormat(line) ||
+          (this.isRegularLine(line) &&
+            !this.isWhitespaceLine(line) &&
+            line.length > 0);
+        const endOfPre = this.getEndOfSection(lines, i + 1, predicate);
         result.push({
           type: 'pre',
-          text: lines.slice(i, nextI).join('\n'),
+          text: lines.slice(i, endOfPre).join('\n'),
         });
-        i = nextI - 1;
+        i = endOfPre - 1;
       } else {
-        let nextI = i + 1;
-        while (this._isRegularLine(lines[nextI])) {
-          nextI++;
-        }
+        const endOfRegularLines = this.getEndOfSection(lines, i + 1, line =>
+          this.isRegularLine(line)
+        );
         result.push({
           type: 'paragraph',
-          text: lines.slice(i, nextI).join('\n'),
+          text: lines.slice(i, endOfRegularLines).join('\n'),
         });
-        i = nextI - 1;
+        i = endOfRegularLines - 1;
       }
     }
 
     return result;
   }
 
+  private getEndOfSection(
+    lines: string[],
+    startIndex: number,
+    sectionPredicate: (line: string) => boolean
+  ) {
+    const index = lines
+      .slice(startIndex)
+      .findIndex(line => !sectionPredicate(line));
+    return index === -1 ? lines.length : index + startIndex;
+  }
+
   /**
    * Take a block of comment text that contains a list, generate appropriate
    * block objects and append them to the output list.
@@ -211,101 +230,78 @@
    *
    * @param lines The block containing the list.
    */
-  _makeList(lines: string[]) {
-    const items = [];
-    for (let i = 0; i < lines.length; i++) {
-      let line = lines[i];
-      line = line.substring(1).trim();
-      items.push(line);
-    }
+  private makeList(lines: string[]): Block {
+    const items = lines.map(line => line.substring(1).trim());
     return {type: 'list', items};
   }
 
-  _isRegularLine(line: string) {
-    // line can not be recognized by existing patterns
-    if (line === undefined) return false;
+  private isRegularLine(line: string): boolean {
     return (
-      !this._isQuote(line) &&
-      !this._isCodeMarkLine(line) &&
-      !this._isSingleLineCode(line) &&
-      !this._isList(line) &&
-      !this._isPreFormat(line)
+      !this.isQuote(line) &&
+      !this.isCodeMarkLine(line) &&
+      !this.isSingleLineCode(line) &&
+      !this.isList(line) &&
+      !this.isPreFormat(line)
     );
   }
 
-  _isQuote(line: string) {
-    return line && (line.startsWith('> ') || line.startsWith(' > '));
+  private isQuote(line: string): boolean {
+    return line.startsWith('> ') || line.startsWith(' > ');
   }
 
-  _isCodeMarkLine(line: string) {
-    return line && line.trim() === '```';
+  private isCodeMarkLine(line: string): boolean {
+    return line.trim() === '```';
   }
 
-  _isSingleLineCode(line: string) {
-    return line && CODE_MARKER_PATTERN.test(line);
+  private isSingleLineCode(line: string): boolean {
+    return CODE_MARKER_PATTERN.test(line);
   }
 
-  _isPreFormat(line: string) {
-    return line && /^[ \t]/.test(line);
+  private isPreFormat(line: string): boolean {
+    return /^[ \t]/.test(line) && !this.isWhitespaceLine(line);
   }
 
-  _isList(line: string) {
-    return line && /^[-*] /.test(line);
+  private isList(line: string): boolean {
+    return /^[-*] /.test(line);
   }
 
-  _makeLinkedText(content = '', isPre?: boolean) {
-    const text = document.createElement('gr-linked-text');
-    text.config = this.config;
-    text.content = content;
-    text.pre = true;
-    if (isPre) {
-      text.classList.add('pre');
+  private isWhitespaceLine(line: string): boolean {
+    return /^\s+$/.test(line);
+  }
+
+  private renderLinkedText(content: string, isPre?: boolean): TemplateResult {
+    return html`
+      <gr-linked-text
+        class="${isPre ? 'pre' : ''}"
+        .config=${this.config}
+        content=${content}
+        pre
+      ></gr-linked-text>
+    `;
+  }
+
+  private renderBlock(block: Block): TemplateResult {
+    switch (block.type) {
+      case 'paragraph':
+        return html`<p>${this.renderLinkedText(block.text)}</p>`;
+      case 'quote':
+        return html`
+          <blockquote>
+            ${block.blocks.map(subBlock => this.renderBlock(subBlock))}
+          </blockquote>
+        `;
+      case 'code':
+        return html`<code>${block.text}</code>`;
+      case 'pre':
+        return this.renderLinkedText(block.text, true);
+      case 'list':
+        return html`
+          <ul>
+            ${block.items.map(
+              item => html`<li>${this.renderLinkedText(item)}</li>`
+            )}
+          </ul>
+        `;
     }
-    return text;
-  }
-
-  /**
-   * Map an array of block objects to an array of DOM nodes.
-   */
-  _computeNodes(blocks: Block[]): HTMLElement[] {
-    return blocks.map(block => {
-      if (block.type === 'paragraph') {
-        const p = document.createElement('p');
-        p.appendChild(this._makeLinkedText(block.text));
-        return p;
-      }
-
-      if (block.type === 'quote') {
-        const bq = document.createElement('blockquote');
-        for (const node of this._computeNodes(block.blocks || [])) {
-          if (node) bq.appendChild(node);
-        }
-        return bq;
-      }
-
-      if (block.type === 'code') {
-        const code = document.createElement('code');
-        code.textContent = block.text || '';
-        return code;
-      }
-
-      if (block.type === 'pre') {
-        return this._makeLinkedText(block.text, true);
-      }
-
-      if (block.type === 'list') {
-        const ul = document.createElement('ul');
-        const items = block.items || [];
-        for (const item of items) {
-          const li = document.createElement('li');
-          li.appendChild(this._makeLinkedText(item));
-          ul.appendChild(li);
-        }
-        return ul;
-      }
-
-      console.warn('Unrecognized type.');
-      return document.createElement('span');
-    });
   }
 }
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
deleted file mode 100644
index 04e4954..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_html.ts
+++ /dev/null
@@ -1,67 +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>
-    :host {
-      display: block;
-      font-family: var(--font-family);
-    }
-    p,
-    ul,
-    code,
-    blockquote,
-    gr-linked-text.pre {
-      margin: 0 0 var(--spacing-m) 0;
-    }
-    p,
-    ul,
-    code,
-    blockquote {
-      max-width: var(--gr-formatted-text-prose-max-width, none);
-    }
-    :host(.noTrailingMargin) p:last-child,
-    :host(.noTrailingMargin) ul:last-child,
-    :host(.noTrailingMargin) blockquote:last-child,
-    :host(.noTrailingMargin) gr-linked-text.pre:last-child {
-      margin: 0;
-    }
-    code,
-    blockquote {
-      border-left: 1px solid #aaa;
-      padding: 0 var(--spacing-m);
-    }
-    code {
-      display: block;
-      white-space: pre-wrap;
-      color: var(--deemphasized-text-color);
-    }
-    li {
-      list-style-type: disc;
-      margin-left: var(--spacing-xl);
-    }
-    code,
-    gr-linked-text.pre {
-      font-family: var(--monospace-font-family);
-      font-size: var(--font-size-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-formatted-text/gr-formatted-text_test.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
deleted file mode 100644
index fd5a9ba..0000000
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.js
+++ /dev/null
@@ -1,406 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-formatted-text.js';
-
-const basicFixture = fixtureFromElement('gr-formatted-text');
-
-suite('gr-formatted-text tests', () => {
-  let element;
-
-  function assertBlock(result, index, type, text) {
-    assert.equal(result[index].type, type);
-    assert.equal(result[index].text, text);
-  }
-
-  function assertListBlock(result, resultIndex, itemIndex, text) {
-    assert.equal(result[resultIndex].type, 'list');
-    assert.equal(result[resultIndex].items[itemIndex], text);
-  }
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('parse null undefined and empty', () => {
-    assert.lengthOf(element._computeBlocks(null), 0);
-    assert.lengthOf(element._computeBlocks(undefined), 0);
-    assert.lengthOf(element._computeBlocks(''), 0);
-  });
-
-  test('parse simple', () => {
-    const comment = 'Para1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'paragraph', comment);
-  });
-
-  test('parse multiline para', () => {
-    const comment = 'Para 1\nStill para 1';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'paragraph', comment);
-  });
-
-  test('parse para break without special blocks', () => {
-    const comment = 'Para 1\n\nPara 2\n\nPara 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'paragraph', comment);
-  });
-
-  test('parse quote', () => {
-    const comment = '> Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-  });
-
-  test('parse quote lead space', () => {
-    const comment = ' > Quote text';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
-  });
-
-  test('parse multiline quote', () => {
-    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph',
-        'Quote line 1\nQuote line 2\nQuote line 3');
-  });
-
-  test('parse pre', () => {
-    const comment = '    Four space indent.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'pre', comment);
-  });
-
-  test('parse one space pre', () => {
-    const comment = ' One space indent.\n Another line.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'pre', comment);
-  });
-
-  test('parse tab pre', () => {
-    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertBlock(result, 0, 'pre', comment);
-  });
-
-  test('parse star list', () => {
-    const comment = '* Item 1\n* Item 2\n* Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result, 0, 0, 'Item 1');
-    assertListBlock(result, 0, 1, 'Item 2');
-    assertListBlock(result, 0, 2, 'Item 3');
-  });
-
-  test('parse dash list', () => {
-    const comment = '- Item 1\n- Item 2\n- Item 3';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result, 0, 0, 'Item 1');
-    assertListBlock(result, 0, 1, 'Item 2');
-    assertListBlock(result, 0, 2, 'Item 3');
-  });
-
-  test('parse mixed list', () => {
-    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assertListBlock(result, 0, 0, 'Item 1');
-    assertListBlock(result, 0, 1, 'Item 2');
-    assertListBlock(result, 0, 2, 'Item 3');
-    assertListBlock(result, 0, 3, 'Item 4');
-  });
-
-  test('parse mixed block types', () => {
-    const comment = 'Paragraph\nacross\na\nfew\nlines.' +
-        '\n\n' +
-        '> Quote\n> across\n> not many lines.' +
-        '\n\n' +
-        'Another paragraph' +
-        '\n\n' +
-        '* Series\n* of\n* list\n* items' +
-        '\n\n' +
-        'Yet another paragraph' +
-        '\n\n' +
-        '\tPreformatted text.' +
-        '\n\n' +
-        'Parting words.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 7);
-    assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.\n');
-
-    assert.equal(result[1].type, 'quote');
-    assert.lengthOf(result[1].blocks, 1);
-    assertBlock(result[1].blocks, 0, 'paragraph',
-        'Quote\nacross\nnot many lines.');
-
-    assertBlock(result, 2, 'paragraph', 'Another paragraph\n');
-    assertListBlock(result, 3, 0, 'Series');
-    assertListBlock(result, 3, 1, 'of');
-    assertListBlock(result, 3, 2, 'list');
-    assertListBlock(result, 3, 3, 'items');
-    assertBlock(result, 4, 'paragraph', 'Yet another paragraph\n');
-    assertBlock(result, 5, 'pre', '\tPreformatted text.');
-    assertBlock(result, 6, 'paragraph', 'Parting words.');
-  });
-
-  test('bullet list 1', () => {
-    const comment = 'A\n\n* line 1\n* 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'A\n');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-  });
-
-  test('bullet list 2', () => {
-    const comment = 'A\n* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-    assertBlock(result, 2, 'paragraph', 'B');
-  });
-
-  test('bullet list 3', () => {
-    const comment = '* line 1\n* 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result, 0, 0, 'line 1');
-    assertListBlock(result, 0, 1, '2nd line');
-    assertBlock(result, 1, 'paragraph', 'B');
-  });
-
-  test('bullet list 4', () => {
-    const comment = 'To see this bug, you have to:\n' +
-        '* Be on IMAP or EAS (not on POP)\n' +
-        '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
-    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-    assertListBlock(result, 1, 1, 'Be very unlucky');
-  });
-
-  test('bullet list 5', () => {
-    const comment = 'To see this bug,\n' +
-        'you have to:\n' +
-        '* Be on IMAP or EAS (not on POP)\n' +
-        '* Be very unlucky\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'To see this bug,\nyou have to:');
-    assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
-    assertListBlock(result, 1, 1, 'Be very unlucky');
-  });
-
-  test('dash list 1', () => {
-    const comment = 'A\n- line 1\n- 2nd line';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-  });
-
-  test('dash list 2', () => {
-    const comment = 'A\n- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertListBlock(result, 1, 0, 'line 1');
-    assertListBlock(result, 1, 1, '2nd line');
-    assertBlock(result, 2, 'paragraph', 'B');
-  });
-
-  test('dash list 3', () => {
-    const comment = '- line 1\n- 2nd line\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertListBlock(result, 0, 0, 'line 1');
-    assertListBlock(result, 0, 1, '2nd line');
-    assertBlock(result, 1, 'paragraph', 'B');
-  });
-
-  test('nested list will NOT be recognized', () => {
-    // will be rendered as two separate lists
-    const comment = '- line 1\n  - line with indentation\n- line 2';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertListBlock(result, 0, 0, 'line 1');
-    assert.equal(result[1].type, 'pre');
-    assertListBlock(result, 2, 0, 'line 2');
-  });
-
-  test('pre format 1', () => {
-    const comment = 'A\n  This is pre\n  formatted';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-  });
-
-  test('pre format 2', () => {
-    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
-    assertBlock(result, 2, 'paragraph', 'but this is not');
-  });
-
-  test('pre format 3', () => {
-    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'A');
-    assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
-    assertBlock(result, 2, 'paragraph', 'B');
-  });
-
-  test('pre format 4', () => {
-    const comment = '  Q\n    <R>\n  S\n\nB';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
-    assertBlock(result, 1, 'paragraph', 'B');
-  });
-
-  test('quote 1', () => {
-    const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 1);
-    assertBlock(result[0].blocks, 0, 'paragraph', 'I\'m happy\nwith quotes!');
-    assertBlock(result, 1, 'paragraph', 'See above.');
-  });
-
-  test('quote 2', () => {
-    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 3);
-    assertBlock(result, 0, 'paragraph', 'See this said:');
-    assert.equal(result[1].type, 'quote');
-    assert.lengthOf(result[1].blocks, 1);
-    assertBlock(result[1].blocks, 0, 'paragraph', 'a quoted\nstring block');
-    assertBlock(result, 2, 'paragraph', 'OK?');
-  });
-
-  test('nested quotes', () => {
-    const comment = ' > > prior\n > \n > next\n';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'quote');
-    assert.lengthOf(result[0].blocks, 2);
-    assert.equal(result[0].blocks[0].type, 'quote');
-    assert.lengthOf(result[0].blocks[0].blocks, 1);
-    assertBlock(result[0].blocks[0].blocks, 0, 'paragraph', 'prior');
-    assertBlock(result[0].blocks, 1, 'paragraph', 'next');
-  });
-
-  test('code 1', () => {
-    const comment = '```\n// test code\n```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'code');
-    assert.equal(result[0].text, '// test code');
-  });
-
-  test('code 2', () => {
-    const comment = 'test code\n```// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code');
-    assert.equal(result[1].type, 'code');
-    assert.equal(result[1].text, '// test code');
-  });
-
-  test('code 3', () => {
-    const comment = 'test code\n```// test code```';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code');
-    assert.equal(result[1].type, 'code');
-    assert.equal(result[1].text, '// test code');
-  });
-
-  test('not a code', () => {
-    const comment = 'test code\n```// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 1);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code\n```// test code');
-  });
-
-  test('not a code 2', () => {
-    const comment = 'test code\n```\n// test code';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 2);
-    assert.equal(result[0].type, 'paragraph');
-    assert.equal(result[0].text, 'test code');
-    assert.equal(result[1].type, 'paragraph');
-    assert.equal(result[1].text, '```\n// test code');
-  });
-
-  test('mix all 1', () => {
-    const comment = ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
-      '```// test code```\n\n> reference is here';
-    const result = element._computeBlocks(comment);
-    assert.lengthOf(result, 5);
-    assert.equal(result[0].type, 'pre');
-    assert.equal(result[1].type, 'list');
-    assert.equal(result[2].type, 'paragraph');
-    assert.equal(result[3].type, 'code');
-    assert.equal(result[4].type, 'quote');
-  });
-
-  test('_computeNodes called without config', () => {
-    const computeNodesSpy = sinon.spy(element, '_computeNodes');
-    element.content = 'some text';
-    assert.isTrue(computeNodesSpy.called);
-  });
-
-  test('_contentOrConfigChanged called with config', () => {
-    const contentStub = sinon.stub(element, '_contentChanged');
-    const contentConfigStub = sinon.stub(element, '_contentOrConfigChanged');
-    element.content = 'some text';
-    element.config = {};
-    assert.isTrue(contentStub.called);
-    assert.isTrue(contentConfigStub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
new file mode 100644
index 0000000..8cecf71
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.ts
@@ -0,0 +1,433 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-formatted-text';
+import {
+  GrFormattedText,
+  Block,
+  ListBlock,
+  TextBlock,
+  QuoteBlock,
+} from './gr-formatted-text';
+
+const basicFixture = fixtureFromElement('gr-formatted-text');
+
+suite('gr-formatted-text tests', () => {
+  let element: GrFormattedText;
+
+  function assertTextBlock(block: Block, type: string, text: string) {
+    assert.equal(block.type, type);
+    const textBlock = block as TextBlock;
+    assert.equal(textBlock.text, text);
+  }
+
+  function assertListBlock(block: Block, items: string[]) {
+    assert.equal(block.type, 'list');
+    const listBlock = block as ListBlock;
+    assert.deepEqual(listBlock.items, items);
+  }
+
+  function assertQuoteBlock(block: Block): QuoteBlock {
+    assert.equal(block.type, 'quote');
+    return block as QuoteBlock;
+  }
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  test('parse empty', () => {
+    assert.lengthOf(element._computeBlocks(''), 0);
+  });
+
+  test('parse simple', () => {
+    const comment = 'Para1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'paragraph', comment);
+  });
+
+  test('parse multiline para', () => {
+    const comment = 'Para 1\nStill para 1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'paragraph', comment);
+  });
+
+  test('parse para break without special blocks', () => {
+    const comment = 'Para 1\n\nPara 2\n\nPara 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'paragraph', comment);
+  });
+
+  test('parse quote', () => {
+    const comment = '> Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    const quoteBlock = assertQuoteBlock(result[0]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(quoteBlock.blocks[0], 'paragraph', 'Quote text');
+  });
+
+  test('parse quote lead space', () => {
+    const comment = ' > Quote text';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    const quoteBlock = assertQuoteBlock(result[0]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(quoteBlock.blocks[0], 'paragraph', 'Quote text');
+  });
+
+  test('parse multiline quote', () => {
+    const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    const quoteBlock = assertQuoteBlock(result[0]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(
+      quoteBlock.blocks[0],
+      'paragraph',
+      'Quote line 1\nQuote line 2\nQuote line 3'
+    );
+  });
+
+  test('parse pre', () => {
+    const comment = '    Four space indent.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'pre', comment);
+  });
+
+  test('parse one space pre', () => {
+    const comment = ' One space indent.\n Another line.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'pre', comment);
+  });
+
+  test('parse tab pre', () => {
+    const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'pre', comment);
+  });
+
+  test('parse star list', () => {
+    const comment = '* Item 1\n* Item 2\n* Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3']);
+  });
+
+  test('parse dash list', () => {
+    const comment = '- Item 1\n- Item 2\n- Item 3';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3']);
+  });
+
+  test('parse mixed list', () => {
+    const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertListBlock(result[0], ['Item 1', 'Item 2', 'Item 3', 'Item 4']);
+  });
+
+  test('parse mixed block types', () => {
+    const comment =
+      'Paragraph\nacross\na\nfew\nlines.' +
+      '\n\n' +
+      '> Quote\n> across\n> not many lines.' +
+      '\n\n' +
+      'Another paragraph' +
+      '\n\n' +
+      '* Series\n* of\n* list\n* items' +
+      '\n\n' +
+      'Yet another paragraph' +
+      '\n\n' +
+      '\tPreformatted text.' +
+      '\n\n' +
+      'Parting words.';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 7);
+    assertTextBlock(
+      result[0],
+      'paragraph',
+      'Paragraph\nacross\na\nfew\nlines.\n'
+    );
+
+    const quoteBlock = assertQuoteBlock(result[1]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(
+      quoteBlock.blocks[0],
+      'paragraph',
+      'Quote\nacross\nnot many lines.'
+    );
+
+    assertTextBlock(result[2], 'paragraph', 'Another paragraph\n');
+    assertListBlock(result[3], ['Series', 'of', 'list', 'items']);
+    assertTextBlock(result[4], 'paragraph', 'Yet another paragraph\n');
+    assertTextBlock(result[5], 'pre', '\tPreformatted text.');
+    assertTextBlock(result[6], 'paragraph', 'Parting words.');
+  });
+
+  test('bullet list 1', () => {
+    const comment = 'A\n\n* line 1';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'A\n');
+    assertListBlock(result[1], ['line 1']);
+  });
+
+  test('bullet list 2', () => {
+    const comment = 'A\n\n* line 1\n* 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'A\n');
+    assertListBlock(result[1], ['line 1', '2nd line']);
+  });
+
+  test('bullet list 3', () => {
+    const comment = 'A\n* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertTextBlock(result[0], 'paragraph', 'A');
+    assertListBlock(result[1], ['line 1', '2nd line']);
+    assertTextBlock(result[2], 'paragraph', 'B');
+  });
+
+  test('bullet list 4', () => {
+    const comment = '* line 1\n* 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result[0], ['line 1', '2nd line']);
+    assertTextBlock(result[1], 'paragraph', 'B');
+  });
+
+  test('bullet list 5', () => {
+    const comment =
+      'To see this bug, you have to:\n' +
+      '* Be on IMAP or EAS (not on POP)\n' +
+      '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'To see this bug, you have to:');
+    assertListBlock(result[1], [
+      'Be on IMAP or EAS (not on POP)',
+      'Be very unlucky',
+    ]);
+  });
+
+  test('bullet list 6', () => {
+    const comment =
+      'To see this bug,\n' +
+      'you have to:\n' +
+      '* Be on IMAP or EAS (not on POP)\n' +
+      '* Be very unlucky\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'To see this bug,\nyou have to:');
+    assertListBlock(result[1], [
+      'Be on IMAP or EAS (not on POP)',
+      'Be very unlucky',
+    ]);
+  });
+
+  test('dash list 1', () => {
+    const comment = 'A\n- line 1\n- 2nd line';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'A');
+    assertListBlock(result[1], ['line 1', '2nd line']);
+  });
+
+  test('dash list 2', () => {
+    const comment = 'A\n- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertTextBlock(result[0], 'paragraph', 'A');
+    assertListBlock(result[1], ['line 1', '2nd line']);
+    assertTextBlock(result[2], 'paragraph', 'B');
+  });
+
+  test('dash list 3', () => {
+    const comment = '- line 1\n- 2nd line\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertListBlock(result[0], ['line 1', '2nd line']);
+    assertTextBlock(result[1], 'paragraph', 'B');
+  });
+
+  test('nested list will NOT be recognized', () => {
+    // will be rendered as two separate lists
+    const comment = '- line 1\n  - line with indentation\n- line 2';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertListBlock(result[0], ['line 1']);
+    assertTextBlock(result[1], 'pre', '  - line with indentation');
+    assertListBlock(result[2], ['line 2']);
+  });
+
+  test('pre format 1', () => {
+    const comment = 'A\n  This is pre\n  formatted';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'A');
+    assertTextBlock(result[1], 'pre', '  This is pre\n  formatted');
+  });
+
+  test('pre format 2', () => {
+    const comment = 'A\n  This is pre\n  formatted\n\nbut this is not';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertTextBlock(result[0], 'paragraph', 'A');
+    assertTextBlock(result[1], 'pre', '  This is pre\n  formatted');
+    assertTextBlock(result[2], 'paragraph', 'but this is not');
+  });
+
+  test('pre format 3', () => {
+    const comment = 'A\n  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertTextBlock(result[0], 'paragraph', 'A');
+    assertTextBlock(result[1], 'pre', '  Q\n    <R>\n  S');
+    assertTextBlock(result[2], 'paragraph', 'B');
+  });
+
+  test('pre format 4', () => {
+    const comment = '  Q\n    <R>\n  S\n\nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'pre', '  Q\n    <R>\n  S');
+    assertTextBlock(result[1], 'paragraph', 'B');
+  });
+
+  test('pre format 5', () => {
+    const comment = '  Q\n    <R>\n  S\n \nB';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'pre', '  Q\n    <R>\n  S');
+    assertTextBlock(result[1], 'paragraph', ' \nB');
+  });
+
+  test('quote 1', () => {
+    const comment = "> I'm happy with quotes!!";
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    const quoteBlock = assertQuoteBlock(result[0]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(
+      quoteBlock.blocks[0],
+      'paragraph',
+      "I'm happy with quotes!!"
+    );
+  });
+
+  test('quote 2', () => {
+    const comment = "> I'm happy\n > with quotes!\n\nSee above.";
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    const quoteBlock = assertQuoteBlock(result[0]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(
+      quoteBlock.blocks[0],
+      'paragraph',
+      "I'm happy\nwith quotes!"
+    );
+    assertTextBlock(result[1], 'paragraph', 'See above.');
+  });
+
+  test('quote 3', () => {
+    const comment = 'See this said:\n > a quoted\n > string block\n\nOK?';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 3);
+    assertTextBlock(result[0], 'paragraph', 'See this said:');
+    const quoteBlock = assertQuoteBlock(result[1]);
+    assert.lengthOf(quoteBlock.blocks, 1);
+    assertTextBlock(
+      quoteBlock.blocks[0],
+      'paragraph',
+      'a quoted\nstring block'
+    );
+    assertTextBlock(result[2], 'paragraph', 'OK?');
+  });
+
+  test('nested quotes', () => {
+    const comment = ' > > prior\n > \n > next\n';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    const outerQuoteBlock = assertQuoteBlock(result[0]);
+    assert.lengthOf(outerQuoteBlock.blocks, 2);
+    const nestedQuoteBlock = assertQuoteBlock(outerQuoteBlock.blocks[0]);
+    assert.lengthOf(nestedQuoteBlock.blocks, 1);
+    assertTextBlock(nestedQuoteBlock.blocks[0], 'paragraph', 'prior');
+    assertTextBlock(outerQuoteBlock.blocks[1], 'paragraph', 'next');
+  });
+
+  test('code 1', () => {
+    const comment = '```\n// test code\n```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'code', '// test code');
+  });
+
+  test('code 2', () => {
+    const comment = 'test code\n```// test code```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'test code');
+    assertTextBlock(result[1], 'code', '// test code');
+  });
+
+  test('not a code block', () => {
+    const comment = 'test code\n```// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 1);
+    assertTextBlock(result[0], 'paragraph', 'test code\n```// test code');
+  });
+
+  test('not a code block 2', () => {
+    const comment = 'test code\n```\n// test code';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'test code');
+    assertTextBlock(result[1], 'paragraph', '```\n// test code');
+  });
+
+  test('not a code block 3', () => {
+    const comment = 'test code\n```';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 2);
+    assertTextBlock(result[0], 'paragraph', 'test code');
+    assertTextBlock(result[1], 'paragraph', '```');
+  });
+
+  test('mix all 1', () => {
+    const comment =
+      ' bullets:\n- bullet 1\n- bullet 2\n\ncode example:\n' +
+      '```// test code```\n\n> reference is here';
+    const result = element._computeBlocks(comment);
+    assert.lengthOf(result, 5);
+    assert.equal(result[0].type, 'pre');
+    assert.equal(result[1].type, 'list');
+    assert.equal(result[2].type, 'paragraph');
+    assert.equal(result[3].type, 'code');
+    assert.equal(result[4].type, 'quote');
+  });
+});
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 abd4469..6a34fbb 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
@@ -16,16 +16,11 @@
  */
 
 import '@polymer/iron-icon/iron-icon';
-import '../../../styles/shared-styles';
 import '../gr-avatar/gr-avatar';
 import '../gr-button/gr-button';
-import {hovercardBehaviorMixin} from '../gr-hovercard/gr-hovercard-behavior';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-hovercard-account_html';
 import {appContext} from '../../../services/app-context';
 import {accountKey, isSelf} from '../../../utils/account-util';
-import {getDisplayName} from '../../../utils/display-name-util';
-import {customElement, property} from '@polymer/decorators';
+import {customElement, property} from 'lit/decorators';
 import {
   AccountInfo,
   ChangeInfo,
@@ -35,22 +30,25 @@
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {
   canHaveAttention,
+  getAddedByReason,
   getLastUpdate,
   getReason,
+  getRemovedByReason,
   hasAttention,
-  isAttentionSetEnabled,
 } from '../../../utils/attention-set-util';
 import {ReviewerState} from '../../../constants/constants';
 import {CURRENT} from '../../../utils/patch-set-util';
 import {isInvolved, isRemovableReviewer} from '../../../utils/change-util';
 import {assertIsDefined} from '../../../utils/common-util';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {css, html, LitElement} from 'lit';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
 
 @customElement('gr-hovercard-account')
-export class GrHovercardAccount extends hovercardBehaviorMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrHovercardAccount extends base {
   @property({type: Object})
   account!: AccountInfo;
 
@@ -92,7 +90,7 @@
     this.reporting = appContext.reportingService;
   }
 
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     this.restApiService.getConfig().then(config => {
       this._config = config;
@@ -102,14 +100,218 @@
     });
   }
 
-  _computeText(account?: AccountInfo, selfAccount?: AccountInfo) {
-    if (!account || !selfAccount) return '';
-    return isSelf(account, selfAccount) ? 'Your' : 'Their';
+  static override get styles() {
+    return [
+      fontStyles,
+      base.styles || [],
+      css`
+        .top,
+        .attention,
+        .status,
+        .voteable {
+          padding: var(--spacing-s) var(--spacing-l);
+        }
+        .top {
+          display: flex;
+          padding-top: var(--spacing-xl);
+          min-width: 300px;
+        }
+        gr-avatar {
+          height: 48px;
+          width: 48px;
+          margin-right: var(--spacing-l);
+        }
+        .title,
+        .email {
+          color: var(--deemphasized-text-color);
+        }
+        .action {
+          border-top: 1px solid var(--border-color);
+          padding: var(--spacing-s) var(--spacing-l);
+          --gr-button-padding: var(--spacing-s) var(--spacing-m);
+        }
+        .attention {
+          background-color: var(--emphasis-color);
+        }
+        .attention a {
+          text-decoration: none;
+        }
+        iron-icon {
+          vertical-align: top;
+        }
+        .status iron-icon {
+          width: 14px;
+          height: 14px;
+          position: relative;
+          top: 2px;
+        }
+        iron-icon.attentionIcon {
+          width: 14px;
+          height: 14px;
+          position: relative;
+          top: 3px;
+        }
+        .reason {
+          padding-top: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div id="container" role="tooltip" tabindex="-1">
+        ${this.renderContent()}
+      </div>
+    `;
+  }
+
+  private renderContent() {
+    if (!this._isShowing) return;
+    return html`
+      <div class="top">
+        <div class="avatar">
+          <gr-avautar .account=${this.account} imageSize="56"></gr-avatar>
+        </div>
+        <div class="account">
+          <h3 class="name heading-3">${this.account.name}</h3>
+          <div class="email">${this.account.email}</div>
+        </div>
+      </div>
+      ${this.renderAccountStatus()}
+      ${
+        this.voteableText
+          ? html`
+              <div class="voteable">
+                <span class="title">Voteable:</span>
+                <span class="value">${this.voteableText}</span>
+              </div>
+            `
+          : ''
+      }
+      ${this.renderNeedsAttention()} ${this.renderAddToAttention()}
+      ${this.renderRemoveFromAttention()} ${this.renderReviewerOrCcActions()}
+    `;
+  }
+
+  private renderReviewerOrCcActions() {
+    if (!this._selfAccount || !isRemovableReviewer(this.change, this.account))
+      return;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeReviewerOrCC"
+          link=""
+          no-uppercase
+          @click="${this.handleRemoveReviewerOrCC}"
+        >
+          Remove ${this.computeReviewerOrCCText()}
+        </gr-button>
+      </div>
+      <div class="action">
+        <gr-button
+          class="changeReviewerOrCC"
+          link=""
+          no-uppercase
+          @click="${this.handleChangeReviewerOrCCStatus}"
+        >
+          ${this.computeChangeReviewerOrCCText()}
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderAccountStatus() {
+    if (!this.account.status) return;
+    return html`
+      <div class="status">
+        <span class="title">
+          <iron-icon icon="gr-icons:calendar"></iron-icon>
+          Status:
+        </span>
+        <span class="value">${this.account.status}</span>
+      </div>
+    `;
+  }
+
+  private renderNeedsAttention() {
+    if (!(this.isAttentionEnabled && this.hasUserAttention)) return;
+    const lastUpdate = getLastUpdate(this.account, this.change);
+    return html`
+      <div class="attention">
+        <div>
+          <iron-icon
+            class="attentionIcon"
+            icon="gr-icons:attention"
+          ></iron-icon>
+          <span> ${this.computePronoun()} turn to take this action. </span>
+          <a
+            href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
+            target="_blank"
+          >
+            <iron-icon
+              icon="gr-icons:help-outline"
+              title="read documentation"
+            ></iron-icon>
+          </a>
+        </div>
+        <div class="reason">
+          <span class="title">Reason:</span>
+          <span class="value">
+            ${getReason(this._config, this.account, this.change)}
+          </span>
+          ${lastUpdate
+            ? html` (<gr-date-formatter
+                  withTooltip
+                  .dateStr="${lastUpdate}"
+                ></gr-date-formatter
+                >)`
+            : ''}
+        </div>
+      </div>
+    `;
+  }
+
+  private renderAddToAttention() {
+    if (!this.computeShowActionAddToAttentionSet()) return;
+    return html`
+      <div class="action">
+        <gr-button
+          class="addToAttentionSet"
+          link=""
+          no-uppercase
+          @click="${this.handleClickAddToAttentionSet}"
+        >
+          Add to attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  private renderRemoveFromAttention() {
+    if (!this.computeShowActionRemoveFromAttentionSet()) return;
+    return html`
+      <div class="action">
+        <gr-button
+          class="removeFromAttentionSet"
+          link=""
+          no-uppercase
+          @click="${this.handleClickRemoveFromAttentionSet}"
+        >
+          Remove from attention set
+        </gr-button>
+      </div>
+    `;
+  }
+
+  // private but used by tests
+  computePronoun() {
+    if (!this.account || !this._selfAccount) return '';
+    return isSelf(this.account, this._selfAccount) ? 'Your' : 'Their';
   }
 
   get isAttentionEnabled() {
     return (
-      isAttentionSetEnabled(this._config) &&
       !!this.highlightAttention &&
       !!this.change &&
       canHaveAttention(this.account)
@@ -117,25 +319,14 @@
   }
 
   get hasUserAttention() {
-    return hasAttention(this._config, this.account, this.change);
+    return hasAttention(this.account, this.change);
   }
 
-  _computeReason(change?: ChangeInfo) {
-    return getReason(this.account, change);
-  }
-
-  _computeLastUpdate(change?: ChangeInfo) {
-    return getLastUpdate(this.account, change);
-  }
-
-  _showReviewerOrCCActions(account?: AccountInfo, change?: ChangeInfo) {
-    return !!this._selfAccount && isRemovableReviewer(change, account);
-  }
-
-  _getReviewerState(account: AccountInfo, change: ChangeInfo) {
+  private getReviewerState() {
     if (
-      change.reviewers[ReviewerState.REVIEWER]?.some(
-        (reviewer: AccountInfo) => reviewer._account_id === account._account_id
+      this.change!.reviewers[ReviewerState.REVIEWER]?.some(
+        (reviewer: AccountInfo) =>
+          reviewer._account_id === this.account._account_id
       )
     ) {
       return ReviewerState.REVIEWER;
@@ -143,21 +334,21 @@
     return ReviewerState.CC;
   }
 
-  _computeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
-    if (!change || !account) return '';
-    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+  private computeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState() === ReviewerState.REVIEWER
       ? 'Reviewer'
       : 'CC';
   }
 
-  _computeChangeReviewerOrCCText(account?: AccountInfo, change?: ChangeInfo) {
-    if (!change || !account) return '';
-    return this._getReviewerState(account, change) === ReviewerState.REVIEWER
+  private computeChangeReviewerOrCCText() {
+    if (!this.change || !this.account) return '';
+    return this.getReviewerState() === ReviewerState.REVIEWER
       ? 'Move Reviewer to CC'
       : 'Move CC to Reviewer';
   }
 
-  _handleChangeReviewerOrCCStatus() {
+  private handleChangeReviewerOrCCStatus() {
     assertIsDefined(this.change, 'change');
     // accountKey() throws an error if _account_id & email is not found, which
     // we want to check before showing reloading toast
@@ -170,7 +361,7 @@
       {
         reviewer: _accountKey,
         state:
-          this._getReviewerState(this.account, this.change) === ReviewerState.CC
+          this.getReviewerState() === ReviewerState.CC
             ? ReviewerState.REVIEWER
             : ReviewerState.CC,
       },
@@ -181,15 +372,14 @@
       .then(response => {
         if (!response || !response.ok) {
           throw new Error(
-            'something went wrong when toggling' +
-              this._getReviewerState(this.account, this.change!)
+            'something went wrong when toggling' + this.getReviewerState()
           );
         }
         this.dispatchEventThroughTarget('reload', {clearPatchset: true});
       });
   }
 
-  _handleRemoveReviewerOrCC() {
+  private handleRemoveReviewerOrCC() {
     if (!this.change || !(this.account?._account_id || this.account?.email))
       throw new Error('Missing change or account.');
     this.dispatchEventThroughTarget('show-alert', {
@@ -209,25 +399,21 @@
       });
   }
 
-  _computeShowLabelNeedsAttention() {
-    return this.isAttentionEnabled && this.hasUserAttention;
-  }
-
-  _computeShowActionAddToAttentionSet() {
+  private computeShowActionAddToAttentionSet() {
     const involvedOrSelf =
       isInvolved(this.change, this._selfAccount) ||
       isSelf(this.account, this._selfAccount);
     return involvedOrSelf && this.isAttentionEnabled && !this.hasUserAttention;
   }
 
-  _computeShowActionRemoveFromAttentionSet() {
+  private computeShowActionRemoveFromAttentionSet() {
     const involvedOrSelf =
       isInvolved(this.change, this._selfAccount) ||
       isSelf(this.account, this._selfAccount);
     return involvedOrSelf && this.isAttentionEnabled && this.hasUserAttention;
   }
 
-  _handleClickAddToAttentionSet() {
+  private handleClickAddToAttentionSet(e: MouseEvent) {
     if (!this.change || !this.account._account_id) return;
     this.dispatchEventThroughTarget('show-alert', {
       message: 'Saving attention set update ...',
@@ -236,28 +422,29 @@
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const selfName = getDisplayName(this._config, this._selfAccount);
-    const reason = `Added by ${selfName} using the hovercard menu`;
+    const reason = getAddedByReason(this._selfAccount, this._config);
+
     if (!this.change.attention_set) this.change.attention_set = {};
     this.change.attention_set[this.account._account_id] = {
       account: this.account,
       reason,
+      reason_account: this._selfAccount,
     };
     this.dispatchEventThroughTarget('attention-set-updated');
 
     this.reporting.reportInteraction(
       'attention-hovercard-add',
-      this._reportingDetails()
+      this.reportingDetails()
     );
     this.restApiService
       .addToAttentionSet(this.change._number, this.account._account_id, reason)
       .then(() => {
         this.dispatchEventThroughTarget('hide-alert');
       });
-    this.hide();
+    this.hide(e);
   }
 
-  _handleClickRemoveFromAttentionSet() {
+  private handleClickRemoveFromAttentionSet(e: MouseEvent) {
     if (!this.change || !this.account._account_id) return;
     this.dispatchEventThroughTarget('show-alert', {
       message: 'Saving attention set update ...',
@@ -266,15 +453,15 @@
 
     // We are deliberately updating the UI before making the API call. It is a
     // risk that we are taking to achieve a better UX for 99.9% of the cases.
-    const selfName = getDisplayName(this._config, this._selfAccount);
-    const reason = `Removed by ${selfName} using the hovercard menu`;
+
+    const reason = getRemovedByReason(this._selfAccount, this._config);
     if (this.change.attention_set)
       delete this.change.attention_set[this.account._account_id];
     this.dispatchEventThroughTarget('attention-set-updated');
 
     this.reporting.reportInteraction(
       'attention-hovercard-remove',
-      this._reportingDetails()
+      this.reportingDetails()
     );
     this.restApiService
       .removeFromAttentionSet(
@@ -285,10 +472,10 @@
       .then(() => {
         this.dispatchEventThroughTarget('hide-alert');
       });
-    this.hide();
+    this.hide(e);
   }
 
-  _reportingDetails() {
+  private reportingDetails() {
     const targetId = this.account._account_id;
     const ownerId =
       (this.change && this.change.owner && this.change.owner._account_id) || -1;
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
deleted file mode 100644
index d0986de..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard-account/gr-hovercard-account_html.ts
+++ /dev/null
@@ -1,193 +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 '../gr-hovercard/gr-hovercard-shared-style';
-import {html} from '@polymer/polymer/lib/utils/html-tag';
-
-export const htmlTemplate = html`
-  <style include="gr-hovercard-shared-style">
-    .top,
-    .attention,
-    .status,
-    .voteable {
-      padding: var(--spacing-s) var(--spacing-l);
-    }
-    .top {
-      display: flex;
-      padding-top: var(--spacing-xl);
-      min-width: 300px;
-    }
-    gr-avatar {
-      height: 48px;
-      width: 48px;
-      margin-right: var(--spacing-l);
-    }
-    .title,
-    .email {
-      color: var(--deemphasized-text-color);
-    }
-    .action {
-      border-top: 1px solid var(--border-color);
-      padding: var(--spacing-s) var(--spacing-l);
-      --gr-button: {
-        padding: var(--spacing-s) var(--spacing-m);
-      }
-    }
-    .attention {
-      background-color: var(--emphasis-color);
-    }
-    .attention a {
-      text-decoration: none;
-    }
-    iron-icon {
-      vertical-align: top;
-    }
-    .status iron-icon {
-      width: 14px;
-      height: 14px;
-      position: relative;
-      top: 2px;
-    }
-    iron-icon.attentionIcon {
-      width: 14px;
-      height: 14px;
-      position: relative;
-      top: 3px;
-    }
-    .reason {
-      padding-top: var(--spacing-s);
-    }
-  </style>
-  <div id="container" role="tooltip" tabindex="-1">
-    <template is="dom-if" if="[[_isShowing]]">
-      <div class="top">
-        <div class="avatar">
-          <gr-avatar account="[[account]]" image-size="56"></gr-avatar>
-        </div>
-        <div class="account">
-          <h3 class="name heading-3">[[account.name]]</h3>
-          <div class="email">[[account.email]]</div>
-        </div>
-      </div>
-      <template is="dom-if" if="[[account.status]]">
-        <div class="status">
-          <span class="title">
-            <iron-icon icon="gr-icons:calendar"></iron-icon>
-            Status:
-          </span>
-          <span class="value">[[account.status]]</span>
-        </div>
-      </template>
-      <template is="dom-if" if="[[voteableText]]">
-        <div class="voteable">
-          <span class="title">Voteable:</span>
-          <span class="value">[[voteableText]]</span>
-        </div>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeShowLabelNeedsAttention(_config, highlightAttention, account, change)]]"
-      >
-        <div class="attention">
-          <div>
-            <iron-icon
-              class="attentionIcon"
-              icon="gr-icons:attention"
-            ></iron-icon>
-            <span>
-              [[_computeText(account, _selfAccount)]] turn to take action.
-            </span>
-            <a
-              href="https://gerrit-review.googlesource.com/Documentation/user-attention-set.html"
-              target="_blank"
-            >
-              <iron-icon
-                icon="gr-icons:help-outline"
-                title="read documentation"
-              ></iron-icon>
-            </a>
-          </div>
-          <div class="reason">
-            <span class="title">Reason:</span>
-            <span class="value">[[_computeReason(change)]]</span>
-            <template is="dom-if" if="[[_computeLastUpdate(change)]]">
-              (<gr-date-formatter
-                has-tooltip
-                date-str="[[_computeLastUpdate(change)]]"
-              ></gr-date-formatter
-              >)
-            </template>
-          </div>
-        </div>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeShowActionAddToAttentionSet(_config, highlightAttention, account, change, _selfAccount)]]"
-      >
-        <div class="action">
-          <gr-button
-            class="addToAttentionSet"
-            link=""
-            no-uppercase=""
-            on-click="_handleClickAddToAttentionSet"
-          >
-            Add to attention set
-          </gr-button>
-        </div>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_computeShowActionRemoveFromAttentionSet(_config, highlightAttention, account, change, _selfAccount)]]"
-      >
-        <div class="action">
-          <gr-button
-            class="removeFromAttentionSet"
-            link=""
-            no-uppercase=""
-            on-click="_handleClickRemoveFromAttentionSet"
-          >
-            Remove from attention set
-          </gr-button>
-        </div>
-      </template>
-      <template
-        is="dom-if"
-        if="[[_showReviewerOrCCActions(account, change, _selfAccount)]]"
-      >
-        <div class="action">
-          <gr-button
-            class="removeReviewerOrCC"
-            link=""
-            no-uppercase=""
-            on-click="_handleRemoveReviewerOrCC"
-          >
-            Remove [[_computeReviewerOrCCText(account, change)]]
-          </gr-button>
-        </div>
-        <div class="action">
-          <gr-button
-            class="changeReviewerOrCC"
-            link=""
-            no-uppercase=""
-            on-click="_handleChangeReviewerOrCCStatus"
-          >
-            [[_computeChangeReviewerOrCCText(account, change)]]
-          </gr-button>
-        </div>
-      </template>
-    </template>
-  </div>
-`;
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 1c67e13..5530d7c 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,7 +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';
+import {mockPromise, stubRestApi} from '../../../test/test-utils.js';
 
 const basicFixture = fixtureFromTemplate(html`
 <gr-hovercard-account class="hovered"></gr-hovercard-account>
@@ -37,9 +37,6 @@
 
   setup(async () => {
     stubRestApi('getAccount').returns(Promise.resolve({...ACCOUNT}));
-    stubRestApi('getConfig').returns(
-        Promise.resolve({change: {enable_attention_set: true}})
-    );
     element = basicFixture.instantiate();
     element.account = {...ACCOUNT};
     element.change = {
@@ -60,33 +57,21 @@
         'Kermit The Frog');
   });
 
-  test('_computeLastUpdate', () => {
-    const last_update = '2019-07-17 19:39:02.000000000';
-    const change = {
-      attention_set: {
-        31415926535: {
-          last_update,
-        },
-      },
-    };
-    assert.equal(element._computeLastUpdate(change), last_update);
-  });
-
-  test('_computeText', () => {
-    let account = {_account_id: '1'};
-    const selfAccount = {_account_id: '1'};
-    assert.equal(element._computeText(account, selfAccount), 'Your');
-    account = {_account_id: '2'};
-    assert.equal(element._computeText(account, selfAccount), 'Their');
+  test('computePronoun', () => {
+    element.account = {_account_id: '1'};
+    element._selfAccount = {_account_id: '1'};
+    assert.equal(element.computePronoun(), 'Your');
+    element.account = {_account_id: '2'};
+    assert.equal(element.computePronoun(), 'Their');
   });
 
   test('account status is not shown if the property is not set', () => {
     assert.isNull(element.shadowRoot.querySelector('.status'));
   });
 
-  test('account status is displayed', () => {
+  test('account status is displayed', async () => {
     element.account = {status: 'OOO', ...ACCOUNT};
-    flush();
+    await element.updateComplete;
     assert.equal(element.shadowRoot.querySelector('.status .value').innerText,
         'OOO');
   });
@@ -95,9 +80,9 @@
     assert.isNull(element.shadowRoot.querySelector('.voteable'));
   });
 
-  test('voteable div is displayed', () => {
+  test('voteable div is displayed', async () => {
     element.voteableText = 'CodeReview: +2';
-    flush();
+    await element.updateComplete;
     assert.equal(element.shadowRoot.querySelector('.voteable .value').innerText,
         element.voteableText);
   });
@@ -109,15 +94,15 @@
         [ReviewerState.REVIEWER]: [ACCOUNT],
       },
     };
+    await element.updateComplete;
     stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
-    flush();
     const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
     assert.isOk(button);
     assert.equal(button.innerText, 'Remove Reviewer');
     MockInteractions.tap(button);
-    await flush();
+    await element.updateComplete;
     assert.isTrue(reloadListener.called);
   });
 
@@ -128,6 +113,7 @@
         [ReviewerState.REVIEWER]: [ACCOUNT],
       },
     };
+    await element.updateComplete;
     const saveReviewStub = stubRestApi(
         'saveChangeReview').returns(
         Promise.resolve({ok: true}));
@@ -135,14 +121,12 @@
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
-    flush();
     const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
 
     assert.isOk(button);
     assert.equal(button.innerText, 'Move Reviewer to CC');
     MockInteractions.tap(button);
-    await flush();
-
+    await element.updateComplete;
     assert.isTrue(saveReviewStub.called);
     assert.isTrue(reloadListener.called);
   });
@@ -154,20 +138,19 @@
         [ReviewerState.REVIEWER]: [],
       },
     };
+    await element.updateComplete;
     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();
 
     const button = element.shadowRoot.querySelector('.changeReviewerOrCC');
     assert.isOk(button);
     assert.equal(button.innerText, 'Move CC to Reviewer');
 
     MockInteractions.tap(button);
-    await flush();
-
+    await element.updateComplete;
     assert.isTrue(saveReviewStub.called);
     assert.isTrue(reloadListener.called);
   });
@@ -179,31 +162,26 @@
         [ReviewerState.REVIEWER]: [],
       },
     };
+    await element.updateComplete;
     stubRestApi('removeChangeReviewer').returns(Promise.resolve({ok: true}));
     const reloadListener = sinon.spy();
     element._target.addEventListener('reload', reloadListener);
 
-    flush();
     const button = element.shadowRoot.querySelector('.removeReviewerOrCC');
 
     assert.equal(button.innerText, 'Remove CC');
     assert.isOk(button);
     MockInteractions.tap(button);
-
-    await flush();
-
+    await element.updateComplete;
     assert.isTrue(reloadListener.called);
   });
 
   test('add to attention set', async () => {
-    let apiResolve;
-    const apiPromise = new Promise(r => {
-      apiResolve = r;
-    });
-    stubRestApi('addToAttentionSet').returns(apiPromise);
+    const apiPromise = mockPromise();
+    const apiSpy = stubRestApi('addToAttentionSet').returns(apiPromise);
     element.highlightAttention = true;
     element._target = document.createElement('div');
-    flush();
+    await element.updateComplete;
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
@@ -217,22 +195,28 @@
     MockInteractions.tap(button);
 
     assert.equal(Object.keys(element.change.attention_set).length, 1);
+    const attention_set_info = Object.values(element.change.attention_set)[0];
+    assert.equal(attention_set_info.reason,
+        `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
+        + ` using the hovercard menu`);
+    assert.equal(attention_set_info.reason_account._account_id,
+        ACCOUNT._account_id);
     assert.isTrue(showAlertListener.called, 'showAlertListener was called');
     assert.isTrue(updatedListener.called, 'updatedListener was called');
     assert.isFalse(element._isShowing, 'hovercard is hidden');
 
-    apiResolve({});
-    await flush();
-
+    apiPromise.resolve({});
+    await element.updateComplete;
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(apiSpy.lastCall.args[2],
+        `Added by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
+        + ` using the hovercard menu`);
     assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
   });
 
   test('remove from attention set', async () => {
-    let apiResolve;
-    const apiPromise = new Promise(r => {
-      apiResolve = r;
-    });
-    stubRestApi('removeFromAttentionSet').returns(apiPromise);
+    const apiPromise = mockPromise();
+    const apiSpy = stubRestApi('removeFromAttentionSet').returns(apiPromise);
     element.highlightAttention = true;
     element.change = {
       attention_set: {31415926535: {}},
@@ -240,7 +224,7 @@
       owner: {...ACCOUNT},
     };
     element._target = document.createElement('div');
-    flush();
+    await element.updateComplete;
     const showAlertListener = sinon.spy();
     const hideAlertListener = sinon.spy();
     const updatedListener = sinon.spy();
@@ -258,9 +242,13 @@
     assert.isTrue(updatedListener.called, 'updatedListener was called');
     assert.isFalse(element._isShowing, 'hovercard is hidden');
 
-    apiResolve({});
-    await flush();
+    apiPromise.resolve({});
+    await element.updateComplete;
 
+    assert.isTrue(apiSpy.calledOnce);
+    assert.equal(apiSpy.lastCall.args[2],
+        `Removed by <GERRIT_ACCOUNT_${ACCOUNT._account_id}>`
+        + ` using the hovercard menu`);
     assert.isTrue(hideAlertListener.called, 'hideAlertListener was called');
   });
 });
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
deleted file mode 100644
index 48de78e..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-behavior.ts
+++ /dev/null
@@ -1,494 +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 '../../../styles/shared-styles';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {getRootElement} from '../../../scripts/rootElement';
-import {Constructor} from '../../../utils/common-util';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
-import {property, observe} from '@polymer/decorators';
-import {
-  pushScrollLock,
-  removeScrollLock,
-} from '@polymer/iron-overlay-behavior/iron-scroll-manager';
-import {ShowAlertEventDetail} from '../../../types/events';
-import {debounce, DelayedTask} from '../../../utils/async-util';
-interface ReloadEventDetail {
-  clearPatchset?: boolean;
-}
-
-const HOVER_CLASS = 'hovered';
-const HIDE_CLASS = 'hide';
-
-/**
- * ID for the container element.
- */
-const containerId = 'gr-hovercard-container';
-
-export function getHovercardContainer(
-  options: {createIfNotExists: boolean} = {createIfNotExists: false}
-): HTMLElement | null {
-  let container = getRootElement().querySelector<HTMLElement>(
-    `#${containerId}`
-  );
-  if (!container && options.createIfNotExists) {
-    // If it does not exist, create and initialize the hovercard container.
-    container = document.createElement('div');
-    container.setAttribute('id', containerId);
-    getRootElement().appendChild(container);
-  }
-  return container;
-}
-
-/**
- * How long should we wait before showing the hovercard when the user hovers
- * over the element?
- */
-const SHOW_DELAY_MS = 550;
-
-/**
- * How long should we wait before hiding the hovercard when the user moves from
- * target to the hovercard.
- *
- * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
- */
-const HIDE_DELAY_MS = 500;
-
-/**
- * The mixin for gr-hovercard-behavior.
- *
- * @example
- *
- * class YourComponent extends hovercardBehaviorMixin(
- *  PolymerElement
- *
- * @see gr-hovercard.ts
- *
- * // following annotations are required for polylint
- * @polymer
- * @mixinFunction
- */
-export const hovercardBehaviorMixin = dedupingMixin(
-  <T extends Constructor<PolymerElement>>(
-    superClass: T
-  ): T & Constructor<GrHovercardBehaviorInterface> => {
-    /**
-     * @polymer
-     * @mixinClass
-     */
-    class Mixin extends superClass {
-      @property({type: Object})
-      _target: HTMLElement | null = null;
-
-      // Determines whether or not the hovercard is visible.
-      @property({type: Boolean})
-      _isShowing = false;
-
-      // The `id` of the element that the hovercard is anchored to.
-      @property({type: String})
-      for?: string;
-
-      /**
-       * The spacing between the top of the hovercard and the element it is
-       * anchored to.
-       */
-      @property({type: Number})
-      offset = 14;
-
-      /**
-       * Positions the hovercard to the top, right, bottom, left, bottom-left,
-       * bottom-right, top-left, or top-right of its content.
-       */
-      @property({type: String})
-      position = 'right';
-
-      @property({type: Object})
-      container: HTMLElement | null = null;
-
-      private hideTask?: DelayedTask;
-
-      private showTask?: DelayedTask;
-
-      private isScheduledToShow?: boolean;
-
-      private isScheduledToHide?: boolean;
-
-      /** @override */
-      connectedCallback() {
-        super.connectedCallback();
-        if (!this._target) {
-          this._target = this.target;
-        }
-        this._target.addEventListener('mouseenter', this.debounceShow);
-        this._target.addEventListener('focus', this.debounceShow);
-        this._target.addEventListener('mouseleave', this.debounceHide);
-        this._target.addEventListener('blur', this.debounceHide);
-
-        // when click, dismiss immediately
-        this._target.addEventListener('click', this.hide);
-
-        // show the hovercard if mouse moves to hovercard
-        // this will cancel pending hide as well
-        this.addEventListener('mouseenter', this.show);
-        this.addEventListener('mouseenter', this.lock);
-        // when leave hovercard, hide it immediately
-        this.addEventListener('mouseleave', this.hide);
-        this.addEventListener('mouseleave', this.unlock);
-      }
-
-      disconnectedCallback() {
-        this.cancelShowTask();
-        this.cancelHideTask();
-        this.unlock();
-        super.disconnectedCallback();
-      }
-
-      /** @override */
-      ready() {
-        super.ready();
-        // First, check to see if the container has already been created.
-        this.container = getHovercardContainer({createIfNotExists: true});
-      }
-
-      removeListeners() {
-        this._target?.removeEventListener('mouseenter', this.debounceShow);
-        this._target?.removeEventListener('focus', this.debounceShow);
-        this._target?.removeEventListener('mouseleave', this.debounceHide);
-        this._target?.removeEventListener('blur', this.debounceHide);
-        this._target?.removeEventListener('click', this.hide);
-      }
-
-      readonly debounceHide = () => {
-        this.cancelShowTask();
-        if (!this._isShowing || this.isScheduledToHide) return;
-        this.isScheduledToHide = true;
-        this.hideTask = debounce(
-          this.hideTask,
-          () => {
-            // This happens when hide immediately through click or mouse leave
-            // on the hovercard
-            if (!this.isScheduledToHide) return;
-            this.hide();
-          },
-          HIDE_DELAY_MS
-        );
-      };
-
-      cancelHideTask() {
-        if (this.hideTask) {
-          this.hideTask.cancel();
-          this.isScheduledToHide = false;
-        }
-      }
-
-      /**
-       * Hovercard elements are created outside of <gr-app>, so if you want to fire
-       * events, then you probably want to do that through the target element.
-       */
-
-      dispatchEventThroughTarget(eventName: string): void;
-
-      dispatchEventThroughTarget(
-        eventName: 'show-alert',
-        detail: ShowAlertEventDetail
-      ): void;
-
-      dispatchEventThroughTarget(
-        eventName: 'reload',
-        detail: ReloadEventDetail
-      ): void;
-
-      dispatchEventThroughTarget(eventName: string, detail?: unknown) {
-        if (!detail) detail = {};
-        if (this._target)
-          this._target.dispatchEvent(
-            new CustomEvent(eventName, {
-              detail,
-              bubbles: true,
-              composed: true,
-            })
-          );
-      }
-
-      /**
-       * Returns the target element that the hovercard is anchored to (the `id` of
-       * the `for` property).
-       */
-      get target(): HTMLElement {
-        const parentNode = this.parentNode;
-        // If the parentNode is a document fragment, then we need to use the host.
-        const ownerRoot = this.getRootNode() as ShadowRoot;
-        let target;
-        if (this.for) {
-          target = ownerRoot.querySelector('#' + this.for);
-        } else {
-          target =
-            !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
-              ? ownerRoot.host
-              : parentNode;
-        }
-        return target as HTMLElement;
-      }
-
-      /**
-       * unlock scroll, this will resume the scroll outside of the hovercard.
-       */
-      readonly unlock = () => {
-        removeScrollLock(this);
-      };
-
-      /**
-       * Hides/closes the hovercard. This occurs when the user triggers the
-       * `mouseleave` event on the hovercard's `target` element (as long as the
-       * user is not hovering over the hovercard).
-       *
-       */
-      readonly hide = (e?: MouseEvent) => {
-        this.cancelHideTask();
-        this.cancelShowTask();
-        if (!this._isShowing) {
-          return;
-        }
-
-        // If the user is now hovering over the hovercard or the user is returning
-        // from the hovercard but now hovering over the target (to stop an annoying
-        // flicker effect), just return.
-        if (e) {
-          if (
-            e.relatedTarget === this ||
-            (e.target === this && e.relatedTarget === this._target)
-          ) {
-            return;
-          }
-        }
-
-        // Mark that the hovercard is not visible and do not allow focusing
-        this._isShowing = false;
-
-        // Clear styles in preparation for the next time we need to show the card
-        this.classList.remove(HOVER_CLASS);
-
-        // Reset and remove the hovercard from the DOM
-        this.style.cssText = '';
-        this.$['container'].setAttribute('tabindex', '-1');
-
-        // Remove the hovercard from the container, given that it is still a child
-        // of the container.
-        if (this.container?.contains(this)) {
-          this.container.removeChild(this);
-        }
-      };
-
-      /**
-       * Shows/opens the hovercard with a fixed delay.
-       */
-      readonly debounceShow = () => {
-        this.debounceShowBy(SHOW_DELAY_MS);
-      };
-
-      /**
-       * Shows/opens the hovercard with the given delay.
-       */
-      debounceShowBy(delayMs: number) {
-        this.cancelHideTask();
-        if (this._isShowing || this.isScheduledToShow) return;
-        this.isScheduledToShow = true;
-        this.showTask = debounce(
-          this.showTask,
-          () => {
-            // This happens when the mouse leaves the target before the delay is over.
-            if (!this.isScheduledToShow) return;
-            this.show();
-          },
-          delayMs
-        );
-      }
-
-      cancelShowTask() {
-        if (this.showTask) {
-          this.showTask.cancel();
-          this.isScheduledToShow = false;
-        }
-      }
-
-      /**
-       * Lock background scroll but enable scroll inside of current hovercard.
-       */
-      readonly lock = () => {
-        pushScrollLock(this);
-      };
-
-      /**
-       * Shows/opens the hovercard. This occurs when the user triggers the
-       * `mousenter` event on the hovercard's `target` element.
-       */
-      readonly show = () => {
-        this.cancelHideTask();
-        this.cancelShowTask();
-        if (this._isShowing || !this.container) {
-          return;
-        }
-
-        // Mark that the hovercard is now visible
-        this._isShowing = true;
-        this.setAttribute('tabindex', '0');
-
-        // Add it to the DOM and calculate its position
-        this.container.appendChild(this);
-        // We temporarily hide the hovercard until we have found the correct
-        // position for it.
-        this.classList.add(HIDE_CLASS);
-        this.classList.add(HOVER_CLASS);
-        // Make sure that the hovercard actually rendered and all dom-if
-        // statements processed, so that we can measure the (invisible)
-        // hovercard properly in updatePosition().
-        flush();
-        this.updatePosition();
-        this.classList.remove(HIDE_CLASS);
-      };
-
-      updatePosition() {
-        const positionsToTry = new Set([
-          this.position,
-          'right',
-          'bottom-right',
-          'top-right',
-          'bottom',
-          'top',
-          'bottom-left',
-          'top-left',
-          'left',
-        ]);
-        for (const position of positionsToTry) {
-          this.updatePositionTo(position);
-          if (this._isInsideViewport()) return;
-        }
-        console.warn('Could not find a visible position for the hovercard.');
-      }
-
-      _isInsideViewport() {
-        const thisRect = this.getBoundingClientRect();
-        if (thisRect.top < 0) return false;
-        if (thisRect.left < 0) return false;
-        const docuRect = document.documentElement.getBoundingClientRect();
-        if (thisRect.bottom > docuRect.height) return false;
-        if (thisRect.right > docuRect.width) return false;
-        return true;
-      }
-
-      /**
-       * Updates the hovercard's position based the current position of the `target`
-       * element.
-       *
-       * The hovercard is supposed to stay open if the user hovers over it.
-       * To keep it open when the user moves away from the target, the bounding
-       * rects of the target and hovercard must touch or overlap.
-       *
-       * NOTE: You do not need to directly call this method unless you need to
-       * update the position of the tooltip while it is already visible (the
-       * target element has moved and the tooltip is still open).
-       */
-      updatePositionTo(position: string) {
-        if (!this._target) {
-          return;
-        }
-
-        // Make sure that thisRect will not get any paddings and such included
-        // in the width and height of the bounding client rect.
-        this.style.cssText = '';
-
-        const docuRect = document.documentElement.getBoundingClientRect();
-        const targetRect = this._target.getBoundingClientRect();
-        const thisRect = this.getBoundingClientRect();
-
-        const targetLeft = targetRect.left - docuRect.left;
-        const targetTop = targetRect.top - docuRect.top;
-
-        let hovercardLeft;
-        let hovercardTop;
-
-        switch (position) {
-          case 'top':
-            hovercardLeft =
-              targetLeft + (targetRect.width - thisRect.width) / 2;
-            hovercardTop = targetTop - thisRect.height - this.offset;
-            break;
-          case 'bottom':
-            hovercardLeft =
-              targetLeft + (targetRect.width - thisRect.width) / 2;
-            hovercardTop = targetTop + targetRect.height + this.offset;
-            break;
-          case 'left':
-            hovercardLeft = targetLeft - thisRect.width - this.offset;
-            hovercardTop =
-              targetTop + (targetRect.height - thisRect.height) / 2;
-            break;
-          case 'right':
-            hovercardLeft = targetLeft + targetRect.width + this.offset;
-            hovercardTop =
-              targetTop + (targetRect.height - thisRect.height) / 2;
-            break;
-          case 'bottom-right':
-            hovercardLeft = targetLeft + targetRect.width + this.offset;
-            hovercardTop = targetTop;
-            break;
-          case 'bottom-left':
-            hovercardLeft = targetLeft - thisRect.width - this.offset;
-            hovercardTop = targetTop;
-            break;
-          case 'top-left':
-            hovercardLeft = targetLeft - thisRect.width - this.offset;
-            hovercardTop = targetTop + targetRect.height - thisRect.height;
-            break;
-          case 'top-right':
-            hovercardLeft = targetLeft + targetRect.width + this.offset;
-            hovercardTop = targetTop + targetRect.height - thisRect.height;
-            break;
-        }
-
-        this.style.left = `${hovercardLeft}px`;
-        this.style.top = `${hovercardTop}px`;
-      }
-
-      /**
-       * Responds to a change in the `for` value and gets the updated `target`
-       * element for the hovercard.
-       */
-      @observe('for')
-      _forChanged() {
-        this._target = this.target;
-      }
-    }
-
-    return Mixin;
-  }
-);
-
-export interface GrHovercardBehaviorInterface {
-  ready(): void;
-  removeListeners(): void;
-  debounceHide(): void;
-  cancelHideTask(): void;
-  dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
-  hide(e?: MouseEvent): void;
-  debounceShow(): void;
-  debounceShowBy(delayMs: number): void;
-  cancelShowTask(): void;
-  show(): void;
-  updatePosition(): void;
-  updatePositionTo(position: string): void;
-}
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
deleted file mode 100644
index aa92654..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard-shared-style.ts
+++ /dev/null
@@ -1,51 +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.
- */
-
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
-
-/** The shared styles for all hover cards. */
-const GrHoverCardSharedStyle = document.createElement('dom-module');
-GrHoverCardSharedStyle.innerHTML = `<template>
-    <style include="shared-styles">
-      :host {
-        position: absolute;
-        display: none;
-        z-index: 200;
-        max-width: 600px;
-        outline: none;
-      }
-      :host(.hovered) {
-        display: block;
-      }
-      :host(.hide) {
-        visibility: hidden;
-      }
-      /* You have to use a <div class="container"> in your hovercard in order
-         to pick up this consistent styling. */
-      #container {
-        background: var(--dialog-background-color);
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        box-shadow: var(--elevation-level-5);
-      }
-    </style>
-  </template>`;
-
-GrHoverCardSharedStyle.register('gr-hovercard-shared-style');
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
index 8857d36..bf35c06 100644
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
+++ b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard.ts
@@ -15,17 +15,32 @@
  * limitations under the License.
  */
 
-import '../../../styles/shared-styles';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-hovercard_html';
-import {hovercardBehaviorMixin} from './gr-hovercard-behavior';
-import './gr-hovercard-shared-style';
-import {customElement} from '@polymer/decorators';
+import {customElement} from 'lit/decorators';
+import {HovercardMixin} from '../../../mixins/hovercard-mixin/hovercard-mixin';
+import {css, html, LitElement} from 'lit';
+
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = HovercardMixin(LitElement);
 
 @customElement('gr-hovercard')
-export class GrHovercard extends hovercardBehaviorMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
+export class GrHovercard extends base {
+  static override get styles() {
+    return [
+      base.styles ?? [],
+      css`
+        #container {
+          padding: var(--spacing-l);
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <div id="container" role="tooltip" tabindex="-1">
+        <slot></slot>
+      </div>
+    `;
   }
 }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts b/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
deleted file mode 100644
index 830cbd878..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_html.ts
+++ /dev/null
@@ -1,28 +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="gr-hovercard-shared-style">
-    #container {
-      padding: var(--spacing-l);
-    }
-  </style>
-  <div id="container" role="tooltip" tabindex="-1">
-    <slot></slot>
-  </div>
-`;
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
deleted file mode 100644
index 27ef23f..0000000
--- a/polygerrit-ui/app/elements/shared/gr-hovercard/gr-hovercard_test.js
+++ /dev/null
@@ -1,166 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-hovercard.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-hovercard for="foo" id="bar"></gr-hovercard>
-`);
-
-suite('gr-hovercard tests', () => {
-  let element;
-
-  let button;
-  let testResolve;
-  let testPromise;
-
-  setup(() => {
-    testResolve = undefined;
-    testPromise = new Promise(r => testResolve = r);
-    button = document.createElement('button');
-    button.innerHTML = 'Hello';
-    button.setAttribute('id', 'foo');
-    document.body.appendChild(button);
-
-    element = basicFixture.instantiate();
-  });
-
-  teardown(() => {
-    element.hide({});
-    button.remove();
-  });
-
-  test('updatePosition', () => {
-    // Test that the correct style properties have at least been set.
-    element.position = 'bottom';
-    element.updatePosition();
-    assert.typeOf(element.style.getPropertyValue('left'), 'string');
-    assert.typeOf(element.style.getPropertyValue('top'), 'string');
-    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
-    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
-
-    const parentRect = document.documentElement.getBoundingClientRect();
-    const targetRect = element._target.getBoundingClientRect();
-    const thisRect = element.getBoundingClientRect();
-
-    const targetLeft = targetRect.left - parentRect.left;
-    const targetTop = targetRect.top - parentRect.top;
-
-    const pixelCompare = pixel =>
-      Math.round(parseInt(pixel.substring(0, pixel.length - 1)), 10);
-
-    assert.equal(
-        pixelCompare(element.style.left),
-        pixelCompare(
-            (targetLeft + (targetRect.width - thisRect.width) / 2) + 'px'));
-    assert.equal(
-        pixelCompare(element.style.top),
-        pixelCompare(
-            (targetTop + targetRect.height + element.offset) + 'px'));
-  });
-
-  test('hide', () => {
-    element.hide({});
-    const style = getComputedStyle(element);
-    assert.isFalse(element._isShowing);
-    assert.isFalse(element.classList.contains('hovered'));
-    assert.equal(style.display, 'none');
-    assert.notEqual(element.container, element.parentNode);
-  });
-
-  test('show', () => {
-    element.show({});
-    const style = getComputedStyle(element);
-    assert.isTrue(element._isShowing);
-    assert.isTrue(element.classList.contains('hovered'));
-    assert.equal(style.opacity, '1');
-    assert.equal(style.visibility, 'visible');
-  });
-
-  test('debounceShow does not show immediately', async () => {
-    element.debounceShowBy(100);
-    setTimeout(testResolve, 0);
-    await testPromise;
-    assert.isFalse(element._isShowing);
-  });
-
-  test('debounceShow shows after delay', async () => {
-    element.debounceShowBy(1);
-    setTimeout(testResolve, 10);
-    await testPromise;
-    assert.isTrue(element._isShowing);
-  });
-
-  test('card is scheduled to show on enter and hides on leave', async () => {
-    const button = document.querySelector('button');
-    let enterResolve = undefined;
-    const enterPromise = new Promise(r => enterResolve = r);
-    button.addEventListener('mouseenter', enterResolve);
-    let leaveResolve = undefined;
-    const leavePromise = new Promise(r => leaveResolve = r);
-    button.addEventListener('mouseleave', leaveResolve);
-
-    assert.isFalse(element._isShowing);
-    button.dispatchEvent(new CustomEvent('mouseenter'));
-
-    await enterPromise;
-    assert.isTrue(element.isScheduledToShow);
-    element.showTask.flush();
-    assert.isTrue(element._isShowing);
-    assert.isFalse(element.isScheduledToShow);
-
-    button.dispatchEvent(new CustomEvent('mouseleave'));
-
-    await leavePromise;
-    assert.isTrue(element.isScheduledToHide);
-    assert.isTrue(element._isShowing);
-    element.hideTask.flush();
-    assert.isFalse(element.isScheduledToShow);
-    assert.isFalse(element._isShowing);
-
-    button.removeEventListener('mouseenter', enterResolve);
-    button.removeEventListener('mouseleave', leaveResolve);
-  });
-
-  test('card should disappear on click', async () => {
-    const button = document.querySelector('button');
-    let enterResolve = undefined;
-    const enterPromise = new Promise(r => enterResolve = r);
-    button.addEventListener('mouseenter', enterResolve);
-    let clickResolve = undefined;
-    const clickPromise = new Promise(r => clickResolve = r);
-    button.addEventListener('click', clickResolve);
-
-    assert.isFalse(element._isShowing);
-
-    button.dispatchEvent(new CustomEvent('mouseenter'));
-
-    await enterPromise;
-    assert.isTrue(element.isScheduledToShow);
-    MockInteractions.tap(button);
-
-    await clickPromise;
-    assert.isFalse(element.isScheduledToShow);
-    assert.isFalse(element._isShowing);
-
-    button.removeEventListener('mouseenter', enterResolve);
-    button.removeEventListener('click', clickResolve);
-  });
-});
-
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 2259ca1..1a6239f 100644
--- a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.ts
@@ -44,6 +44,8 @@
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="chevron-right"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
+      <g id="more-horiz"><path d="M6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="more-vert"><path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"></path></g>
       <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.js -->
       <g id="deleteEdit"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"></path></g>
@@ -140,8 +142,26 @@
       <g id="download"><path d="M0 0h24v24H0z" fill="none"/><path d="M5,20h14v-2H5V20z M19,9h-4V3H9v6H5l7,7L19,9z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#system_update-->
       <g id="system-update"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-2 .9-2 2v18c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14zm-1-6h-3V8h-2v5H8l4 4 4-4z"/></g>
+      <!-- This SVG is a copy from material.io https://material.io/icons/#swap_horiz-->
+      <g id="swapHoriz"><path d="M0 0h24v24H0z" fill="none"/><path d="M6.99 11L3 15l3.99 4v-3H14v-2H6.99v-3zM21 9l-3.99-4v3H10v2h7.01v3L21 9z"/></g>
       <!-- This SVG is a copy from material.io https://material.io/icons/#link-->
       <g id="link"><path d="M0 0h24v24H0z" fill="none"/><path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aplay_arrow-->
+      <g id="playArrow"><path d="M0 0h24v24H0z" fill="none"/><path d="M8 5v14l11-7z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Apause-->
+      <g id="pause"><path d="M0 0h24v24H0z" fill="none"/><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Acode-->
+      <g id="code"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Afile_present-->
+      <g id="file-present"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M15 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V7l-5-5zM6 20V4h8v4h4v12H6zm10-10v5c0 2.21-1.79 4-4 4s-4-1.79-4-4V8.5c0-1.47 1.26-2.64 2.76-2.49 1.3.13 2.24 1.32 2.24 2.63V15h-2V8.5c0-.28-.22-.5-.5-.5s-.5.22-.5.5V15c0 1.1.9 2 2 2s2-.9 2-2v-5h2z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material%20Icons%3Aarrow_forward-->
+      <g id="arrow-forward"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons:feedback -->
+      <g id="feedback"><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-7 12h-2v-2h2v2zm0-4h-2V6h2v4z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=description -->
+      <g id="description"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z"/></g>
+      <!-- This SVG is a copy from material.io https://fonts.google.com/icons?selected=Material+Icons&icon.query=settings_backup_restore and 0.65 scale and 4 translate https://fonts.google.com/icons?selected=Material+Icons&icon.query=done-->
+      <g id="overridden"><path xmlns="http://www.w3.org/2000/svg" d="M0 0h24v24H0V0z" fill="none"/><path xmlns="http://www.w3.org/2000/svg" d="M12 15 zM2 4v6h6V8H5.09C6.47 5.61 9.04 4 12 4c4.42 0 8 3.58 8 8s-3.58 8-8 8-8-3.58-8-8H2c0 5.52 4.48 10 10.01 10C17.53 22 22 17.52 22 12S17.53 2 12.01 2C8.73 2 5.83 3.58 4 6.01V4H2z"/><path xmlns="http://www.w3.org/2000/svg" d="M9.85 14.53 7.12 11.8l-.91.91L9.85 16.35 17.65 8.55l-.91-.91L9.85 14.53z"/></g>
     </defs>
   </svg>
 </iron-iconset-svg>`;
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 3f75970..82c7118 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
@@ -42,7 +42,9 @@
   ): GrAnnotationActionsInterface {
     this.reporting.trackApi(this.plugin, 'annotation', 'setCoverageProvider');
     if (this.coverageProvider) {
-      console.warn('Overwriting an existing coverage provider.');
+      this.reporting.error(
+        new Error(`Overwriting cov provider: ${this.plugin.getPluginName()}`)
+      );
     }
     this.coverageProvider = coverageProvider;
     return this;
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 db127f3..36e5c25 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
@@ -20,7 +20,6 @@
 import {RequestPayload} from '../../../types/common';
 import {appContext} from '../../../services/app-context';
 
-export const PRELOADED_PROTOCOL = 'preloaded:';
 export const PLUGIN_LOADING_TIMEOUT_MS = 10000;
 
 /**
@@ -35,9 +34,6 @@
       return null;
     }
   }
-  if (url.protocol === PRELOADED_PROTOCOL) {
-    return url.pathname;
-  }
   const base = getBaseUrl();
   let pathname = url.pathname.replace(base, '');
   // Load from ASSETS_PATH
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
similarity index 65%
rename from polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
rename to polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
index a09d887..865aa20 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-api-utils_test.ts
@@ -15,11 +15,9 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-js-api-interface.js';
-import {getPluginNameFromUrl} from './gr-api-utils.js';
-
-const PRELOADED_PROTOCOL = 'preloaded:';
+import '../../../test/common-test-setup-karma';
+import './gr-js-api-interface';
+import {getPluginNameFromUrl} from './gr-api-utils';
 
 suite('gr-api-utils tests', () => {
   suite('test getPluginNameFromUrl', () => {
@@ -34,41 +32,36 @@
     test('with random invalid url', () => {
       assert.equal(getPluginNameFromUrl('http://example.com'), null);
       assert.equal(
-          getPluginNameFromUrl('http://example.com/static/a.js'),
-          null
+        getPluginNameFromUrl('http://example.com/static/a.js'),
+        null
       );
     });
 
     test('with valid urls', () => {
       assert.equal(
-          getPluginNameFromUrl('http://example.com/plugins/a.js'),
-          'a'
+        getPluginNameFromUrl('http://example.com/plugins/a.js'),
+        'a'
       );
       assert.equal(
-          getPluginNameFromUrl('http://example.com/plugins/a/static/t.js'),
-          'a'
+        getPluginNameFromUrl('http://example.com/plugins/a/static/t.js'),
+        'a'
       );
     });
 
-    test('with preloaded urls', () => {
-      assert.equal(getPluginNameFromUrl(`${PRELOADED_PROTOCOL}a`), 'a');
-    });
-
     test('with gerrit-theme override', () => {
       assert.equal(
-          getPluginNameFromUrl('http://example.com/static/gerrit-theme.js'),
-          'gerrit-theme'
+        getPluginNameFromUrl('http://example.com/static/gerrit-theme.js'),
+        'gerrit-theme'
       );
     });
 
     test('with ASSETS_PATH', () => {
       window.ASSETS_PATH = 'http://cdn.com/2';
       assert.equal(
-          getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.js`),
-          'a'
+        getPluginNameFromUrl(`${window.ASSETS_PATH}/plugins/a.js`),
+        'a'
       );
       window.ASSETS_PATH = 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 15d4680..a33b145 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
@@ -80,7 +80,7 @@
    */
   private setEl(el?: GrChangeActionsElement) {
     if (!el) {
-      console.warn('changeActions() is not ready');
+      this.reporting.error(new Error('changeActions() API is not ready'));
       return;
     }
     this.el = el;
@@ -90,13 +90,13 @@
    * Ensure GrChangeActionsInterface instance has access to gr-change-actions
    * element and retrieve if the interface was created before element.
    */
-  private ensureEl(): GrChangeActionsElement {
+  ensureEl(): GrChangeActionsElement {
     if (!this.el) {
       const sharedApiElement = appContext.jsApiService;
       this.setEl(
-        (sharedApiElement.getElement(
+        sharedApiElement.getElement(
           TargetElement.CHANGE_ACTIONS
-        ) as unknown) as GrChangeActionsElement
+        ) as unknown as GrChangeActionsElement
       );
     }
     return this.el!;
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
index 203784d..87f6052 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.js
@@ -125,7 +125,7 @@
           .querySelector('[data-action-key="' + key + '"]'));
     });
 
-    test('action button properties', () => {
+    test('action button properties', async () => {
       const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
       flush();
       const button = element.shadowRoot
@@ -137,17 +137,17 @@
       changeActions.setTitle(key, 'Yo hint');
       changeActions.setEnabled(key, false);
       changeActions.setIcon(key, 'pupper');
-      flush();
+      await flush();
       assert.equal(button.getAttribute('data-label'), 'Yo');
-      assert.equal(button.getAttribute('title'), 'Yo hint');
+      assert.equal(button.parentElement.getAttribute('title'), 'Yo hint');
       assert.isTrue(button.disabled);
       assert.equal(button.querySelector('iron-icon').icon,
           'gr-icons:pupper');
     });
 
-    test('hide action buttons', () => {
+    test('hide action buttons', async () => {
       const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush();
+      await flush();
       let button = element.shadowRoot
           .querySelector('[data-action-key="' + key + '"]');
       assert.isOk(button);
@@ -168,7 +168,7 @@
           .querySelector('[data-action-key="' + key + '"]'));
       changeActions.setActionOverflow(
           changeActions.ActionType.REVISION, key, true);
-      flush();
+      await flush();
       assert.isNotOk(element.shadowRoot
           .querySelector('[data-action-key="' + key + '"]'));
       assert.isFalse(element.$.moreActions.hidden);
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 fc5a4aa..aa86de4 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
@@ -26,6 +26,7 @@
   ValueChangedDetail,
 } from '../../../api/change-reply';
 import {appContext} from '../../../services/app-context';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 /**
  * GrChangeReplyInterface, provides a set of handy methods on reply dialog.
@@ -41,9 +42,9 @@
   }
 
   get _el(): GrReplyDialog {
-    return (this.sharedApiElement.getElement(
+    return this.sharedApiElement.getElement(
       TargetElement.REPLY_DIALOG
-    ) as unknown) as GrReplyDialog;
+    ) as unknown as GrReplyDialog;
   }
 
   getLabelValue(label: string): string {
@@ -58,44 +59,39 @@
 
   addReplyTextChangedCallback(handler: ReplyChangedCallback) {
     this.reporting.trackApi(this.plugin, 'reply', 'addReplyTextChangedCb');
-    const hookApi = this.plugin.hook('reply-text');
-    const registeredHandler = (e: Event) => {
+    const hookApi = this.plugin.hook<PluginElement>('reply-text');
+    const wrappedHandler = (el: PluginElement, e: Event) => {
       const ce = e as CustomEvent<ValueChangedDetail>;
-      handler(ce.detail.value);
+      handler(ce.detail.value, el.change);
     };
-    hookApi.onAttached(el => {
-      if (!el.content) {
-        return;
-      }
-      el.content.addEventListener('value-changed', registeredHandler);
-    });
-    hookApi.onDetached(el => {
-      if (!el.content) {
-        return;
-      }
-      el.content.removeEventListener('value-changed', registeredHandler);
-    });
+    this.addCallbackAsHookListener(hookApi, 'value-changed', wrappedHandler);
   }
 
   addLabelValuesChangedCallback(handler: LabelsChangedCallback) {
     this.reporting.trackApi(this.plugin, 'reply', 'addLabelValuesChangedCb');
-    const hookApi = this.plugin.hook('reply-label-scores');
-    const registeredHandler = (e: Event) => {
+    const hookApi = this.plugin.hook<PluginElement>('reply-label-scores');
+    const wrappedHandler = (el: PluginElement, e: Event) => {
       const ce = e as CustomEvent<LabelsChangedDetail>;
-      handler(ce.detail);
+      handler(ce.detail, el.change);
     };
-    hookApi.onAttached(el => {
-      if (!el.content) {
-        return;
-      }
-      el.content.addEventListener('labels-changed', registeredHandler);
-    });
+    this.addCallbackAsHookListener(hookApi, 'labels-changed', wrappedHandler);
+  }
 
+  private addCallbackAsHookListener(
+    hookApi: HookApi<PluginElement>,
+    eventName: string,
+    handler: (el: PluginElement, e: Event) => void
+  ) {
+    let registeredHandler: ((e: Event) => void) | undefined;
+    hookApi.onAttached(el => {
+      registeredHandler = (e: Event) => handler(el, e);
+      el.content?.addEventListener(eventName, registeredHandler);
+    });
     hookApi.onDetached(el => {
-      if (!el.content) {
-        return;
+      if (registeredHandler) {
+        el.content?.removeEventListener(eventName, registeredHandler);
+        registeredHandler = undefined;
       }
-      el.content.removeEventListener('labels-changed', registeredHandler);
     });
   }
 
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 7afdd20..1d4bd06 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
@@ -30,8 +30,21 @@
   EventEmitterService,
 } from '../../../services/gr-event-interface/gr-event-interface';
 import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {Gerrit} from '../../../api/gerrit';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {formStyles} from '../../../styles/gr-form-styles';
+import {menuPageStyles} from '../../../styles/gr-menu-page-styles';
+import {spinnerStyles} from '../../../styles/gr-spinner-styles';
+import {subpageStyles} from '../../../styles/gr-subpage-styles';
+import {tableStyles} from '../../../styles/gr-table-styles';
 
-export interface GerritGlobal extends EventEmitterService {
+/**
+ * These are the methods and properties that are exposed explicitly in the
+ * public global `Gerrit` interface. In reality JavaScript plugins do depend
+ * on some of this "internal" stuff. But we want to convert plugins to
+ * TypeScript one by one and while doing that remove those dependencies.
+ */
+export interface GerritInternal extends EventEmitterService, Gerrit {
   css(rule: string): string;
   install(
     callback: (plugin: PluginApi) => void,
@@ -55,11 +68,9 @@
   awaitPluginsLoaded(): Promise<unknown>;
   _loadPlugins(plugins: string[], opts: PluginOptionMap): void;
   _arePluginsLoaded(): boolean;
-  _isPluginPreloaded(pathOrUrl: string): boolean;
   _isPluginEnabled(pathOrUrl: string): boolean;
   _isPluginLoaded(pathOrUrl: string): boolean;
-  _eventEmitter: EventEmitterService;
-  _customStyleSheet: CSSStyleSheet;
+  _customStyleSheet?: CSSStyleSheet;
 
   // exposed methods
   Nav: typeof GerritNav;
@@ -67,18 +78,12 @@
 }
 
 export function initGerritPluginApi() {
-  window.Gerrit = window.Gerrit || {};
-  initGerritPluginsMethods(window.Gerrit as GerritGlobal);
-  // Preloaded plugins should be installed after Gerrit.install() is set,
-  // since plugin preloader substitutes Gerrit.install() temporarily.
-  // (Gerrit.install() is set in initGerritPluginsMethods)
-  getPluginLoader().installPreloadedPlugins();
+  window.Gerrit = window.Gerrit ?? new GerritImpl();
 }
 
-export function _testOnly_initGerritPluginApi(): GerritGlobal {
-  window.Gerrit = window.Gerrit || {};
+export function _testOnly_initGerritPluginApi(): GerritInternal {
   initGerritPluginApi();
-  return window.Gerrit as GerritGlobal;
+  return window.Gerrit as GerritInternal;
 }
 
 export function deprecatedDelete(
@@ -107,130 +112,133 @@
   getPluginName: () => 'global',
 };
 
-function initGerritPluginsMethods(globalGerritObj: GerritGlobal) {
+/**
+ * TODO(brohlfs): Reduce this step by step until it only contains install().
+ */
+class GerritImpl implements GerritInternal {
+  _customStyleSheet?: CSSStyleSheet;
+
+  public readonly Nav = GerritNav;
+
+  public readonly Auth = appContext.authService;
+
+  public readonly styles = {
+    font: fontStyles,
+    form: formStyles,
+    menuPage: menuPageStyles,
+    spinner: spinnerStyles,
+    subPage: subpageStyles,
+    table: tableStyles,
+  };
+
   /**
    * @deprecated Use plugin.styles().css(rulesStr) instead. Please, consult
    * the documentation how to replace it accordingly.
    */
-  globalGerritObj.css = (rulesStr: string) => {
+  css(rulesStr: string) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'css');
     console.warn(
       'Gerrit.css(rulesStr) is deprecated!',
       'Use plugin.styles().css(rulesStr)'
     );
-    if (!globalGerritObj._customStyleSheet) {
+    if (!this._customStyleSheet) {
       const styleEl = document.createElement('style');
       document.head.appendChild(styleEl);
-      globalGerritObj._customStyleSheet = styleEl.sheet!;
+      this._customStyleSheet = styleEl.sheet!;
     }
 
-    const name = `__pg_js_api_class_${globalGerritObj._customStyleSheet.cssRules.length}`;
-    globalGerritObj._customStyleSheet.insertRule(
-      '.' + name + '{' + rulesStr + '}',
-      0
-    );
+    const name = `__pg_js_api_class_${this._customStyleSheet.cssRules.length}`;
+    this._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
     return name;
-  };
+  }
 
-  globalGerritObj.install = (callback, opt_version, opt_src) => {
-    getPluginLoader().install(callback, opt_version, opt_src);
-  };
+  install(
+    callback: (plugin: PluginApi) => void,
+    version?: string,
+    src?: string
+  ) {
+    getPluginLoader().install(callback, version, src);
+  }
 
-  globalGerritObj.getLoggedIn = () => {
+  getLoggedIn() {
     appContext.reportingService.trackApi(fakeApi, 'global', 'getLoggedIn');
     console.warn(
       'Gerrit.getLoggedIn() is deprecated! ' +
         'Use plugin.restApi().getLoggedIn()'
     );
     return appContext.restApiService.getLoggedIn();
-  };
+  }
 
-  globalGerritObj.get = (
-    url: string,
-    callback?: (response: unknown) => void
-  ) => {
+  get(url: string, callback?: (response: unknown) => void) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'get');
     console.warn('.get() is deprecated! Use plugin.restApi().get()');
     send(HttpMethod.GET, url, callback);
-  };
+  }
 
-  globalGerritObj.post = (
+  post(
     url: string,
     payload?: RequestPayload,
     callback?: (response: unknown) => void
-  ) => {
+  ) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'post');
     console.warn('.post() is deprecated! Use plugin.restApi().post()');
     send(HttpMethod.POST, url, callback, payload);
-  };
+  }
 
-  globalGerritObj.put = (
+  put(
     url: string,
     payload?: RequestPayload,
     callback?: (response: unknown) => void
-  ) => {
+  ) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'put');
     console.warn('.put() is deprecated! Use plugin.restApi().put()');
     send(HttpMethod.PUT, url, callback, payload);
-  };
+  }
 
-  globalGerritObj.delete = (
-    url: string,
-    callback?: (response: Response) => void
-  ) => {
+  delete(url: string, callback?: (response: Response) => void) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'delete');
     deprecatedDelete(url, callback);
-  };
+  }
 
-  globalGerritObj.awaitPluginsLoaded = () => {
+  awaitPluginsLoaded() {
     appContext.reportingService.trackApi(
       fakeApi,
       'global',
       'awaitPluginsLoaded'
     );
     return getPluginLoader().awaitPluginsLoaded();
-  };
+  }
 
   // TODO(taoalpha): consider removing these proxy methods
   // and using getPluginLoader() directly
-  globalGerritObj._loadPlugins = plugins => {
+  _loadPlugins(plugins: string[] = []) {
     appContext.reportingService.trackApi(fakeApi, 'global', '_loadPlugins');
     getPluginLoader().loadPlugins(plugins);
-  };
+  }
 
-  globalGerritObj._arePluginsLoaded = () => {
+  _arePluginsLoaded() {
     appContext.reportingService.trackApi(
       fakeApi,
       'global',
       '_arePluginsLoaded'
     );
     return getPluginLoader().arePluginsLoaded();
-  };
+  }
 
-  globalGerritObj._isPluginPreloaded = url => {
-    appContext.reportingService.trackApi(
-      fakeApi,
-      'global',
-      '_isPluginPreloaded'
-    );
-    return getPluginLoader().isPluginPreloaded(url);
-  };
-
-  globalGerritObj._isPluginEnabled = pathOrUrl => {
+  _isPluginEnabled(pathOrUrl: string) {
     appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginEnabled');
     return getPluginLoader().isPluginEnabled(pathOrUrl);
-  };
+  }
 
-  globalGerritObj._isPluginLoaded = pathOrUrl => {
+  isPluginLoaded(pathOrUrl: string) {
+    return this._isPluginLoaded(pathOrUrl);
+  }
+
+  _isPluginLoaded(pathOrUrl: string) {
     appContext.reportingService.trackApi(fakeApi, 'global', '_isPluginLoaded');
     return getPluginLoader().isPluginLoaded(pathOrUrl);
-  };
+  }
 
-  const eventEmitter = appContext.eventEmitter;
-
-  // TODO(taoalpha): List all internal supported event names.
-  // Also convert this to inherited class once we move Gerrit to class.
-  globalGerritObj._eventEmitter = eventEmitter;
   /**
    * Enabling EventEmitter interface on Gerrit.
    *
@@ -253,42 +261,49 @@
    *   });
    * });
    */
-  globalGerritObj.addListener = (eventName: string, cb: EventCallback) => {
+  addListener(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'addListener');
-    return eventEmitter.addListener(eventName, cb);
-  };
+    return appContext.eventEmitter.addListener(eventName, cb);
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  globalGerritObj.dispatch = (eventName: string, detail: any) => {
+  dispatch(eventName: string, detail: any) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'dispatch');
-    return eventEmitter.dispatch(eventName, detail);
-  };
+    return appContext.eventEmitter.dispatch(eventName, detail);
+  }
+
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  globalGerritObj.emit = (eventName: string, detail: any) => {
+  emit(eventName: string, detail: any) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'emit');
-    return eventEmitter.emit(eventName, detail);
-  };
-  globalGerritObj.off = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.emit(eventName, detail);
+  }
+
+  off(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'off');
-    return eventEmitter.off(eventName, cb);
-  };
-  globalGerritObj.on = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.off(eventName, cb);
+  }
+
+  on(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'on');
-    return eventEmitter.on(eventName, cb);
-  };
-  globalGerritObj.once = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.on(eventName, cb);
+  }
+
+  once(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'once');
-    return eventEmitter.once(eventName, cb);
-  };
-  globalGerritObj.removeAllListeners = (eventName: string) => {
+    return appContext.eventEmitter.once(eventName, cb);
+  }
+
+  removeAllListeners(eventName: string) {
     appContext.reportingService.trackApi(
       fakeApi,
       'global',
       'removeAllListeners'
     );
-    return eventEmitter.removeAllListeners(eventName);
-  };
-  globalGerritObj.removeListener = (eventName: string, cb: EventCallback) => {
+    return appContext.eventEmitter.removeAllListeners(eventName);
+  }
+
+  removeListener(eventName: string, cb: EventCallback) {
     appContext.reportingService.trackApi(fakeApi, 'global', 'removeListener');
-    return eventEmitter.removeListener(eventName, cb);
-  };
+    return appContext.eventEmitter.removeListener(eventName, cb);
+  }
 }
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 608e711..95a67ab 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
@@ -64,16 +64,6 @@
       pluginApi._isPluginLoaded('test_plugin');
       assert.isTrue(stubFn.calledWith('test_plugin'));
     });
-
-    test('Gerrit._isPluginPreloaded proxy to getPluginLoader()', () => {
-      const stubFn = sinon.stub();
-      sinon.stub(
-          getPluginLoader(),
-          'isPluginPreloaded')
-          .callsFake((...args) => stubFn(...args));
-      pluginApi._isPluginPreloaded('test_plugin');
-      assert.isTrue(stubFn.calledWith('test_plugin'));
-    });
   });
 });
 
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 95d2e7f..4bb5fd4 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
@@ -250,7 +250,7 @@
   getDiffLayers(path: string) {
     const layers: DiffLayer[] = [];
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
-      const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+      const annotationApi = cb as unknown as GrAnnotationActionsInterface;
       try {
         const layer = annotationApi.createLayer(path);
         if (layer) layers.push(layer);
@@ -264,7 +264,7 @@
   disposeDiffLayers(path: string) {
     for (const cb of this._getEventCallbacks(EventType.ANNOTATE_DIFF)) {
       try {
-        const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+        const annotationApi = cb as unknown as GrAnnotationActionsInterface;
         annotationApi.disposeLayer(path);
       } catch (err) {
         this.reporting.error(err);
@@ -285,7 +285,7 @@
       .then(() => {
         const providers: GrAnnotationActionsInterface[] = [];
         this._getEventCallbacks(EventType.ANNOTATE_DIFF).forEach(cb => {
-          const annotationApi = (cb as unknown) as GrAnnotationActionsInterface;
+          const annotationApi = cb as unknown as GrAnnotationActionsInterface;
           const provider = annotationApi.getCoverageProvider();
           if (provider) providers.push(annotationApi);
         });
@@ -296,7 +296,7 @@
   getAdminMenuLinks(): MenuLink[] {
     const links: MenuLink[] = [];
     for (const cb of this._getEventCallbacks(EventType.ADMIN_MENU_LINKS)) {
-      const adminApi = (cb as unknown) as GrAdminApi;
+      const adminApi = cb as unknown as GrAdminApi;
       links.push(...adminApi.getMenuLinks());
     }
     return links;
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 a9bb8ae..29db685 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
@@ -23,7 +23,6 @@
 import {getPluginLoader} from './gr-plugin-loader.js';
 import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
 import {stubBaseUrl} from '../../../test/test-utils.js';
-import sinon from 'sinon/pkg/sinon-esm';
 import {stubRestApi} from '../../../test/test-utils.js';
 import {appContext} from '../../../services/app-context.js';
 
@@ -65,28 +64,6 @@
         'http://test.com/plugins/testplugin/static/test.js');
   });
 
-  test('url for preloaded plugin without ASSETS_PATH', () => {
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'preloaded:testpluginB');
-    assert.equal(plugin.url(),
-        `${window.location.origin}/plugins/testpluginB/`);
-    assert.equal(plugin.url('/static/test.js'),
-        `${window.location.origin}/plugins/testpluginB/static/test.js`);
-  });
-
-  test('url for preloaded plugin without ASSETS_PATH', () => {
-    const oldAssetsPath = window.ASSETS_PATH;
-    window.ASSETS_PATH = 'http://test.com';
-    let plugin;
-    pluginApi.install(p => { plugin = p; }, '0.1',
-        'preloaded:testpluginC');
-    assert.equal(plugin.url(), `${window.ASSETS_PATH}/plugins/testpluginC/`);
-    assert.equal(plugin.url('/static/test.js'),
-        `${window.ASSETS_PATH}/plugins/testpluginC/static/test.js`);
-    window.ASSETS_PATH = oldAssetsPath;
-  });
-
   test('_send on failure rejects with response text', () => {
     sendStub.returns(Promise.resolve(
         {status: 400, text() { return Promise.resolve('text'); }}));
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 2135c30..46c7ad6 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
@@ -22,6 +22,7 @@
 import {windowLocationReload} from '../../../utils/dom-util';
 import {PopupPluginApi} from '../../../api/popup';
 import {GrPopupInterface} from '../../plugins/gr-popup-interface/gr-popup-interface';
+import {appContext} from '../../../services/app-context';
 
 interface ButtonCallBacks {
   onclick: (event: Event) => boolean;
@@ -30,6 +31,8 @@
 export class GrPluginActionContext {
   private popups: PopupPluginApi[] = [];
 
+  private readonly reporting = appContext.reportingService;
+
   constructor(
     public readonly plugin: PluginApi,
     public readonly action: UIActionInfo,
@@ -108,7 +111,9 @@
   call(payload: RequestPayload, onSuccess: (result: unknown) => void) {
     if (!this.action.method) return;
     if (!this.action.__url) {
-      console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+      this.reporting.error(
+        new Error(`Unable to ${this.action.method} to ${this.action.__key}!`)
+      );
       return;
     }
     this.plugin
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 0cc95a3..b039a7e 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,7 +16,7 @@
  */
 import {PluginApi} from '../../../api/plugin';
 import {notUndefined} from '../../../types/types';
-import {HookApi} from '../../../api/hook';
+import {HookApi, PluginElement} from '../../../api/hook';
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 type Callback = (value: any) => void;
@@ -26,7 +26,7 @@
   plugin: PluginApi;
   pluginUrl?: URL;
   type?: string;
-  domHook?: HookApi;
+  domHook?: HookApi<PluginElement>;
   slot?: string;
 }
 
@@ -36,7 +36,7 @@
   slot?: string;
   type?: string;
   moduleName?: string;
-  domHook?: HookApi;
+  domHook?: HookApi<PluginElement>;
 }
 
 export class GrPluginEndpoints {
@@ -104,7 +104,7 @@
    * which endpoints to dynamically add to the page.
    */
   registerModule(plugin: PluginApi, opts: Options) {
-    const endpoint = opts.endpoint!;
+    const endpoint = opts.endpoint;
     const dynamicEndpoint = opts.dynamicEndpoint;
     if (dynamicEndpoint) {
       if (!this._dynamicPlugins.has(dynamicEndpoint)) {
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
deleted file mode 100644
index e3475ad..0000000
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.js
+++ /dev/null
@@ -1,166 +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 {resetPlugins} from '../../../test/test-utils.js';
-import './gr-js-api-interface.js';
-import {GrPluginEndpoints} from './gr-plugin-endpoints.js';
-import {_testOnly_initGerritPluginApi} from './gr-gerrit.js';
-
-const pluginApi = _testOnly_initGerritPluginApi();
-
-suite('gr-plugin-endpoints tests', () => {
-  let instance;
-  let pluginFoo;
-  let pluginBar;
-  let domHook;
-
-  setup(() => {
-    domHook = {};
-    instance = new GrPluginEndpoints();
-    pluginApi.install(p => { pluginFoo = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/foo.js');
-    instance.registerModule(
-        pluginFoo,
-        {
-          endpoint: 'a-place',
-          type: 'decorate',
-          moduleName: 'foo-module',
-          domHook,
-        }
-    );
-    pluginApi.install(p => { pluginBar = p; }, '0.1',
-        'http://test.com/plugins/testplugin/static/bar.js');
-    instance.registerModule(
-        pluginBar,
-        {
-          endpoint: 'a-place',
-          type: 'style',
-          moduleName: 'bar-module',
-          domHook,
-        }
-    );
-  });
-
-  teardown(() => {
-    resetPlugins();
-  });
-
-  test('getDetails all', () => {
-    assert.deepEqual(instance.getDetails('a-place'), [
-      {
-        moduleName: 'foo-module',
-        plugin: pluginFoo,
-        pluginUrl: pluginFoo._url,
-        type: 'decorate',
-        domHook,
-        slot: undefined,
-      },
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-
-  test('getDetails by type', () => {
-    assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-
-  test('getDetails by module', () => {
-    assert.deepEqual(
-        instance.getDetails('a-place', {moduleName: 'foo-module'}),
-        [
-          {
-            moduleName: 'foo-module',
-            plugin: pluginFoo,
-            pluginUrl: pluginFoo._url,
-            type: 'decorate',
-            domHook,
-            slot: undefined,
-          },
-        ]);
-  });
-
-  test('getModules', () => {
-    assert.deepEqual(
-        instance.getModules('a-place'), ['foo-module', 'bar-module']);
-  });
-
-  test('getPlugins', () => {
-    assert.deepEqual(
-        instance.getPlugins('a-place'), [pluginFoo._url]);
-  });
-
-  test('onNewEndpoint', () => {
-    const newModuleStub = sinon.stub();
-    instance.setPluginsReady();
-    instance.onNewEndpoint('a-place', newModuleStub);
-    instance.registerModule(
-        pluginFoo,
-        {
-          endpoint: 'a-place',
-          type: 'replace',
-          moduleName: 'zaz-module',
-          domHook,
-        });
-    assert.deepEqual(newModuleStub.lastCall.args[0], {
-      moduleName: 'zaz-module',
-      plugin: pluginFoo,
-      pluginUrl: pluginFoo._url,
-      type: 'replace',
-      domHook,
-      slot: undefined,
-    });
-  });
-
-  test('reuse dom hooks', () => {
-    instance.registerModule(
-        pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
-    assert.deepEqual(instance.getDetails('a-place'), [
-      {
-        moduleName: 'foo-module',
-        plugin: pluginFoo,
-        pluginUrl: pluginFoo._url,
-        type: 'decorate',
-        domHook,
-        slot: undefined,
-      },
-      {
-        moduleName: 'bar-module',
-        plugin: pluginBar,
-        pluginUrl: pluginBar._url,
-        type: 'style',
-        domHook,
-        slot: undefined,
-      },
-    ]);
-  });
-});
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
new file mode 100644
index 0000000..c7bdfb4
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.ts
@@ -0,0 +1,177 @@
+/**
+ * @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 {resetPlugins} from '../../../test/test-utils';
+import './gr-js-api-interface';
+import {GrPluginEndpoints} from './gr-plugin-endpoints';
+import {_testOnly_initGerritPluginApi} from './gr-gerrit';
+import {PluginApi} from '../../../api/plugin';
+import {HookApi, HookCallback, PluginElement} from '../../../api/hook';
+
+const pluginApi = _testOnly_initGerritPluginApi();
+
+export class MockHook<T extends PluginElement> implements HookApi<T> {
+  handleInstanceDetached(_: T) {}
+
+  handleInstanceAttached(_: T) {}
+
+  getLastAttached(): Promise<HTMLElement> {
+    throw new Error('unimplemented in mock');
+  }
+
+  getAllAttached() {
+    return [];
+  }
+
+  onAttached(_: HookCallback<T>) {
+    return this;
+  }
+
+  onDetached(_: HookCallback<T>) {
+    return this;
+  }
+
+  getModuleName() {
+    return 'MockHookApi-ModuleName';
+  }
+}
+
+suite('gr-plugin-endpoints tests', () => {
+  let instance: GrPluginEndpoints;
+  let decoratePlugin: PluginApi;
+  let stylePlugin: PluginApi;
+  let domHook: HookApi<PluginElement>;
+
+  setup(() => {
+    domHook = new MockHook<PluginElement>();
+    instance = new GrPluginEndpoints();
+    pluginApi.install(
+      plugin => (decoratePlugin = plugin),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/decorate.js'
+    );
+    instance.registerModule(decoratePlugin, {
+      endpoint: 'my-endpoint',
+      type: 'decorate',
+      moduleName: 'decorate-module',
+      domHook,
+    });
+    pluginApi.install(
+      plugin => (stylePlugin = plugin),
+      '0.1',
+      'http://test.com/plugins/testplugin/static/style.js'
+    );
+    instance.registerModule(stylePlugin, {
+      endpoint: 'my-endpoint',
+      type: 'style',
+      moduleName: 'style-module',
+      domHook,
+    });
+  });
+
+  teardown(() => {
+    resetPlugins();
+  });
+
+  test('getDetails all', () => {
+    assert.deepEqual(instance.getDetails('my-endpoint'), [
+      {
+        moduleName: 'decorate-module',
+        plugin: decoratePlugin,
+        pluginUrl: decoratePlugin._url,
+        type: 'decorate',
+        domHook,
+        slot: undefined,
+      },
+      {
+        moduleName: 'style-module',
+        plugin: stylePlugin,
+        pluginUrl: stylePlugin._url,
+        type: 'style',
+        domHook,
+        slot: undefined,
+      },
+    ]);
+  });
+
+  test('getDetails by type', () => {
+    assert.deepEqual(
+      instance.getDetails('my-endpoint', {endpoint: 'a-place', type: 'style'}),
+      [
+        {
+          moduleName: 'style-module',
+          plugin: stylePlugin,
+          pluginUrl: stylePlugin._url,
+          type: 'style',
+          domHook,
+          slot: undefined,
+        },
+      ]
+    );
+  });
+
+  test('getDetails by module', () => {
+    assert.deepEqual(
+      instance.getDetails('my-endpoint', {
+        endpoint: 'my-endpoint',
+        moduleName: 'decorate-module',
+      }),
+      [
+        {
+          moduleName: 'decorate-module',
+          plugin: decoratePlugin,
+          pluginUrl: decoratePlugin._url,
+          type: 'decorate',
+          domHook,
+          slot: undefined,
+        },
+      ]
+    );
+  });
+
+  test('getModules', () => {
+    assert.deepEqual(instance.getModules('my-endpoint'), [
+      'decorate-module',
+      'style-module',
+    ]);
+  });
+
+  test('getPlugins URLs are unique', () => {
+    assert.equal(decoratePlugin._url, stylePlugin._url);
+    assert.deepEqual(instance.getPlugins('my-endpoint'), [decoratePlugin._url]);
+  });
+
+  test('onNewEndpoint', () => {
+    const newModuleStub = sinon.stub();
+    instance.setPluginsReady();
+    instance.onNewEndpoint('my-endpoint', newModuleStub);
+    instance.registerModule(decoratePlugin, {
+      endpoint: 'my-endpoint',
+      type: 'replace',
+      moduleName: 'replace-module',
+      domHook,
+    });
+    assert.deepEqual(newModuleStub.lastCall.args[0], {
+      moduleName: 'replace-module',
+      plugin: decoratePlugin,
+      pluginUrl: decoratePlugin._url,
+      type: 'replace',
+      domHook,
+      slot: undefined,
+    });
+  });
+});
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 85b6747..7c99480 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
@@ -15,17 +15,12 @@
  * limitations under the License.
  */
 import {appContext} from '../../../services/app-context';
-import {
-  PLUGIN_LOADING_TIMEOUT_MS,
-  PRELOADED_PROTOCOL,
-  getPluginNameFromUrl,
-} from './gr-api-utils';
+import {PLUGIN_LOADING_TIMEOUT_MS, getPluginNameFromUrl} from './gr-api-utils';
 import {Plugin} from './gr-public-js-api';
 import {getBaseUrl} from '../../../utils/url-util';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
 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 {
@@ -56,16 +51,6 @@
   __importElement: HTMLScriptElement;
 };
 
-type PluginCallback = (plugin: PluginApi) => void;
-
-interface PluginCallbackMap {
-  [name: string]: PluginCallback;
-}
-
-interface GerritGlobal {
-  _preloadedPlugins?: PluginCallbackMap;
-}
-
 // Prefix for any unrecognized plugin urls.
 // Url should match following patterns:
 // /plugins/PLUGINNAME/static/SCRIPTNAME.js
@@ -120,9 +105,6 @@
 
     plugins.forEach(path => {
       const url = this._urlFor(path, window.ASSETS_PATH);
-      // Skip if preloaded, for bundling.
-      if (this.isPluginPreloaded(url)) return;
-
       const pluginKey = this._getPluginKeyFromUrl(url);
       // Skip if already installed.
       if (this._plugins.has(pluginKey)) return;
@@ -141,7 +123,10 @@
     });
 
     this.awaitPluginsLoaded().then(() => {
-      this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
+      const loaded = this.getPluginsByState(PluginState.LOADED);
+      const failed = this.getPluginsByState(PluginState.LOAD_FAILED);
+      this._getReporting().pluginsLoaded(loaded.map(p => p.name));
+      this._getReporting().pluginsFailed(failed.map(p => p.name));
     });
   }
 
@@ -150,7 +135,7 @@
       try {
         url = new URL(url);
       } catch (e) {
-        console.warn(e);
+        this._getReporting().error(e);
         return false;
       }
     }
@@ -158,14 +143,8 @@
     return url.pathname && url.pathname.endsWith(suffix);
   }
 
-  _getAllInstalledPluginNames() {
-    const installedPlugins = [];
-    for (const plugin of this._plugins.values()) {
-      if (plugin.state === PluginState.LOADED) {
-        installedPlugins.push(plugin.name);
-      }
-    }
-    return installedPlugins;
+  private getPluginsByState(state: PluginState) {
+    return [...this._plugins.values()].filter(p => p.state === state);
   }
 
   install(
@@ -208,16 +187,9 @@
     }
   }
 
-  // The polygerrit uses version of sinon where you can't stub getter,
-  // declare it as a function here
   arePluginsLoaded() {
-    // As the size of plugins is relatively small,
-    // so the performance of this check should be reasonable
     if (!this._pluginListLoaded) return false;
-    for (const plugin of this._plugins.values()) {
-      if (plugin.state === PluginState.PENDING) return false;
-    }
-    return true;
+    return this.getPluginsByState(PluginState.PENDING).length === 0;
   }
 
   _checkIfCompleted() {
@@ -232,15 +204,14 @@
   }
 
   _timeout() {
-    const pendingPlugins = [];
-    for (const plugin of this._plugins.values()) {
-      if (plugin.state === PluginState.PENDING) {
-        this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
-        this._checkIfCompleted();
-        pendingPlugins.push(plugin.url);
-      }
+    const pending = this.getPluginsByState(PluginState.PENDING);
+    for (const plugin of pending) {
+      this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
     }
-    return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
+    this._checkIfCompleted();
+    return `Timeout when loading plugins: ${pending
+      .map(p => p.name)
+      .join(',')}`;
   }
 
   _failToLoad(message: string, pluginUrl?: string) {
@@ -270,6 +241,7 @@
         plugin: null,
       });
     }
+    console.info(`Plugin ${key} ${state}`);
     return this._plugins.get(key)!;
   }
 
@@ -277,38 +249,14 @@
     const pluginObj = this._updatePluginState(url, PluginState.LOADED);
     pluginObj.plugin = plugin;
     this._getReporting().pluginLoaded(plugin.getPluginName() || url);
-    console.info(`Plugin ${plugin.getPluginName() || url} installed.`);
     this._checkIfCompleted();
   }
 
-  installPreloadedPlugins() {
-    const Gerrit = window.Gerrit as GerritGlobal;
-    if (!Gerrit || !Gerrit._preloadedPlugins) {
-      return;
-    }
-    for (const name of Object.keys(Gerrit._preloadedPlugins)) {
-      const callback = Gerrit._preloadedPlugins[name];
-      this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
-    }
-  }
-
-  isPluginPreloaded(pathOrUrl: string) {
-    const url = this._urlFor(pathOrUrl);
-    const name = getPluginNameFromUrl(url);
-    const Gerrit = window.Gerrit as GerritGlobal;
-    if (name && Gerrit?._preloadedPlugins) {
-      return hasOwnProperty(Gerrit._preloadedPlugins, name);
-    } else {
-      return false;
-    }
-  }
-
   /**
    * Checks if given plugin path/url is enabled or not.
    */
   isPluginEnabled(pathOrUrl: string) {
     const url = this._urlFor(pathOrUrl);
-    if (this.isPluginPreloaded(url)) return true;
     const key = this._getPluginKeyFromUrl(url);
     return this._plugins.has(key);
   }
@@ -364,10 +312,7 @@
     // theme is per host, should always load from assetsPath
     const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.js');
     const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
-    if (
-      pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
-      pathOrUrl.startsWith('http')
-    ) {
+    if (pathOrUrl.startsWith('http')) {
       // Plugins are loaded from another domain or preloaded.
       if (
         pathOrUrl.includes(location.host) &&
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 1ca7e75..ab69267 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,7 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {PRELOADED_PROTOCOL, PLUGIN_LOADING_TIMEOUT_MS} from './gr-api-utils.js';
+import {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_initGerritPluginApi} from './gr-gerrit.js';
@@ -397,54 +397,4 @@
     assert.isTrue(installed);
     await pluginLoader.awaitPluginsLoaded();
   });
-
-  suite('preloaded plugins', () => {
-    teardown(() => {
-      window.Gerrit._preloadedPlugins = null;
-    });
-    test('skips preloaded plugins when load plugins', () => {
-      const loadJsPluginStub = sinon.stub();
-      sinon.stub(pluginLoader, '_loadJsPlugin').callsFake( url => {
-        loadJsPluginStub(url);
-      });
-
-      window.Gerrit._preloadedPlugins = {
-        foo: () => void 0,
-        bar: () => void 0,
-      };
-
-      pluginLoader.loadPlugins([
-        'http://e.com/plugins/foo.js',
-        'http://e.com/plugins/test/foo.js',
-      ]);
-
-      assert.isTrue(loadJsPluginStub.calledOnce);
-    });
-
-    test('isPluginPreloaded', () => {
-      window.Gerrit._preloadedPlugins = {baz: ()=>{}};
-      assert.isFalse(pluginLoader.isPluginPreloaded('plugins/foo/bar'));
-      assert.isFalse(pluginLoader.isPluginPreloaded('http://a.com/42'));
-      assert.isTrue(
-          pluginLoader.isPluginPreloaded(PRELOADED_PROTOCOL + 'baz')
-      );
-    });
-
-    test('preloaded plugins are installed', () => {
-      const installStub = sinon.stub();
-      window.Gerrit._preloadedPlugins = {foo: installStub};
-      pluginLoader.installPreloadedPlugins();
-      assert.isTrue(installStub.called);
-      const pluginApi = installStub.lastCall.args[0];
-      assert.strictEqual(pluginApi.getPluginName(), 'foo');
-    });
-
-    test('installing preloaded plugin', () => {
-      let plugin;
-      pluginApi.install(p => { plugin = p; }, '0.1', 'preloaded:foo');
-      assert.strictEqual(plugin.getPluginName(), 'foo');
-      assert.strictEqual(plugin.url('/some/thing.js'),
-          `${window.location.origin}/plugins/foo/some/thing.js`);
-    });
-  });
 });
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 150c45a..2b6db21 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
@@ -68,11 +68,6 @@
     return this.restApi.getAccount();
   }
 
-  getAccountCapabilities(capabilities: string[]) {
-    this.reporting.trackApi(this.plugin, 'rest', 'getAccountCapabilities');
-    return this.restApi.getAccountCapabilities(capabilities);
-  }
-
   getRepos(filter: string, reposPerPage: number, offset?: number) {
     this.reporting.trackApi(this.plugin, 'rest', 'getRepos');
     return this.restApi.getRepos(filter, reposPerPage, offset);
@@ -125,7 +120,7 @@
   /**
    * Fetch and parse REST API response, if request succeeds.
    */
-  send(
+  send<T>(
     method: HttpMethod,
     url: string,
     payload?: RequestPayload,
@@ -167,7 +162,7 @@
             Promise.reject(new Error(msg))
           );
         } else {
-          return this.restApi.getResponseObject(response);
+          return this.restApi.getResponseObject(response) as Promise<T>;
         }
       })
       .catch(err => {
@@ -180,29 +175,29 @@
       });
   }
 
-  get(url: string) {
+  get<T>(url: string) {
     this.reporting.trackApi(this.plugin, 'rest', 'get');
-    return this.send(HttpMethod.GET, url);
+    return this.send<T>(HttpMethod.GET, url);
   }
 
-  post(
+  post<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'post');
-    return this.send(HttpMethod.POST, url, payload, errFn, contentType);
+    return this.send<T>(HttpMethod.POST, url, payload, errFn, contentType);
   }
 
-  put(
+  put<T>(
     url: string,
     payload?: RequestPayload,
     errFn?: ErrorCallback,
     contentType?: string
   ) {
     this.reporting.trackApi(this.plugin, 'rest', 'put');
-    return this.send(HttpMethod.PUT, url, payload, errFn, contentType);
+    return this.send<T>(HttpMethod.PUT, url, payload, errFn, contentType);
   }
 
   delete(url: string) {
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 6ff9a50..18737a9 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
@@ -25,7 +25,7 @@
 import {GrEventHelper} from '../../plugins/gr-event-helper/gr-event-helper';
 import {GrPluginRestApi} from './gr-plugin-rest-api';
 import {getPluginEndpoints} from './gr-plugin-endpoints';
-import {getPluginNameFromUrl, PRELOADED_PROTOCOL, send} from './gr-api-utils';
+import {getPluginNameFromUrl, send} from './gr-api-utils';
 import {GrReportingJsApi} from './gr-reporting-js-api';
 import {EventType, PluginApi, TargetElement} from '../../../api/plugin';
 import {RequestPayload} from '../../../types/common';
@@ -41,7 +41,7 @@
 import {ChangeActionsPluginApi} from '../../../api/change-actions';
 import {ChangeReplyPluginApi} from '../../../api/change-reply';
 import {RestPluginApi} from '../../../api/rest';
-import {HookApi, RegisterOptions} from '../../../api/hook';
+import {HookApi, PluginElement, RegisterOptions} from '../../../api/hook';
 import {AttributeHelperPluginApi} from '../../../api/attribute-helper';
 
 /**
@@ -79,9 +79,10 @@
     this.domHooks = new GrDomHooksManager(this);
 
     if (!url) {
-      console.warn(
-        'Plugin not being loaded from /plugins base path.',
-        'Unable to determine name.'
+      this.report.error(
+        new Error(
+          'Plugin not being loaded from /plugins base path. Unable to determine name.'
+        )
       );
       return this;
     }
@@ -107,11 +108,11 @@
   /**
    * Registers an endpoint for the plugin.
    */
-  registerCustomComponent(
+  registerCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi {
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'registerCustomComponent');
     return this._registerCustomComponent(endpointName, moduleName, options);
   }
@@ -122,11 +123,11 @@
    * Dynamic plugins are registered by specific prefix, such as
    * 'change-list-header'.
    */
-  registerDynamicCustomComponent(
+  registerDynamicCustomComponent<T extends PluginElement>(
     endpointName: string,
     moduleName?: string,
     options?: RegisterOptions
-  ): HookApi {
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'registerDynamicCustomComponent');
     const fullEndpointName = `${endpointName}-${this.getPluginName()}`;
     return this._registerCustomComponent(
@@ -137,16 +138,17 @@
     );
   }
 
-  _registerCustomComponent(
+  _registerCustomComponent<T extends PluginElement>(
     endpoint: string,
     moduleName?: string,
     options?: RegisterOptions,
     dynamicEndpoint?: string
-  ): HookApi {
-    const type =
-      options && options.replace ? EndpointType.REPLACE : EndpointType.DECORATE;
-    const slot = (options && options.slot) || '';
-    const domHook = this.domHooks.getDomHook(endpoint, moduleName);
+  ): HookApi<T> {
+    const type = options?.replace
+      ? EndpointType.REPLACE
+      : EndpointType.DECORATE;
+    const slot = options?.slot ?? '';
+    const domHook = this.domHooks.getDomHook<T>(endpoint, moduleName);
     moduleName = moduleName || domHook.getModuleName();
     getPluginEndpoints().registerModule(this, {
       slot,
@@ -163,7 +165,10 @@
    * Returns instance of DOM hook API for endpoint. Creates a placeholder
    * element for the first call.
    */
-  hook(endpointName: string, options?: RegisterOptions) {
+  hook<T extends PluginElement>(
+    endpointName: string,
+    options?: RegisterOptions
+  ): HookApi<T> {
     this.report.trackApi(this, 'plugin', 'hook');
     return this.registerCustomComponent(endpointName, undefined, options);
   }
@@ -187,11 +192,6 @@
     if (window.location.origin === this._url.origin) {
       // Plugin loaded from the same origin as gr-app, getBaseUrl in effect.
       return sameOriginPath;
-    } else if (this._url.protocol === PRELOADED_PROTOCOL) {
-      // Plugin is preloaded, load plugin with ASSETS_PATH or location.origin
-      return window.ASSETS_PATH
-        ? `${window.ASSETS_PATH}${relPath}`
-        : sameOriginPath;
     } else {
       // Plugin loaded from assets bundle, expect assets placed along with it.
       return this._url.href.split('/plugins/' + this._name)[0] + relPath;
@@ -222,9 +222,9 @@
   changeActions(): ChangeActionsPluginApi {
     return new GrChangeActionsInterface(
       this,
-      (this.jsApi.getElement(
+      this.jsApi.getElement(
         TargetElement.CHANGE_ACTIONS
-      ) as unknown) as GrChangeActions
+      ) as unknown as GrChangeActions
     );
   }
 
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 595fb4f..0427b43 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
@@ -18,7 +18,6 @@
 import {appContext} from '../../../services/app-context';
 import {PluginApi} from '../../../api/plugin';
 import {EventDetails, ReportingPluginApi} from '../../../api/reporting';
-import {LifeCycle} from '../../../constants/reporting';
 
 /**
  * Defines all methods that will be exported to plugin from reporting service.
@@ -32,7 +31,7 @@
 
   reportInteraction(eventName: string, details?: EventDetails) {
     this.reporting.trackApi(this.plugin, 'reporting', 'reportInteraction');
-    this.reporting.reportInteraction(
+    this.reporting.reportPluginInteractionLog(
       `${this.plugin.getPluginName()}-${eventName}`,
       details
     );
@@ -40,10 +39,9 @@
 
   reportLifeCycle(eventName: string, details?: EventDetails) {
     this.reporting.trackApi(this.plugin, 'reporting', 'reportLifeCycle');
-    this.reporting.reportLifeCycle(LifeCycle.PLUGIN_LIFE_CYCLE, {
-      ...details,
-      pluginName: this.plugin.getPluginName(),
-      eventName,
-    });
+    this.reporting.reportPluginLifeCycleLog(
+      `${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 ffc2fbb..71cc565 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
@@ -43,30 +43,34 @@
     });
 
     test('redirect reportInteraction call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportInteraction');
+      sinon.spy(appContext.reportingService, 'reportPluginInteractionLog');
       reporting.reportInteraction('test', {});
-      assert.isTrue(appContext.reportingService.reportInteraction.called);
+      assert.isTrue(appContext.reportingService.reportPluginInteractionLog
+          .called);
       assert.equal(
-          appContext.reportingService.reportInteraction.lastCall.args[0],
+          appContext.reportingService.reportPluginInteractionLog.lastCall
+              .args[0],
           'testplugin-test'
       );
       assert.deepEqual(
-          appContext.reportingService.reportInteraction.lastCall.args[1],
+          appContext.reportingService.reportPluginInteractionLog.lastCall
+              .args[1],
           {}
       );
     });
 
     test('redirect reportLifeCycle call to reportingService', () => {
-      sinon.spy(appContext.reportingService, 'reportLifeCycle');
+      sinon.spy(appContext.reportingService, 'reportPluginLifeCycleLog');
       reporting.reportLifeCycle('test', {});
-      assert.isTrue(appContext.reportingService.reportLifeCycle.called);
+      assert.isTrue(appContext.reportingService.reportPluginLifeCycleLog
+          .called);
       assert.equal(
-          appContext.reportingService.reportLifeCycle.lastCall.args[0],
-          'Plugin life cycle'
+          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[0],
+          'testplugin-test'
       );
       assert.deepEqual(
-          appContext.reportingService.reportLifeCycle.lastCall.args[1],
-          {pluginName: 'testplugin', eventName: 'test'}
+          appContext.reportingService.reportPluginLifeCycleLog.lastCall.args[1],
+          {}
       );
     });
   });
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 db22ce5..a65bb75 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
@@ -14,30 +14,47 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import '../../../styles/gr-font-styles';
 import '../../../styles/gr-voting-styles';
 import '../../../styles/shared-styles';
+import '../gr-vote-chip/gr-vote-chip';
 import '../gr-account-label/gr-account-label';
 import '../gr-account-link/gr-account-link';
+import '../gr-account-chip/gr-account-chip';
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../gr-label/gr-label';
+import '../gr-tooltip-content/gr-tooltip-content';
 import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-label-info_html';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation';
-import {customElement, property} from '@polymer/decorators';
 import {
-  ChangeInfo,
   AccountInfo,
   LabelInfo,
   ApprovalInfo,
   AccountId,
   isQuickLabelInfo,
   isDetailedLabelInfo,
+  LabelNameToInfoMap,
 } from '../../../types/common';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 import {GrButton} from '../gr-button/gr-button';
-import {getVotingRangeOrDefault} from '../../../utils/label-util';
+import {
+  canVote,
+  getApprovalInfo,
+  getVotingRangeOrDefault,
+  hasNeutralStatus,
+  hasVoted,
+  valueString,
+} from '../../../utils/label-util';
 import {appContext} from '../../../services/app-context';
+import {ParsedChangeInfo} from '../../../types/types';
+import {fontStyles} from '../../../styles/gr-font-styles';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {votingStyles} from '../../../styles/gr-voting-styles';
+import {ifDefined} from 'lit/directives/if-defined';
+import {fireReload} from '../../../utils/event-util';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {sortReviewers} from '../../../utils/attention-set-util';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -54,16 +71,12 @@
 
 interface FormattedLabel {
   className?: LabelClassName;
-  account: ApprovalInfo;
+  account: ApprovalInfo | AccountInfo;
   value: string;
 }
 
 @customElement('gr-label-info')
-export class GrLabelInfo extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLabelInfo extends LitElement {
   @property({type: Object})
   labelInfo?: LabelInfo;
 
@@ -71,24 +84,263 @@
   label = '';
 
   @property({type: Object})
-  change?: ChangeInfo;
+  change?: ParsedChangeInfo;
 
   @property({type: Object})
   account?: AccountInfo;
 
+  /**
+   * A user is able to delete a vote iff the mutable property is true and the
+   * reviewer that left the vote exists in the list of removable_reviewers
+   * received from the backend.
+   */
   @property({type: Boolean})
   mutable = false;
 
+  /**
+   * if true - show all reviewers that can vote on label
+   * if false - show only reviewers that voted on label
+   */
+  @property({type: Boolean})
+  showAllReviewers = true;
+
+  /** temporary until submit requirements are finished */
+  @property({type: Boolean})
+  showAlwaysOldUI = false;
+
   private readonly restApiService = appContext.restApiService;
 
+  private readonly reporting = appContext.reportingService;
+
   // TODO(TS): not used, remove later
   _xhrPromise?: Promise<void>;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      fontStyles,
+      votingStyles,
+      css`
+        .placeholder {
+          color: var(--deemphasized-text-color);
+        }
+        .hidden {
+          display: none;
+        }
+        /* Note that most of the .voteChip styles are coming from the
+         gr-voting-styles include. */
+        .voteChip {
+          display: flex;
+          justify-content: center;
+          margin-right: var(--spacing-s);
+          padding: 1px;
+        }
+        .max {
+          background-color: var(--vote-color-approved);
+        }
+        .min {
+          background-color: var(--vote-color-rejected);
+        }
+        .positive {
+          background-color: var(--vote-color-recommended);
+          border-radius: 12px;
+          border: 1px solid var(--vote-outline-recommended);
+          color: var(--chip-color);
+        }
+        .negative {
+          background-color: var(--vote-color-disliked);
+          border-radius: 12px;
+          border: 1px solid var(--vote-outline-disliked);
+          color: var(--chip-color);
+        }
+        .hidden {
+          display: none;
+        }
+        td {
+          vertical-align: top;
+        }
+        tr {
+          min-height: var(--line-height-normal);
+        }
+        gr-tooltip-content {
+          display: block;
+        }
+        gr-button {
+          vertical-align: top;
+        }
+        gr-button::part(paper-button) {
+          height: var(--line-height-normal);
+          width: var(--line-height-normal);
+          padding: 0;
+        }
+        gr-button[disabled] iron-icon {
+          color: var(--border-color);
+        }
+        gr-account-link {
+          --account-max-length: 100px;
+          margin-right: var(--spacing-xs);
+        }
+        iron-icon {
+          height: calc(var(--line-height-normal) - 2px);
+          width: calc(var(--line-height-normal) - 2px);
+        }
+        .labelValueContainer:not(:first-of-type) td {
+          padding-top: var(--spacing-s);
+        }
+        .reviewer-row {
+          padding-top: var(--spacing-s);
+        }
+        .reviewer-row:first-of-type {
+          padding-top: 0;
+        }
+        .reviewer-row gr-account-chip,
+        .reviewer-row gr-tooltip-content {
+          display: inline-block;
+          vertical-align: top;
+        }
+        .reviewer-row .no-votes {
+          color: var(--deemphasized-text-color);
+          margin-left: var(--spacing-xs);
+        }
+        gr-vote-chip {
+          --gr-vote-chip-width: 14px;
+          --gr-vote-chip-height: 14px;
+          margin-right: var(--spacing-s);
+        }
+      `,
+    ];
+  }
+
+  private readonly flagsService = appContext.flagsService;
+
+  override render() {
+    if (
+      this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI) &&
+      !this.showAlwaysOldUI
+    ) {
+      return this.renderNewSubmitRequirements();
+    } else {
+      return this.renderOldSubmitRequirements();
+    }
+  }
+
+  private renderNewSubmitRequirements() {
+    const labelInfo = this.labelInfo;
+    if (!labelInfo) return;
+    const reviewers = (this.change?.reviewers['REVIEWER'] ?? [])
+      .filter(
+        reviewer =>
+          (this.showAllReviewers && canVote(labelInfo, reviewer)) ||
+          (!this.showAllReviewers && hasVoted(labelInfo, reviewer))
+      )
+      .sort((r1, r2) => sortReviewers(r1, r2, this.change, this.account));
+    return html`<div>
+      ${reviewers.map(reviewer => this.renderReviewerVote(reviewer))}
+    </div>`;
+  }
+
+  private renderOldSubmitRequirements() {
+    const labelInfo = this.labelInfo;
+    return html` <p
+        class="placeholder ${this.computeShowPlaceholder(
+          labelInfo,
+          this.change?.labels
+        )}"
+      >
+        No votes
+      </p>
+      <table>
+        ${this.mapLabelInfo(labelInfo, this.account, this.change?.labels).map(
+          mappedLabel => this.renderLabel(mappedLabel)
+        )}
+      </table>`;
+  }
+
+  renderReviewerVote(reviewer: AccountInfo) {
+    const labelInfo = this.labelInfo;
+    if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return;
+    const approvalInfo = getApprovalInfo(labelInfo, reviewer);
+    const noVoteYet =
+      !approvalInfo || hasNeutralStatus(labelInfo, approvalInfo);
+    return html`<div class="reviewer-row">
+      <gr-account-chip .account="${reviewer}" .change="${this.change}">
+        <gr-vote-chip
+          slot="vote-chip"
+          .vote="${approvalInfo}"
+          .label="${labelInfo}"
+        ></gr-vote-chip
+      ></gr-account-chip>
+      ${noVoteYet
+        ? this.renderVoteAbility(reviewer)
+        : html`${this.renderRemoveVote(reviewer)}`}
+    </div>`;
+  }
+
+  renderLabel(mappedLabel: FormattedLabel) {
+    const {labelInfo, change} = this;
+    return html` <tr class="labelValueContainer">
+      <td>
+        <gr-tooltip-content
+          has-tooltip
+          title="${this._computeValueTooltip(labelInfo, mappedLabel.value)}"
+        >
+          <gr-label class="${mappedLabel.className} voteChip font-small">
+            ${mappedLabel.value}
+          </gr-label>
+        </gr-tooltip-content>
+      </td>
+      <td>
+        <gr-account-link
+          .account="${mappedLabel.account}"
+          .change="${change}"
+        ></gr-account-link>
+      </td>
+      <td>${this.renderRemoveVote(mappedLabel.account)}</td>
+    </tr>`;
+  }
+
+  private renderVoteAbility(reviewer: AccountInfo) {
+    if (this.labelInfo && isDetailedLabelInfo(this.labelInfo)) {
+      const approvalInfo = getApprovalInfo(this.labelInfo, reviewer);
+      if (approvalInfo?.permitted_voting_range) {
+        const {min, max} = approvalInfo?.permitted_voting_range;
+        return html`<span class="no-votes"
+          >Can vote ${valueString(min)}/${valueString(max)}</span
+        >`;
+      }
+    }
+    return html`<span class="no-votes">No votes</span>`;
+  }
+
+  private renderRemoveVote(reviewer: AccountInfo) {
+    return html`<gr-tooltip-content has-tooltip title="Remove vote">
+      <gr-button
+        link
+        aria-label="Remove vote"
+        @click="${this.onDeleteVote}"
+        data-account-id="${ifDefined(reviewer._account_id)}"
+        class="deleteBtn ${this.computeDeleteClass(
+          reviewer,
+          this.mutable,
+          this.change
+        )}"
+      >
+        <iron-icon icon="gr-icons:delete"></iron-icon>
+      </gr-button>
+    </gr-tooltip-content>`;
+  }
+
   /**
    * This method also listens on change.labels.*,
    * to trigger computation when a label is removed from the change.
+   *
+   * The third parameter is just for *triggering* computation.
    */
-  _mapLabelInfo(labelInfo?: LabelInfo, account?: AccountInfo) {
+  private mapLabelInfo(
+    labelInfo?: LabelInfo,
+    account?: AccountInfo,
+    _?: LabelNameToInfoMap
+  ): FormattedLabel[] {
     const result: FormattedLabel[] = [];
     if (!labelInfo) {
       return result;
@@ -103,7 +355,8 @@
           {
             value: ok ? '👍️' : '👎️',
             className: ok ? LabelClassName.POSITIVE : LabelClassName.NEGATIVE,
-            account: ok ? labelInfo.approved : labelInfo.rejected,
+            // executed only if approved or rejected is not undefined
+            account: ok ? labelInfo.approved! : labelInfo.rejected!,
           },
         ];
       }
@@ -138,7 +391,7 @@
             labelClassName = LabelClassName.NEGATIVE;
           }
         }
-        const formattedLabel = {
+        const formattedLabel: FormattedLabel = {
           value: `${labelValPrefix}${label.value}`,
           className: labelClassName,
           account: label,
@@ -162,16 +415,16 @@
    * @param reviewer An object describing the reviewer that left the
    *     vote.
    */
-  _computeDeleteClass(
+  private computeDeleteClass(
     reviewer: ApprovalInfo,
     mutable: boolean,
-    change: ChangeInfo
+    change?: ParsedChangeInfo
   ) {
     if (!mutable || !change || !change.removable_reviewers) {
       return 'hidden';
     }
     const removable = change.removable_reviewers;
-    if (removable.find(r => r._account_id === reviewer._account_id)) {
+    if (removable.find(r => r._account_id === reviewer?._account_id)) {
       return '';
     }
     return 'hidden';
@@ -181,7 +434,7 @@
    * Closure annotation for Polymer.prototype.splice is off.
    * For now, suppressing annotations.
    */
-  _onDeleteVote(e: MouseEvent) {
+  private onDeleteVote(e: MouseEvent) {
     if (!this.change) return;
 
     e.preventDefault();
@@ -205,17 +458,17 @@
           return;
         }
         if (this.change) {
-          GerritNav.navigateToChange(this.change);
+          fireReload(this);
         }
       })
       .catch(err => {
-        console.warn(err);
+        this.reporting.error(err);
         target.disabled = false;
         return;
       });
   }
 
-  _computeValueTooltip(labelInfo: LabelInfo, score: string) {
+  _computeValueTooltip(labelInfo: LabelInfo | undefined, score: string) {
     if (
       !labelInfo ||
       !isDetailedLabelInfo(labelInfo) ||
@@ -229,8 +482,13 @@
   /**
    * This method also listens change.labels.* in
    * order to trigger computation when a label is removed from the change.
+   *
+   * The second parameter is just for *triggering* computation.
    */
-  _computeShowPlaceholder(labelInfo?: LabelInfo) {
+  private computeShowPlaceholder(
+    labelInfo?: LabelInfo,
+    _?: LabelNameToInfoMap
+  ) {
     if (!labelInfo) {
       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
deleted file mode 100644
index 56ab8c6..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_html.ts
+++ /dev/null
@@ -1,130 +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="gr-voting-styles">
-    /* Workaround for empty style block - see https://github.com/Polymer/tools/issues/408 */
-  </style>
-  <style include="shared-styles">
-    .placeholder {
-      color: var(--deemphasized-text-color);
-    }
-    .hidden {
-      display: none;
-    }
-    .voteChip {
-      display: flex;
-      justify-content: center;
-      margin-right: var(--spacing-s);
-      padding: 1px;
-      @apply --vote-chip-styles;
-      border: 1px solid var(--border-color);
-    }
-    .max {
-      background-color: var(--vote-color-approved);
-    }
-    .min {
-      background-color: var(--vote-color-rejected);
-    }
-    .positive {
-      background-color: var(--vote-color-recommended);
-      border-radius: 12px;
-      border: 1px solid var(--vote-outline-recommended);
-      color: var(--chip-color);
-    }
-    .negative {
-      background-color: var(--vote-color-disliked);
-      border-radius: 12px;
-      border: 1px solid var(--vote-outline-disliked);
-      color: var(--chip-color);
-    }
-    .hidden {
-      display: none;
-    }
-    td {
-      vertical-align: top;
-    }
-    tr {
-      min-height: var(--line-height-normal);
-    }
-    gr-button {
-      vertical-align: top;
-      --gr-button: {
-        height: var(--line-height-normal);
-        width: var(--line-height-normal);
-        padding: 0;
-      }
-    }
-    gr-button[disabled] iron-icon {
-      color: var(--border-color);
-    }
-    gr-account-link {
-      --account-max-length: 100px;
-      margin-right: var(--spacing-xs);
-    }
-    iron-icon {
-      height: calc(var(--line-height-normal) - 2px);
-      width: calc(var(--line-height-normal) - 2px);
-    }
-    .labelValueContainer:not(:first-of-type) td {
-      padding-top: var(--spacing-s);
-    }
-  </style>
-  <p
-    class$="placeholder [[_computeShowPlaceholder(labelInfo, change.labels.*)]]"
-  >
-    No votes.
-  </p>
-  <table>
-    <template
-      is="dom-repeat"
-      items="[[_mapLabelInfo(labelInfo, account, change.labels.*)]]"
-      as="mappedLabel"
-    >
-      <tr class="labelValueContainer">
-        <td>
-          <gr-label
-            has-tooltip=""
-            title="[[_computeValueTooltip(labelInfo, mappedLabel.value)]]"
-            class$="[[mappedLabel.className]] voteChip font-small"
-          >
-            [[mappedLabel.value]]
-          </gr-label>
-        </td>
-        <td>
-          <gr-account-link
-            account="[[mappedLabel.account]]"
-            change="[[change]]"
-          ></gr-account-link>
-        </td>
-        <td>
-          <gr-button
-            link=""
-            aria-label="Remove vote"
-            on-click="_onDeleteVote"
-            tooltip="Remove vote"
-            data-account-id$="[[mappedLabel.account._account_id]]"
-            class$="deleteBtn [[_computeDeleteClass(mappedLabel.account, mutable, change)]]"
-          >
-            <iron-icon icon="gr-icons:delete"></iron-icon>
-          </gr-button>
-        </td>
-      </tr>
-    </template>
-  </table>
-`;
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
deleted file mode 100644
index e8a38ec..0000000
--- a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.js
+++ /dev/null
@@ -1,236 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-import '../../../test/common-test-setup-karma.js';
-import './gr-label-info.js';
-import {isHidden, stubRestApi} from '../../../test/test-utils.js';
-
-const basicFixture = fixtureFromElement('gr-label-info');
-
-suite('gr-label-info tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-
-    // Needed to trigger computed bindings.
-    element.account = {};
-    element.change = {labels: {}};
-  });
-
-  suite('remove reviewer votes', () => {
-    setup(() => {
-      sinon.stub(element, '_computeValueTooltip').returns('');
-      element.account = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      const test = {
-        all: [{_account_id: 1, name: 'bojack', value: 1}],
-        default_value: 0,
-        values: [],
-      };
-      element.change = {
-        _number: 42,
-        change_id: 'the id',
-        actions: [],
-        topic: 'the topic',
-        status: 'NEW',
-        submit_type: 'CHERRY_PICK',
-        labels: {test},
-        removable_reviewers: [],
-      };
-      element.labelInfo = test;
-      element.label = 'test';
-
-      flush();
-    });
-
-    test('_computeCanDeleteVote', () => {
-      element.mutable = false;
-      const button = element.shadowRoot
-          .querySelector('gr-button');
-      assert.isTrue(isHidden(button));
-      element.change.removable_reviewers = [element.account];
-      element.mutable = true;
-      assert.isFalse(isHidden(button));
-    });
-
-    test('deletes votes', () => {
-      const deleteResponse = Promise.resolve({ok: true});
-      const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
-
-      element.change.removable_reviewers = [element.account];
-      element.change.labels.test.recommended = {_account_id: 1};
-      element.mutable = true;
-      const button = element.shadowRoot
-          .querySelector('gr-button');
-      MockInteractions.tap(button);
-      assert.isTrue(button.disabled);
-      return deleteResponse.then(() => {
-        assert.isFalse(button.disabled);
-        assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
-      });
-    });
-  });
-
-  suite('label color and order', () => {
-    test('valueless label rejected', () => {
-      element.labelInfo = {rejected: {name: 'someone'}};
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('negative'));
-    });
-
-    test('valueless label approved', () => {
-      element.labelInfo = {approved: {name: 'someone'}};
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('positive'));
-    });
-
-    test('-2 to +2', () => {
-      element.labelInfo = {
-        all: [
-          {value: 2, name: 'user 2'},
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 3'},
-          {value: -2, name: 'user 4'},
-        ],
-        values: {
-          '-2': 'Awful',
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-          '+2': 'Ready to submit',
-        },
-      };
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-      assert.isTrue(labels[2].classList.contains('negative'));
-      assert.isTrue(labels[3].classList.contains('min'));
-    });
-
-    test('-1 to +1', () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1'},
-          {value: -1, name: 'user 2'},
-        ],
-        values: {
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('min'));
-    });
-
-    test('0 to +2', () => {
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 2'},
-          {value: 2, name: 'user '},
-        ],
-        values: {
-          ' 0': 'Don\'t submit as-is',
-          '+1': 'No score',
-          '+2': 'Looks good to me',
-        },
-      };
-      flush();
-      const labels = element.root.querySelectorAll('gr-label');
-      assert.isTrue(labels[0].classList.contains('max'));
-      assert.isTrue(labels[1].classList.contains('positive'));
-    });
-
-    test('self votes at top', () => {
-      element.account = {
-        _account_id: 1,
-        name: 'bojack',
-      };
-      element.labelInfo = {
-        all: [
-          {value: 1, name: 'user 1', _account_id: 2},
-          {value: -1, name: 'bojack', _account_id: 1},
-        ],
-        values: {
-          '-1': 'Don\'t submit as-is',
-          ' 0': 'No score',
-          '+1': 'Looks good to me',
-        },
-      };
-      flush();
-      const chips =
-          element.root.querySelectorAll('gr-account-link');
-      assert.equal(chips[0].account._account_id, element.account._account_id);
-    });
-  });
-
-  test('_computeValueTooltip', () => {
-    // Existing label.
-    let labelInfo = {values: {0: 'Baz'}};
-    let score = '0';
-    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
-
-    // Non-existent score.
-    score = '2';
-    assert.equal(element._computeValueTooltip(labelInfo, score), '');
-
-    // No values on label.
-    labelInfo = {values: {}};
-    score = '0';
-    assert.equal(element._computeValueTooltip(labelInfo, score), '');
-  });
-
-  test('placeholder', () => {
-    const values = {
-      '0': 'No score',
-      '+1': 'good',
-      '+2': 'excellent',
-      '-1': 'bad',
-      '-2': 'terrible',
-    };
-    element.labelInfo = {};
-    assert.isFalse(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {all: [], values};
-    assert.isFalse(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {all: [{value: 1}], values};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {rejected: []};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {values: [], rejected: [], all: [{value: 1}, values]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {approved: []};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-    element.labelInfo = {values: [], approved: [], all: [{value: 1}, values]};
-    assert.isTrue(isHidden(element.shadowRoot
-        .querySelector('.placeholder')));
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
new file mode 100644
index 0000000..cad1f69
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-label-info/gr-label-info_test.ts
@@ -0,0 +1,266 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-label-info';
+import {
+  isHidden,
+  mockPromise,
+  queryAll,
+  queryAndAssert,
+  stubRestApi,
+} from '../../../test/test-utils';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {GrLabelInfo} from './gr-label-info';
+import {GrButton} from '../gr-button/gr-button';
+import {GrLabel} from '../gr-label/gr-label';
+import {GrAccountLink} from '../gr-account-link/gr-account-link';
+import {
+  createAccountWithIdNameAndEmail,
+  createParsedChange,
+} from '../../../test/test-data-generators';
+import {LabelInfo} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-label-info');
+
+suite('gr-label-info tests', () => {
+  let element: GrLabelInfo;
+  const account = createAccountWithIdNameAndEmail(5);
+
+  setup(() => {
+    element = basicFixture.instantiate();
+
+    // Needed to trigger computed bindings.
+    element.account = {};
+    element.change = {...createParsedChange(), labels: {}};
+  });
+
+  suite('remove reviewer votes', () => {
+    const label: LabelInfo = {
+      all: [{...account, value: 1}],
+      default_value: 0,
+      values: {},
+    };
+
+    setup(async () => {
+      sinon.stub(element, '_computeValueTooltip').returns('');
+      element.account = account;
+      element.change = {
+        ...createParsedChange(),
+        labels: {'Code-Review': label},
+      };
+      element.labelInfo = label;
+      element.label = 'Code-Review';
+
+      await element.updateComplete;
+    });
+
+    test('_computeCanDeleteVote', async () => {
+      element.mutable = false;
+      await element.updateComplete;
+      const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
+      assert.isTrue(isHidden(removeButton));
+      element.change!.removable_reviewers = [account];
+      element.mutable = true;
+      await element.updateComplete;
+      assert.isFalse(isHidden(removeButton));
+    });
+
+    test('deletes votes', async () => {
+      const mock = mockPromise();
+      const deleteResponse = mock.then(() => new Response(null, {status: 200}));
+      const deleteStub = stubRestApi('deleteVote').returns(deleteResponse);
+      element.change!.removable_reviewers = [account];
+      element.change!.labels!['Code-Review'] = {
+        ...label,
+        recommended: account,
+      };
+      element.mutable = true;
+      const removeButton = queryAndAssert<GrButton>(element, 'gr-button');
+
+      MockInteractions.tap(removeButton);
+      assert.isTrue(removeButton.disabled);
+      mock.resolve();
+      await deleteResponse;
+
+      assert.isFalse(removeButton.disabled);
+      assert.isTrue(
+        deleteStub.calledWithExactly(
+          element.change!._number,
+          account._account_id!,
+          'Code-Review'
+        )
+      );
+    });
+  });
+
+  suite('label color and order', () => {
+    test('valueless label rejected', async () => {
+      element.labelInfo = {rejected: {name: 'someone'}};
+      await element.updateComplete;
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('negative'));
+    });
+
+    test('valueless label approved', async () => {
+      element.labelInfo = {approved: {name: 'someone'}};
+      await element.updateComplete;
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('positive'));
+    });
+
+    test('-2 to +2', async () => {
+      element.labelInfo = {
+        all: [
+          {value: 2, name: 'user 2'},
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 3'},
+          {value: -2, name: 'user 4'},
+        ],
+        values: {
+          '-2': 'Awful',
+          '-1': "Don't submit as-is",
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+          '+2': 'Ready to submit',
+        },
+      };
+      await element.updateComplete;
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+      assert.isTrue(labels[2].classList.contains('negative'));
+      assert.isTrue(labels[3].classList.contains('min'));
+    });
+
+    test('-1 to +1', async () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 1'},
+          {value: -1, name: 'user 2'},
+        ],
+        values: {
+          '-1': "Don't submit as-is",
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      await element.updateComplete;
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('min'));
+    });
+
+    test('0 to +2', async () => {
+      element.labelInfo = {
+        all: [
+          {value: 1, name: 'user 2'},
+          {value: 2, name: 'user '},
+        ],
+        values: {
+          ' 0': "Don't submit as-is",
+          '+1': 'No score',
+          '+2': 'Looks good to me',
+        },
+      };
+      await element.updateComplete;
+      const labels = queryAll<GrLabel>(element, 'gr-label');
+      assert.isTrue(labels[0].classList.contains('max'));
+      assert.isTrue(labels[1].classList.contains('positive'));
+    });
+
+    test('self votes at top', async () => {
+      const otherAccount = createAccountWithIdNameAndEmail(8);
+      element.account = account;
+      element.labelInfo = {
+        all: [
+          {...otherAccount, value: 1},
+          {...account, value: -1},
+        ],
+        values: {
+          '-1': "Don't submit as-is",
+          ' 0': 'No score',
+          '+1': 'Looks good to me',
+        },
+      };
+      await element.updateComplete;
+      const chips = queryAll<GrAccountLink>(element, 'gr-account-link');
+      assert.equal(chips[0].account!._account_id, element.account._account_id);
+    });
+  });
+
+  test('_computeValueTooltip', () => {
+    // Existing label.
+    let labelInfo: LabelInfo = {values: {0: 'Baz'}};
+    let score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), 'Baz');
+
+    // Non-existent score.
+    score = '2';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+
+    // No values on label.
+    labelInfo = {values: {}};
+    score = '0';
+    assert.equal(element._computeValueTooltip(labelInfo, score), '');
+  });
+
+  test('placeholder', async () => {
+    const values = {
+      '0': 'No score',
+      '+1': 'good',
+      '+2': 'excellent',
+      '-1': 'bad',
+      '-2': 'terrible',
+    };
+    element.labelInfo = {};
+    await element.updateComplete;
+    assert.isFalse(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {all: [], values};
+    await element.updateComplete;
+    assert.isFalse(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {all: [{value: 1}], values};
+    await element.updateComplete;
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {rejected: account};
+    await element.updateComplete;
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {rejected: account, all: [{value: 1}], values};
+    await element.updateComplete;
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {approved: account};
+    await element.updateComplete;
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+    element.labelInfo = {approved: account, all: [{value: 1}], values};
+    await element.updateComplete;
+    assert.isTrue(
+      isHidden(queryAndAssert<HTMLParagraphElement>(element, '.placeholder'))
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
index c13ca6e..842b35e 100644
--- a/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
+++ b/polygerrit-ui/app/elements/shared/gr-label/gr-label.ts
@@ -21,10 +21,8 @@
  * used in gr-label-info.
  */
 
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {customElement} from '@polymer/decorators';
-import {htmlTemplate} from './gr-label_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -33,8 +31,12 @@
 }
 
 @customElement('gr-label')
-export class GrLabel extends TooltipMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
+export class GrLabel extends LitElement {
+  static override get styles() {
+    return [];
+  }
+
+  override render() {
+    return html` <slot></slot> `;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
index 4be79e2..85f29cb 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete.ts
@@ -51,10 +51,10 @@
   label?: string;
 
   @property({type: String})
-  placeholder?: string;
+  placeholder = '';
 
   @property({type: Boolean})
-  disabled?: boolean;
+  disabled = false;
 
   _handleTriggerClick(e: Event) {
     // Stop propagation here so we don't confuse gr-autocomplete, which
diff --git a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
similarity index 62%
rename from polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
rename to polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
index 3e904a2..d6fc45f 100644
--- a/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-labeled-autocomplete/gr-labeled-autocomplete_test.ts
@@ -15,13 +15,14 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-labeled-autocomplete.js';
+import '../../../test/common-test-setup-karma';
+import './gr-labeled-autocomplete';
+import {GrLabeledAutocomplete} from './gr-labeled-autocomplete';
 
 const basicFixture = fixtureFromElement('gr-labeled-autocomplete');
 
 suite('gr-labeled-autocomplete tests', () => {
-  let element;
+  let element: GrLabeledAutocomplete;
 
   setup(() => {
     element = basicFixture.instantiate();
@@ -29,17 +30,16 @@
 
   test('tapping trigger focuses autocomplete', () => {
     const e = {stopPropagation: () => undefined};
-    sinon.stub(e, 'stopPropagation');
-    sinon.stub(element.$.autocomplete, 'focus');
-    element._handleTriggerClick(e);
-    assert.isTrue(e.stopPropagation.calledOnce);
-    assert.isTrue(element.$.autocomplete.focus.calledOnce);
+    const stopPropagationStub = sinon.stub(e, 'stopPropagation');
+    const autocompleteStub = sinon.stub(element.$.autocomplete, 'focus');
+    element._handleTriggerClick(e as Event);
+    assert.isTrue(stopPropagationStub.calledOnce);
+    assert.isTrue(autocompleteStub.calledOnce);
   });
 
   test('setText', () => {
-    sinon.stub(element.$.autocomplete, 'setText');
+    const setTextStub = sinon.stub(element.$.autocomplete, 'setText');
     element.setText('foo-bar');
-    assert.isTrue(element.$.autocomplete.setText.calledWith('foo-bar'));
+    assert.isTrue(setTextStub.calledWith('foo-bar'));
   });
 });
-
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 655acde..a3f128b 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
@@ -14,83 +14,50 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../gr-js-api-interface/gr-js-api-interface';
-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';
-
-type HljsCallback = (value?: HighlightJS) => void;
-
-interface HljsState {
-  configured: boolean;
-  loading: boolean;
-  callbacks: HljsCallback[];
+export interface LibraryConfig {
+  /** Path to the library to be loaded. */
+  src: string;
+  /**
+   * Optional check to see if the library has already been loaded outside of
+   * this class. If this returns true, src will not be loaded, but
+   * configureCallback will be run if present.
+   */
+  checkPresent?: () => boolean;
+  /**
+   * Optional library initialization to be run once after loading the library,
+   * before resolving promises for getLibrary(). Promises returned from
+   * getLibrary() will resolve to the return value of this function, if any.
+   */
+  configureCallback?: () => unknown;
 }
 
 export class GrLibLoader {
-  private readonly jsAPI = appContext.jsApiService;
-
-  _hljsState: HljsState = {
-    configured: false,
-    loading: false,
-    callbacks: [],
-  };
-
-  /**
-   * Get the HLJS library. Returns a promise that resolves with a reference to
-   * the library after it's been loaded. The promise resolves immediately if
-   * it's already been loaded.
+  /*
+   * Pending library loads, keyed by library config, populated when getLibrary()
+   * is first called for a given config. This retains the promise for each
+   * library so that later calls of getLibrary() for the same config can
+   * directly return a resolved promise.
    */
-  getHLJS(): Promise<HighlightJS | undefined> {
-    return new Promise<HighlightJS | undefined>((resolve, reject) => {
-      // If the lib is totally loaded, resolve immediately.
-      if (this._getHighlightLib()) {
-        resolve(this._getHighlightLib());
-        return;
-      }
+  private readonly libraries = new Map<LibraryConfig, Promise<unknown>>();
 
-      // If the library is not currently being loaded, then start loading it.
-      if (!this._hljsState.loading) {
-        this._hljsState.loading = true;
-        this._loadScript(this._getHLJSUrl())
-          .then(() => this._onHLJSLibLoaded())
-          .catch(reject);
-      }
-
-      this._hljsState.callbacks.push(resolve);
-    });
+  _getPath(src: string) {
+    const root = this._getLibRoot();
+    return root ? root + src : null;
   }
 
-  /**
-   * Execute callbacks awaiting the HLJS lib load.
-   */
-  _onHLJSLibLoaded() {
-    const lib = this._getHighlightLib();
-    this._hljsState.loading = false;
-    this.jsAPI.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
-      hljs: lib,
-    });
-    for (const cb of this._hljsState.callbacks) {
-      cb(lib);
+  getLibrary(config: LibraryConfig): Promise<unknown> {
+    if (!this.libraries.has(config)) {
+      const loaded =
+        config.checkPresent && config.checkPresent()
+          ? Promise.resolve()
+          : this._loadScript(this._getPath(config.src));
+      const configured = loaded.then(() =>
+        config.configureCallback ? config.configureCallback() : undefined
+      );
+      this.libraries.set(config, configured);
     }
-    this._hljsState.callbacks = [];
-  }
-
-  /**
-   * Get the HLJS library, assuming it has been loaded. Configure the library
-   * if it hasn't already been configured.
-   */
-  _getHighlightLib(): HighlightJS | undefined {
-    const lib = window.hljs;
-    if (lib && !this._hljsState.configured) {
-      this._hljsState.configured = true;
-
-      lib.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
-    }
-    return lib;
+    return this.libraries.get(config)!;
   }
 
   /**
@@ -126,12 +93,4 @@
       document.head.appendChild(script);
     });
   }
-
-  _getHLJSUrl() {
-    const root = this._getLibRoot();
-    if (!root) {
-      return null;
-    }
-    return root + HLJS_PATH;
-  }
 }
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 c89ff8e..e83698f 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
@@ -22,101 +22,182 @@
 suite('gr-lib-loader tests', () => {
   let grLibLoader;
   let resolveLoad;
+  let rejectLoad;
   let loadStub;
 
   setup(() => {
     grLibLoader = new GrLibLoader();
 
     loadStub = sinon.stub(grLibLoader, '_loadScript').callsFake(() =>
-      new Promise(resolve => resolveLoad = resolve)
+      new Promise((resolve, reject) => {
+        resolveLoad = resolve;
+        rejectLoad = reject;
+      })
     );
-
-    // Assert preconditions:
-    assert.isFalse(grLibLoader._hljsState.loading);
   });
 
-  teardown(() => {
-    if (window.hljs) {
-      delete window.hljs;
-    }
+  test('notifies all callers when loaded', async () => {
+    const libraryConfig = {src: 'foo.js'};
 
-    // Because the element state is a singleton, clean it up.
-    grLibLoader._hljsState.configured = false;
-    grLibLoader._hljsState.loading = false;
-    grLibLoader._hljsState.callbacks = [];
-  });
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
 
-  test('only load once', async () => {
-    sinon.stub(grLibLoader, '_getHLJSUrl').returns('');
-    const firstCallHandler = sinon.stub();
-    grLibLoader.getHLJS().then(firstCallHandler);
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
 
-    // It should now be in the loading state.
-    assert.isTrue(loadStub.called);
-    assert.isTrue(grLibLoader._hljsState.loading);
-    assert.isFalse(firstCallHandler.called);
-
-    const secondCallHandler = sinon.stub();
-    grLibLoader.getHLJS().then(secondCallHandler);
-
-    // No change in state.
-    assert.isTrue(grLibLoader._hljsState.loading);
-    assert.isFalse(firstCallHandler.called);
-    assert.isFalse(secondCallHandler.called);
-
-    // Now load the library.
     resolveLoad();
     await flush();
-    // The state should be loaded and both handlers called.
-    assert.isFalse(grLibLoader._hljsState.loading);
-    assert.isTrue(firstCallHandler.called);
-    assert.isTrue(secondCallHandler.called);
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await flush();
+
+    assert.isTrue(loaded1.calledOnce);
+    assert.isTrue(loaded2.calledOnce);
+    assert.isTrue(lateLoaded.calledOnce);
+  });
+
+  test('notifies all callers when failed', async () => {
+    const libraryConfig = {src: 'foo.js'};
+
+    const failed1 = sinon.stub();
+    const failed2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).catch(failed1);
+    grLibLoader.getLibrary(libraryConfig).catch(failed2);
+
+    rejectLoad();
+    await flush();
+
+    const lateFailed = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).catch(lateFailed);
+
+    await flush();
+
+    assert.isTrue(failed1.calledOnce);
+    assert.isTrue(failed2.calledOnce);
+    assert.isTrue(lateFailed.calledOnce);
+  });
+
+  test('runs library configuration only once', async () => {
+    const configureCallback = sinon.stub();
+    const libraryConfig = {
+      src: 'foo.js',
+      configureCallback,
+    };
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    resolveLoad();
+    await flush();
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await flush();
+
+    assert.isTrue(configureCallback.calledOnce);
+  });
+
+  test('resolves to result of configureCallback, if any', async () => {
+    const library = {someFunction: () => 'foobar'};
+
+    const libraryConfig = {
+      src: 'foo.js',
+      configureCallback: () => window.library,
+    };
+
+    const loaded1 = sinon.stub();
+    const loaded2 = sinon.stub();
+
+    grLibLoader.getLibrary(libraryConfig).then(loaded1);
+    grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+    window.library = library;
+    resolveLoad();
+    await flush();
+
+    assert.isTrue(loaded1.calledWith(library));
+    assert.isTrue(loaded2.calledWith(library));
+
+    const lateLoaded = sinon.stub();
+    grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+    await flush();
+
+    assert.isTrue(lateLoaded.calledWith(library));
   });
 
   suite('preloaded', () => {
-    let hljsStub;
-
     setup(() => {
-      hljsStub = {
-        configure: sinon.stub(),
+      window.library = {
+        initialize: sinon.stub(),
       };
-      window.hljs = hljsStub;
     });
 
     teardown(() => {
-      delete window.hljs;
+      delete window.library;
     });
 
-    test('returns hljs', async () => {
-      const firstCallHandler = sinon.stub();
-      grLibLoader.getHLJS().then(firstCallHandler);
+    test('does not load library again if detected present', async () => {
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => window.library !== undefined,
+      };
+
+      const loaded1 = sinon.stub();
+      const loaded2 = sinon.stub();
+
+      grLibLoader.getLibrary(libraryConfig).then(loaded1);
+      grLibLoader.getLibrary(libraryConfig).then(loaded2);
+
+      resolveLoad();
       await flush();
-      assert.isTrue(firstCallHandler.called);
-      assert.isTrue(firstCallHandler.calledWith(hljsStub));
+
+      const lateLoaded = sinon.stub();
+      grLibLoader.getLibrary(libraryConfig).then(lateLoaded);
+
+      await flush();
+
+      assert.isFalse(loadStub.called);
+      assert.isTrue(loaded1.called);
+      assert.isTrue(loaded2.called);
+      assert.isTrue(lateLoaded.called);
     });
 
-    test('configures hljs', () => grLibLoader.getHLJS().then(() => {
-      assert.isTrue(window.hljs.configure.calledOnce);
-    }));
-  });
+    test('runs configuration for externally loaded library', async () => {
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => window.library !== undefined,
+        configureCallback: () => window.library.initialize(),
+      };
 
-  suite('_getHLJSUrl', () => {
-    suite('checking _getLibRoot', () => {
-      let root;
+      grLibLoader.getLibrary(libraryConfig);
 
-      setup(() => {
-        sinon.stub(grLibLoader, '_getLibRoot').callsFake(() => root);
-      });
+      resolveLoad();
+      await flush();
 
-      test('with no root', () => {
-        assert.isNull(grLibLoader._getHLJSUrl());
-      });
+      assert.isTrue(window.library.initialize.calledOnce);
+    });
 
-      test('with root', () => {
-        root = 'test-root.com/';
-        assert.equal(grLibLoader._getHLJSUrl(),
-            'test-root.com/bower_components/highlightjs/highlight.min.js');
-      });
+    test('loads library again if not detected present', async () => {
+      window.library = undefined;
+      const libraryConfig = {
+        src: 'foo.js',
+        checkPresent: () => window.library !== undefined,
+      };
+
+      grLibLoader.getLibrary(libraryConfig);
+
+      resolveLoad();
+      await flush();
+
+      assert.isTrue(loadStub.called);
     });
   });
 });
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
new file mode 100644
index 0000000..da13396
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/highlightjs_config.ts
@@ -0,0 +1,35 @@
+/**
+ * @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 '../gr-js-api-interface/gr-js-api-interface';
+
+import {EventType} from '../../../api/plugin';
+import {appContext} from '../../../services/app-context';
+
+import {LibraryConfig} from './gr-lib-loader';
+
+export const HLJS_LIBRARY_CONFIG: LibraryConfig = {
+  // preloaded in PolyGerritIndexHtml.soy
+  src: 'bower_components/highlightjs/highlight.min.js',
+  checkPresent: () => window.hljs !== undefined,
+  configureCallback: () => {
+    window.hljs!.configure({classPrefix: 'gr-diff gr-syntax gr-syntax-'});
+    appContext.jsApiService.handleEvent(EventType.HIGHLIGHTJS_LOADED, {
+      hljs: window.hljs,
+    });
+    return window.hljs;
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
new file mode 100644
index 0000000..872d01c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-lib-loader/resemblejs_config.ts
@@ -0,0 +1,33 @@
+/**
+ * @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 {LibraryConfig} from './gr-lib-loader';
+
+export const RESEMBLEJS_LIBRARY_CONFIG: LibraryConfig = {
+  src: 'bower_components/resemblejs/resemble.js',
+  checkPresent: () => window.resemble !== undefined,
+  configureCallback: () => {
+    window.resemble!.outputSettings({
+      errorColor: {red: 255, green: 0, blue: 255},
+      errorType: 'flat',
+      transparency: 0,
+      // Disable large image threshold; by default this otherwise skips pixels
+      // if width or height exceed 1200 pixels.
+      largeImageThreshold: 0,
+    });
+    return window.resemble;
+  },
+};
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
index f76f8d7..7008db2 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.ts
@@ -14,10 +14,8 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-limited-text_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {customElement, observe, property} from '@polymer/decorators';
+import {customElement, property} from 'lit/decorators';
+import {html, LitElement} from 'lit';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -32,58 +30,56 @@
  * and a tooltip containing the full text is enabled.
  */
 @customElement('gr-limited-text')
-export class GrLimitedText extends TooltipMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLimitedText extends LitElement {
   /** The un-truncated text to display. */
   @property({type: String})
   text = '';
 
   /** The maximum length for the text to display before truncating. */
   @property({type: Number})
-  limit: number | null = null;
+  limit = 0;
 
   @property({type: String})
-  tooltip = '';
+  tooltip?: string;
 
-  /** Boolean property used by TooltipMixin. */
-  @property({type: Boolean})
-  hasTooltip = false;
+  static override get styles() {
+    return [];
+  }
 
-  /** Boolean property used by TooltipMixin. */
-  @property({type: Boolean})
-  disableTooltip = false;
-
-  /**
-   * The text or limit have changed. Recompute whether a tooltip needs to be
-   * enabled.
-   */
-  @observe('text', 'tooltip', 'limit')
-  _updateTitle(text: string, tooltip: string, limit?: number) {
-    // Polymer 2: check for undefined
-    if ([text, limit, tooltip].includes(undefined)) {
-      return;
-    }
-
-    this.hasTooltip = !!tooltip || (!!limit && text.length > limit);
-    if (this.hasTooltip && !this.disableTooltip) {
-      // Combine the text and title if over-length
-      if (limit && text.length > limit) {
-        this.title = `${text}${tooltip ? ` (${tooltip})` : ''}`;
-      } else {
-        this.title = tooltip;
-      }
+  override render() {
+    if (this.tooltip || this.tooLong()) {
+      return html` <gr-tooltip-content
+        has-tooltip
+        .title=${this.renderTooltip()}
+      >
+        ${this.renderText()}
+      </gr-tooltip-content>`;
     } else {
-      this.title = '';
+      return this.renderText();
     }
   }
 
-  _computeDisplayText(text: string, limit?: number) {
-    if (!!limit && !!text && text.length > limit) {
-      return text.substr(0, limit - 1) + '…';
+  // Should be private but used in tests.
+  renderText() {
+    if (this.tooLong()) {
+      return this.text.substr(0, this.limit - 1) + '…';
     }
-    return text;
+    return this.text;
+  }
+
+  private renderTooltip() {
+    if (this.tooLong()) {
+      return `${this.text}${this.tooltip ? ` (${this.tooltip})` : ''}`;
+    } else if (this.tooltip) {
+      return this.tooltip;
+    } else {
+      return '';
+    }
+  }
+
+  private tooLong() {
+    if (!this.limit) return false;
+    if (!this.text) return false;
+    return this.text.length > this.limit;
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
deleted file mode 100644
index b942d07..0000000
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_html.ts
+++ /dev/null
@@ -1,19 +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` [[_computeDisplayText(text, limit)]] `;
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
index 3b99d6d..e3e72d0 100644
--- a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.js
@@ -23,77 +23,65 @@
 suite('gr-limited-text tests', () => {
   let element;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await element.updateComplete;
   });
 
-  test('tooltip without title input', () => {
-    const updateSpy = sinon.spy(element, '_updateTitle');
+  test('tooltip without title input', async () => {
     element.text = 'abc 123';
-    flush();
-    assert.isTrue(updateSpy.calledOnce);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
 
     element.limit = 10;
-    flush();
-    assert.isTrue(updateSpy.calledTwice);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
 
     element.limit = 3;
-    flush();
-    assert.equal(updateSpy.callCount, 3);
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.equal(element.title, 'abc 123');
-    assert.isTrue(element.hasTooltip);
+    await element.updateComplete;
+    assert.isOk(element.shadowRoot.querySelector('gr-tooltip-content'));
+    assert.equal(
+        element.shadowRoot.querySelector('gr-tooltip-content').title,
+        'abc 123');
 
     element.limit = 100;
-    flush();
-    assert.equal(updateSpy.callCount, 4);
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
 
     element.limit = null;
-    flush();
-    assert.equal(updateSpy.callCount, 5);
-    assert.isNotOk(element.getAttribute('title'));
-    assert.isFalse(element.hasTooltip);
+    await element.updateComplete;
+    assert.isNotOk(element.shadowRoot.querySelector('gr-tooltip-content'));
   });
 
-  test('with tooltip input', () => {
-    const updateSpy = sinon.spy(element, '_updateTitle');
+  test('with tooltip input', async () => {
     element.tooltip = 'abc 123';
-    flush();
-    assert.isTrue(updateSpy.calledOnce);
-    assert.isTrue(element.hasTooltip);
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.equal(element.title, 'abc 123');
+    await element.updateComplete;
+    let tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abc 123');
 
     element.text = 'abc';
-    flush();
-    assert.equal(element.getAttribute('title'), 'abc 123');
-    assert.isTrue(element.hasTooltip);
+    await element.updateComplete;
+    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abc 123');
 
     element.text = 'abcdef';
     element.limit = 3;
-    flush();
-    assert.equal(element.getAttribute('title'), 'abcdef (abc 123)');
-    assert.isTrue(element.hasTooltip);
+    await element.updateComplete;
+    tooltipContent = element.shadowRoot.querySelector('gr-tooltip-content');
+    assert.isOk(tooltipContent);
+    assert.equal(tooltipContent.title, 'abcdef (abc 123)');
   });
 
   test('_computeDisplayText', () => {
-    assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
-    assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
-    assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
-  });
-
-  test('when disable tooltip', () => {
-    sinon.spy(element, '_updateTitle');
-    element.text = 'abcdefghijklmn';
-    element.disableTooltip = true;
-    element.limit = 10;
-    flush();
-    assert.equal(element.getAttribute('title'), '');
+    element.text = 'foo bar';
+    element.limit = 100;
+    assert.equal(element.renderText(), 'foo bar');
+    element.limit = 4;
+    assert.equal(element.renderText(), 'foo…');
+    element.limit = 0;
+    assert.equal(element.renderText(), 'foo bar');
   });
 });
 
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 615eac8..801b8bf 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
@@ -18,11 +18,10 @@
 import '../gr-button/gr-button';
 import '../gr-icons/gr-icons';
 import '../gr-limited-text/gr-limited-text';
-import '../../../styles/shared-styles';
-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';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -31,22 +30,18 @@
 }
 
 @customElement('gr-linked-chip')
-export class GrLinkedChip extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrLinkedChip extends LitElement {
   @property({type: String})
-  href?: string;
+  href = '';
 
-  @property({type: Boolean, reflectToAttribute: true})
+  @property({type: Boolean, reflect: true})
   disabled = false;
 
   @property({type: Boolean})
   removable = false;
 
   @property({type: String})
-  text?: string;
+  text = '';
 
   @property({type: Boolean})
   transparentBackground = false;
@@ -55,6 +50,87 @@
   @property({type: Number})
   limit?: number;
 
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        :host {
+          display: block;
+          overflow: hidden;
+        }
+        .container {
+          align-items: center;
+          background: var(--chip-background-color);
+          border-radius: 0.75em;
+          display: inline-flex;
+          padding: 0 var(--spacing-m);
+        }
+        .transparentBackground,
+        gr-button.transparentBackground {
+          background-color: transparent;
+        }
+        :host([disabled]) {
+          opacity: 0.6;
+          pointer-events: none;
+        }
+        a {
+          color: var(--linked-chip-text-color);
+        }
+        iron-icon {
+          height: 1.2rem;
+          width: 1.2rem;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    // To pass CSS mixins for @apply to Polymer components, they need to appear
+    // in <style> inside the template.
+    /* eslint-disable lit/prefer-static-styles */
+    const customStyle = html`
+      <style>
+        gr-button::part(paper-button),
+        gr-button.remove:hover::part(paper-button),
+        gr-button.remove:focus::part(paper-button) {
+          border-top-width: 0;
+          border-right-width: 0;
+          border-bottom-width: 0;
+          border-left-width: 0;
+          color: var(--deemphasized-text-color);
+          font-weight: var(--font-weight-normal);
+          height: 0.6em;
+          line-height: 10px;
+          margin-left: var(--spacing-xs);
+          padding: 0;
+          text-decoration: none;
+        }
+      </style>
+    `;
+    return html`${customStyle}
+      <div
+        class="container ${this._getBackgroundClass(
+          this.transparentBackground
+        )}"
+      >
+        <a href="${this.href}">
+          <gr-limited-text
+            .limit="${this.limit}"
+            .text="${this.text}"
+          ></gr-limited-text>
+        </a>
+        <gr-button
+          id="remove"
+          link=""
+          ?hidden=${!this.removable}
+          class="remove ${this._getBackgroundClass(this.transparentBackground)}"
+          @click=${this._handleRemoveTap}
+        >
+          <iron-icon icon="gr-icons:close"></iron-icon>
+        </gr-button>
+      </div>`;
+  }
+
   _getBackgroundClass(transparent: boolean) {
     return transparent ? 'transparentBackground' : '';
   }
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
deleted file mode 100644
index cac88b7..0000000
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_html.ts
+++ /dev/null
@@ -1,90 +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">
-    :host {
-      display: block;
-      overflow: hidden;
-    }
-    .container {
-      align-items: center;
-      background: var(--chip-background-color);
-      border-radius: 0.75em;
-      display: inline-flex;
-      padding: 0 var(--spacing-m);
-    }
-    gr-button.remove {
-      --gr-remove-button-style: {
-        border-top-width: 0;
-        border-right-width: 0;
-        border-bottom-width: 0;
-        border-left-width: 0;
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-normal);
-        height: 0.6em;
-        line-height: 10px;
-        margin-left: var(--spacing-xs);
-        padding: 0;
-        text-decoration: none;
-      }
-    }
-
-    gr-button.remove:hover,
-    gr-button.remove:focus {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    gr-button.remove {
-      --gr-button: {
-        @apply --gr-remove-button-style;
-      }
-    }
-    .transparentBackground,
-    gr-button.transparentBackground {
-      background-color: transparent;
-    }
-    :host([disabled]) {
-      opacity: 0.6;
-      pointer-events: none;
-    }
-    a {
-      color: var(--linked-chip-text-color);
-    }
-    iron-icon {
-      height: 1.2rem;
-      width: 1.2rem;
-    }
-  </style>
-  <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-    <a href$="[[href]]">
-      <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
-    </a>
-    <gr-button
-      id="remove"
-      link=""
-      hidden$="[[!removable]]"
-      hidden=""
-      class$="remove [[_getBackgroundClass(transparentBackground)]]"
-      on-click="_handleRemoveTap"
-    >
-      <iron-icon icon="gr-icons:close"></iron-icon>
-    </gr-button>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
similarity index 66%
rename from polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
rename to polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
index dd2b98a..1d98a0b 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.ts
@@ -15,24 +15,27 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-linked-chip.js';
+import '../../../test/common-test-setup-karma';
+import './gr-linked-chip';
+import {GrLinkedChip} from './gr-linked-chip';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-linked-chip');
 
 suite('gr-linked-chip tests', () => {
-  let element;
+  let element: GrLinkedChip;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
+    await flush();
   });
 
-  test('remove fired', () => {
+  test('remove fired', async () => {
     const spy = sinon.spy();
     element.addEventListener('remove', spy);
-    flush();
-    MockInteractions.tap(element.$.remove);
+    await flush();
+    MockInteractions.tap(queryAndAssert(element, '#remove'));
     assert.isTrue(spy.called);
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
index 4bdc1ab..0d44bc8 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_html.ts
@@ -17,7 +17,7 @@
 import {html} from '@polymer/polymer/lib/utils/html-tag';
 
 export const htmlTemplate = html`
-  <style include="shared-styles">
+  <style>
     :host {
       display: block;
     }
@@ -30,6 +30,9 @@
       text-decoration: none;
       pointer-events: none;
     }
+    a {
+      color: var(--link-color);
+    }
   </style>
   <span id="output"></span>
 `;
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
similarity index 68%
rename from polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
rename to polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
index a67fbc4..b2cdba1 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.ts
@@ -15,27 +15,30 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-linked-text.js';
-import {GerritNav} from '../../core/gr-navigation/gr-navigation.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-linked-text';
+import {GerritNav} from '../../core/gr-navigation/gr-navigation';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrLinkedText} from './gr-linked-text';
+import {CommentLinks} from '../../../types/common';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-linked-text>
-      <div id="output"></div>
-    </gr-linked-text>
+  <gr-linked-text>
+    <div id="output"></div>
+  </gr-linked-text>
 `);
 
 suite('gr-linked-text tests', () => {
-  let element;
+  let element: GrLinkedText;
 
-  let originalCanonicalPath;
+  let originalCanonicalPath: string | undefined;
 
   setup(() => {
     originalCanonicalPath = window.CANONICAL_PATH;
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrLinkedText;
 
-    sinon.stub(GerritNav, 'mapCommentlinks').value( x => x);
+    sinon.stub(GerritNav, 'mapCommentlinks').value((x: CommentLinks) => x);
     element.config = {
       ph: {
         match: '([Bb]ug|[Ii]ssue)\\s*#?(\\d+)',
@@ -86,7 +89,8 @@
     // Regular inline link.
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     element.content = url;
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
@@ -97,14 +101,16 @@
     // "Issue/Bug" pattern.
     element.content = 'Issue 3650';
 
-    let linkEl = element.$.output.childNodes[0];
+    let linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
     assert.equal(linkEl.textContent, 'Issue 3650');
 
     element.content = 'Bug 3650';
-    linkEl = element.$.output.childNodes[0];
+    linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.rel, 'noopener');
     assert.equal(linkEl.href, url);
@@ -115,8 +121,9 @@
     // Pattern starts with the same prefix (`http`) as the url.
     element.content = 'httpexample 3650';
 
-    assert.equal(element.$.output.childNodes.length, 1);
-    const linkEl = element.$.output.childNodes[0];
+    assert.equal(queryAndAssert(element, '#output').childNodes.length, 1);
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     const url = 'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650';
     assert.equal(linkEl.target, '_blank');
     assert.equal(linkEl.href, url);
@@ -129,8 +136,9 @@
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
 
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/q/' + changeID;
     assert.isFalse(linkEl.hasAttribute('target'));
@@ -147,8 +155,9 @@
     const prefix = 'Change-Id: ';
     element.content = prefix + changeID;
 
-    const textNode = element.$.output.childNodes[0];
-    const linkEl = element.$.output.childNodes[1];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.equal(textNode.textContent, prefix);
     const url = '/r/q/' + changeID;
     assert.isFalse(linkEl.hasAttribute('target'));
@@ -159,17 +168,23 @@
 
   test('Multiple matches', () => {
     element.content = 'Issue 3650\nIssue 3450';
-    const linkEl1 = element.$.output.childNodes[0];
-    const linkEl2 = element.$.output.childNodes[2];
+    const linkEl1 = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
+    const linkEl2 = queryAndAssert(element, '#output')
+      .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(linkEl1.target, '_blank');
-    assert.equal(linkEl1.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650');
+    assert.equal(
+      linkEl1.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3650'
+    );
     assert.equal(linkEl1.textContent, 'Issue 3650');
 
     assert.equal(linkEl2.target, '_blank');
-    assert.equal(linkEl2.href,
-        'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450');
+    assert.equal(
+      linkEl2.href,
+      'https://bugs.chromium.org/p/gerrit/issues/detail?id=3450'
+    );
     assert.equal(linkEl2.textContent, 'Issue 3450');
   });
 
@@ -186,9 +201,11 @@
 
     element.content = prefix + changeID + bug;
 
-    const textNode = element.$.output.childNodes[0];
-    const changeLinkEl = element.$.output.childNodes[1];
-    const bugLinkEl = element.$.output.childNodes[2];
+    const textNode = queryAndAssert(element, '#output').childNodes[0];
+    const changeLinkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
+    const bugLinkEl = queryAndAssert(element, '#output')
+      .childNodes[2] as HTMLAnchorElement;
 
     assert.equal(textNode.textContent, prefix);
 
@@ -203,15 +220,19 @@
 
   test('html field in link config', () => {
     element.content = 'google:do a barrel roll';
-    const linkEl = element.$.output.childNodes[0];
-    assert.equal(linkEl.getAttribute('href'),
-        'https://google.com/search?q=do a barrel roll');
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
+    assert.equal(
+      linkEl.getAttribute('href'),
+      'https://google.com/search?q=do a barrel roll'
+    );
     assert.equal(linkEl.textContent, 'do a barrel roll');
   });
 
   test('removing hash from links', () => {
     element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -220,7 +241,8 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'test foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -229,7 +251,8 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'a test foo';
-    const linkEl = element.$.output.childNodes[1];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[1] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
@@ -238,94 +261,119 @@
     window.CANONICAL_PATH = '/r';
 
     element.content = 'hash:foo';
-    const linkEl = element.$.output.childNodes[0];
+    const linkEl = queryAndAssert(element, '#output')
+      .childNodes[0] as HTMLAnchorElement;
     assert.isTrue(linkEl.href.endsWith('/r/awesomesauce'));
     assert.equal(linkEl.textContent, 'foo');
   });
 
   test('disabled config', () => {
     element.content = 'foo:baz';
-    assert.equal(element.$.output.innerHTML, 'foo:baz');
+    assert.equal(queryAndAssert(element, '#output').innerHTML, 'foo:baz');
   });
 
   test('R=email labels link correctly', () => {
     element.removeZeroWidthSpace = true;
     element.content = 'R=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'R=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
+    assert.equal(
+      queryAndAssert(element, '#output').textContent,
+      'R=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.match(/(R=<a)/g)!.length,
+      1
+    );
   });
 
   test('CC=email labels link correctly', () => {
     element.removeZeroWidthSpace = true;
     element.content = 'CC=\u200Btest@google.com';
-    assert.equal(element.$.output.textContent, 'CC=test@google.com');
-    assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+    assert.equal(
+      queryAndAssert(element, '#output').textContent,
+      'CC=test@google.com'
+    );
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.match(/(CC=<a)/g)!.length,
+      1
+    );
   });
 
   test('only {http,https,mailto} protocols are linkified', () => {
     element.content = 'xx mailto:test@google.com yy';
-    let links = element.$.output.querySelectorAll('a');
+    let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx http://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx https://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
   test('links without leading whitespace are linkified', () => {
     element.content = 'xx abcmailto:test@google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx abc');
-    let links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx abc'
+    );
+    let links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'mailto:test@google.com');
     assert.equal(links[0].innerHTML, 'mailto:test@google.com');
 
     element.content = 'xx defhttp://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx def');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx def'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'http://google.com');
     assert.equal(links[0].innerHTML, 'http://google.com');
 
     element.content = 'xx qwehttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx qwe');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx qwe'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     // Non-latin character
     element.content = 'xx абвhttps://google.com yy';
-    assert.equal(element.$.output.innerHTML.substr(0, 6), 'xx абв');
-    links = element.$.output.querySelectorAll('a');
+    assert.equal(
+      queryAndAssert(element, '#output').innerHTML.substr(0, 6),
+      'xx абв'
+    );
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 1);
     assert.equal(links[0].getAttribute('href'), 'https://google.com');
     assert.equal(links[0].innerHTML, 'https://google.com');
 
     element.content = 'xx ssh://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
 
     element.content = 'xx ftp://google.com yy';
-    links = element.$.output.querySelectorAll('a');
+    links = queryAndAssert(element, '#output').querySelectorAll('a');
     assert.equal(links.length, 0);
   });
 
@@ -341,11 +389,13 @@
       },
     };
     element.content = '- B: 123, 45';
-    const links = element.root.querySelectorAll('a');
+    const links = element.root!.querySelectorAll('a');
 
     assert.equal(links.length, 2);
-    assert.equal(element.shadowRoot
-        .querySelector('span').textContent, '- B: 123, 45');
+    assert.equal(
+      queryAndAssert<HTMLSpanElement>(element, 'span').textContent,
+      '- B: 123, 45'
+    );
 
     assert.equal(links[0].href, 'ftp://foo/123');
     assert.equal(links[0].textContent, '123');
@@ -362,4 +412,3 @@
     assert.isTrue(contentConfigStub.called);
   });
 });
-
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 f586f48..4f02897 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
@@ -35,7 +35,7 @@
 }
 
 @customElement('gr-list-view')
-class GrListView extends PolymerElement {
+export class GrListView extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -53,7 +53,7 @@
   filter?: string;
 
   @property({type: Number})
-  offset?: number;
+  offset = 0;
 
   @property({type: Boolean})
   loading?: boolean;
@@ -63,8 +63,7 @@
 
   private reloadTask?: DelayedTask;
 
-  /** @override */
-  disconnectedCallback() {
+  override disconnectedCallback() {
     this.reloadTask?.cancel();
     super.disconnectedCallback();
   }
@@ -103,8 +102,8 @@
     offset: number,
     direction: number,
     itemsPerPage: number,
-    filter: string,
-    path: string
+    filter: string | undefined,
+    path = ''
   ) {
     // Offset could be a string when passed from the router.
     offset = +(offset || 0);
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 2c48ec0f..3d6b5ed 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.ts
@@ -22,7 +22,7 @@
 import {IronOverlayBehavior} from '@polymer/iron-overlay-behavior/iron-overlay-behavior';
 import {findActiveElement} from '../../../utils/dom-util';
 import {fireEvent} from '../../../utils/event-util';
-import {getHovercardContainer} from '../gr-hovercard/gr-hovercard-behavior';
+import {getHovercardContainer} from '../../../mixins/hovercard-mixin/hovercard-mixin';
 
 const AWAIT_MAX_ITERS = 10;
 const AWAIT_STEP = 5;
@@ -34,11 +34,17 @@
   }
 }
 
-@customElement('gr-overlay')
-export class GrOverlay extends IronOverlayMixin(
+// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
+const base = IronOverlayMixin(
   PolymerElement,
   IronOverlayBehavior as IronOverlayBehavior
-) {
+);
+
+/**
+ * @attr {Boolean} with-backdrop - inherited from IronOverlay
+ */
+@customElement('gr-overlay')
+export class GrOverlay extends base {
   static get template() {
     return htmlTemplate;
   }
@@ -63,7 +69,7 @@
 
   private returnFocusTo?: HTMLElement;
 
-  get _focusableNodes() {
+  override get _focusableNodes() {
     if (this.focusableNodes) {
       return this.focusableNodes;
     }
@@ -89,7 +95,7 @@
     );
   }
 
-  open() {
+  override open() {
     this.returnFocusTo = findActiveElement(document, true) ?? undefined;
     window.addEventListener('popstate', this._boundHandleClose);
     return new Promise<void>((resolve, reject) => {
@@ -119,7 +125,7 @@
     }
   }
 
-  _onCaptureFocus(e: Event) {
+  override _onCaptureFocus(e: Event) {
     const hovercardContainer = getHovercardContainer();
     if (hovercardContainer) {
       // Hovercard container is not a child of an overlay.
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
index 9a9dc03..423a1a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.ts
@@ -31,6 +31,12 @@
   };
 }
 
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-page-nav': GrPageNav;
+  }
+}
+
 @customElement('gr-page-nav')
 export class GrPageNav extends PolymerElement {
   static get template() {
@@ -47,12 +53,12 @@
     this.bodyScrollHandler = () => this._handleBodyScroll();
   }
 
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     window.addEventListener('scroll', this.bodyScrollHandler);
   }
 
-  disconnectedCallback() {
+  override disconnectedCallback() {
     window.removeEventListener('scroll', this.bodyScrollHandler);
     super.disconnectedCallback();
   }
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 31566a8..bc60520 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
@@ -54,13 +54,13 @@
   branch?: BranchName;
 
   @property({type: Boolean})
-  _branchDisabled?: boolean;
+  _branchDisabled = false;
 
   @property({type: Object})
-  _query?: AutocompleteQuery;
+  _query: AutocompleteQuery = () => Promise.resolve([]);
 
   @property({type: Object})
-  _repoQuery?: AutocompleteQuery;
+  _repoQuery: AutocompleteQuery = () => Promise.resolve([]);
 
   private readonly restApiService = appContext.restApiService;
 
@@ -70,16 +70,14 @@
     this._repoQuery = input => this._getRepoSuggestions(input);
   }
 
-  /** @override */
-  connectedCallback() {
+  override connectedCallback() {
     super.connectedCallback();
     if (this.repo) {
       this.$.repoInput.setText(this.repo);
     }
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     this._branchDisabled = !this.repo;
   }
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
deleted file mode 100644
index 4f12edc..0000000
--- a/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.js
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * @license
- * 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.
- */
-
-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');
-
-suite('gr-repo-branch-picker tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  suite('_getRepoSuggestions', () => {
-    let getReposStub;
-    setup(() => {
-      getReposStub = stubRestApi('getRepos')
-          .returns(Promise.resolve([
-            {
-              id: 'plugins%2Favatars-external',
-              name: 'plugins/avatars-external',
-            }, {
-              id: 'plugins%2Favatars-gravatar',
-              name: 'plugins/avatars-gravatar',
-            }, {
-              id: 'plugins%2Favatars%2Fexternal',
-              name: 'plugins/avatars/external',
-            }, {
-              id: 'plugins%2Favatars%2Fgravatar',
-              name: 'plugins/avatars/gravatar',
-            },
-          ]));
-    });
-
-    test('converts to suggestion objects', async () => {
-      const input = 'plugins/avatars';
-      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(() => {
-      getRepoBranchesStub = stubRestApi('getRepoBranches')
-          .returns(Promise.resolve([
-            {ref: 'refs/heads/stable-2.10'},
-            {ref: 'refs/heads/stable-2.11'},
-            {ref: 'refs/heads/stable-2.12'},
-            {ref: 'refs/heads/stable-2.13'},
-            {ref: 'refs/heads/stable-2.14'},
-            {ref: 'refs/heads/stable-2.15'},
-          ]));
-    });
-
-    test('converts to suggestion objects', async () => {
-      const repo = 'gerrit';
-      const branchInput = 'stable-2.1';
-      element.repo = repo;
-      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', async () => {
-      const repo = 'gerrit';
-      const branchInput = 'refs/heads/stable-2.1';
-      element.repo = repo;
-      return element._getRepoBranchesSuggestions(branchInput)
-          .then(suggestions => {
-            assert.isTrue(getRepoBranchesStub.calledWith(
-                'stable-2.1', repo, 15));
-          });
-    });
-
-    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-repo-branch-picker/gr-repo-branch-picker_test.ts b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
new file mode 100644
index 0000000..31efa73
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-repo-branch-picker/gr-repo-branch-picker_test.ts
@@ -0,0 +1,137 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../../test/common-test-setup-karma';
+import './gr-repo-branch-picker';
+import {GrRepoBranchPicker} from './gr-repo-branch-picker';
+import {stubRestApi} from '../../../test/test-utils';
+import {GitRef, ProjectInfoWithName, RepoName} from '../../../types/common';
+
+const basicFixture = fixtureFromElement('gr-repo-branch-picker');
+
+suite('gr-repo-branch-picker tests', () => {
+  let element: GrRepoBranchPicker;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+  });
+
+  suite('_getRepoSuggestions', () => {
+    let getReposStub: sinon.SinonStub;
+    setup(() => {
+      getReposStub = stubRestApi('getRepos').returns(
+        Promise.resolve([
+          {
+            id: 'plugins%2Favatars-external',
+            name: 'plugins/avatars-external' as RepoName,
+          },
+          {
+            id: 'plugins%2Favatars-gravatar',
+            name: 'plugins/avatars-gravatar' as RepoName,
+          },
+          {
+            id: 'plugins%2Favatars%2Fexternal',
+            name: 'plugins/avatars/external',
+          },
+          {
+            id: 'plugins%2Favatars%2Fgravatar',
+            name: 'plugins/avatars/gravatar' as RepoName,
+          },
+        ] as ProjectInfoWithName[])
+      );
+    });
+
+    test('converts to suggestion objects', async () => {
+      const input = 'plugins/avatars';
+      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: sinon.SinonStub;
+    setup(() => {
+      getRepoBranchesStub = stubRestApi('getRepoBranches').returns(
+        Promise.resolve([
+          {ref: 'refs/heads/stable-2.10' as GitRef, revision: '123'},
+          {ref: 'refs/heads/stable-2.11' as GitRef, revision: '1234'},
+          {ref: 'refs/heads/stable-2.12' as GitRef, revision: '12345'},
+          {ref: 'refs/heads/stable-2.13' as GitRef, revision: '123456'},
+          {ref: 'refs/heads/stable-2.14' as GitRef, revision: '1234567'},
+          {ref: 'refs/heads/stable-2.15' as GitRef, revision: '12345678'},
+        ])
+      );
+    });
+
+    test('converts to suggestion objects', async () => {
+      const repo = 'gerrit';
+      const branchInput = 'stable-2.1';
+      element.repo = repo as RepoName;
+      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', async () => {
+      const repo = 'gerrit' as RepoName;
+      const branchInput = 'refs/heads/stable-2.1';
+      element.repo = repo;
+      return element._getRepoBranchesSuggestions(branchInput).then(() => {
+        assert.isTrue(getRepoBranchesStub.calledWith('stable-2.1', repo, 15));
+      });
+    });
+
+    test('does not query when repo is unset', async () => {
+      await element._getRepoBranchesSuggestions('');
+      assert.isFalse(getRepoBranchesStub.called);
+      element.repo = 'gerrit' as RepoName;
+      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 5abc258..dd2c8d3 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
@@ -156,6 +156,7 @@
 import {firePageError, fireServerError} from '../../../utils/event-util';
 import {ParsedChangeInfo} from '../../../types/types';
 import {ErrorCallback} from '../../../api/rest';
+import {FlagsService, KnownExperimentId} from '../../../services/flags/flags';
 
 const MAX_PROJECT_RESULTS = 25;
 // This value is somewhat arbitrary and not based on research or calculations.
@@ -278,7 +279,8 @@
 @customElement('gr-rest-api-interface')
 export class GrRestApiInterface
   extends PolymerElement
-  implements RestApiService {
+  implements RestApiService
+{
   readonly _cache = siteBasedCache; // Shared across instances.
 
   readonly _sharedFetchPromises = fetchPromisesCache; // Shared across instances.
@@ -292,14 +294,17 @@
   // The value is set in created, before any other actions
   private authService: AuthService;
 
+  private flagService: FlagsService;
+
   // The value is set in created, before any other actions
   private readonly _restApiHelper: GrRestApiHelper;
 
-  constructor(authService?: AuthService) {
+  constructor(authService?: AuthService, flagService?: FlagsService) {
     super();
     // 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.flagService = flagService ?? appContext.flagsService;
     this._restApiHelper = new GrRestApiHelper(
       this._cache,
       this.authService,
@@ -509,10 +514,10 @@
 
   getGroupMembers(groupName: GroupId | GroupName): Promise<AccountInfo[]> {
     const encodeName = encodeURIComponent(groupName);
-    return (this._restApiHelper.fetchJSON({
+    return this._restApiHelper.fetchJSON({
       url: `/groups/${encodeName}/members/`,
       anonymizedUrl: '/groups/*/members',
-    }) as unknown) as Promise<AccountInfo[]>;
+    }) as unknown as Promise<AccountInfo[]>;
   }
 
   getIncludedGroup(
@@ -590,12 +595,12 @@
   ): Promise<AccountInfo> {
     const encodeName = encodeURIComponent(groupName);
     const encodeMember = encodeURIComponent(`${groupMember}`);
-    return (this._restApiHelper.send({
+    return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: `/groups/${encodeName}/members/${encodeMember}`,
       parseResponse: true,
       anonymizedUrl: '/groups/*/members/*',
-    }) as unknown) as Promise<AccountInfo>;
+    }) as unknown as Promise<AccountInfo>;
   }
 
   saveIncludedGroup(
@@ -613,9 +618,9 @@
     };
     return this._restApiHelper.send(req).then(response => {
       if (response?.ok) {
-        return (this.getResponseObject(
+        return this.getResponseObject(
           response
-        ) as unknown) as Promise<GroupInfo>;
+        ) as unknown as Promise<GroupInfo>;
       }
       return undefined;
     });
@@ -678,19 +683,27 @@
     });
   }
 
-  savePreferences(prefs: PreferencesInput): Promise<Response> {
+  savePreferences(
+    prefs: PreferencesInput
+  ): Promise<PreferencesInfo | undefined> {
     // Note (Issue 5142): normalize the download scheme with lower case before
     // saving.
     if (prefs.download_scheme) {
       prefs.download_scheme = prefs.download_scheme.toLowerCase();
     }
 
-    return this._restApiHelper.send({
-      method: HttpMethod.PUT,
-      url: '/accounts/self/preferences',
-      body: prefs,
-      reportUrlAsIs: true,
-    });
+    return this._restApiHelper
+      .send({
+        method: HttpMethod.PUT,
+        url: '/accounts/self/preferences',
+        body: prefs,
+        reportUrlAsIs: true,
+      })
+      .then((response: Response) =>
+        this.getResponseObject(response).then(
+          obj => obj as unknown as PreferencesInfo
+        )
+      );
   }
 
   saveDiffPreferences(prefs: DiffPreferenceInput): Promise<Response> {
@@ -833,7 +846,7 @@
     return this._restApiHelper
       .send(req)
       .then(newName =>
-        this._updateCachedAccount({name: (newName as unknown) as string})
+        this._updateCachedAccount({name: newName as unknown as string})
       );
   }
 
@@ -849,7 +862,7 @@
     return this._restApiHelper
       .send(req)
       .then(newName =>
-        this._updateCachedAccount({username: (newName as unknown) as string})
+        this._updateCachedAccount({username: newName as unknown as string})
       );
   }
 
@@ -864,7 +877,7 @@
     };
     return this._restApiHelper.send(req).then(newName =>
       this._updateCachedAccount({
-        display_name: (newName as unknown) as string,
+        display_name: newName as unknown as string,
       })
     );
   }
@@ -881,7 +894,7 @@
     return this._restApiHelper
       .send(req)
       .then(newStatus =>
-        this._updateCachedAccount({status: (newStatus as unknown) as string})
+        this._updateCachedAccount({status: newStatus as unknown as string})
       );
   }
 
@@ -964,7 +977,7 @@
           if (!res) {
             return res;
           }
-          const prefInfo = (res as unknown) as PreferencesInfo;
+          const prefInfo = res as unknown as PreferencesInfo;
           if (this._isNarrowScreen()) {
             // Note that this can be problematic, because the diff will stay
             // unified even after increasing the window width.
@@ -980,22 +993,22 @@
   }
 
   getWatchedProjects() {
-    return (this._fetchSharedCacheURL({
+    return this._fetchSharedCacheURL({
       url: '/accounts/self/watched.projects',
       reportUrlAsIs: true,
-    }) as unknown) as Promise<ProjectWatchInfo[] | undefined>;
+    }) as unknown as Promise<ProjectWatchInfo[] | undefined>;
   }
 
   saveWatchedProjects(
     projects: ProjectWatchInfo[]
   ): Promise<ProjectWatchInfo[]> {
-    return (this._restApiHelper.send({
+    return this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/accounts/self/watched.projects',
       body: projects,
       parseResponse: true,
       reportUrlAsIs: true,
-    }) as unknown) as Promise<ProjectWatchInfo[]>;
+    }) as unknown as Promise<ProjectWatchInfo[]>;
   }
 
   deleteWatchedProjects(projects: ProjectWatchInfo[]): Promise<Response> {
@@ -1037,63 +1050,58 @@
     offset?: 'n,z' | number,
     options?: string
   ): Promise<ChangeInfo[] | ChangeInfo[][] | undefined> {
-    return this.getConfig(false)
-      .then(config => {
-        // TODO(TS): config can be null/undefined. Need some checks
-        options = options || this._getChangesOptionsHex(config);
-        // Issue 4524: respect legacy token with max sortkey.
-        if (offset === 'n,z') {
-          offset = 0;
+    options = options || this._getChangesOptionsHex();
+    // Issue 4524: respect legacy token with max sortkey.
+    if (offset === 'n,z') {
+      offset = 0;
+    }
+    const params: QueryChangesParams = {
+      O: options,
+      S: offset || 0,
+    };
+    if (changesPerPage) {
+      params.n = changesPerPage;
+    }
+    if (query && query.length > 0) {
+      params.q = query;
+    }
+    const request = {
+      url: '/changes/',
+      params,
+      reportUrlAsIs: true,
+    };
+
+    return Promise.resolve(
+      this._restApiHelper.fetchJSON(request, true) as Promise<
+        ChangeInfo[] | ChangeInfo[][] | undefined
+      >
+    ).then(response => {
+      if (!response) {
+        return;
+      }
+      const iterateOverChanges = (arr: ChangeInfo[]) => {
+        for (const change of arr) {
+          this._maybeInsertInLookup(change);
         }
-        const params: QueryChangesParams = {
-          O: options,
-          S: offset || 0,
-        };
-        if (changesPerPage) {
-          params.n = changesPerPage;
+      };
+      // Response may be an array of changes OR an array of arrays of
+      // changes.
+      if (query instanceof Array) {
+        // Normalize the response to look like a multi-query response
+        // when there is only one query.
+        const responseArray: Array<ChangeInfo[]> =
+          query.length === 1
+            ? [response as ChangeInfo[]]
+            : (response as ChangeInfo[][]);
+        for (const arr of responseArray) {
+          iterateOverChanges(arr);
         }
-        if (query && query.length > 0) {
-          params.q = query;
-        }
-        return {
-          url: '/changes/',
-          params,
-          reportUrlAsIs: true,
-        };
-      })
-      .then(
-        req =>
-          this._restApiHelper.fetchJSON(req, true) as Promise<
-            ChangeInfo[] | ChangeInfo[][] | undefined
-          >
-      )
-      .then(response => {
-        if (!response) {
-          return;
-        }
-        const iterateOverChanges = (arr: ChangeInfo[]) => {
-          for (const change of arr) {
-            this._maybeInsertInLookup(change);
-          }
-        };
-        // Response may be an array of changes OR an array of arrays of
-        // changes.
-        if (query instanceof Array) {
-          // Normalize the response to look like a multi-query response
-          // when there is only one query.
-          const responseArray: Array<ChangeInfo[]> =
-            query.length === 1
-              ? [response as ChangeInfo[]]
-              : (response as ChangeInfo[][]);
-          for (const arr of responseArray) {
-            iterateOverChanges(arr);
-          }
-          return responseArray;
-        } else {
-          iterateOverChanges(response as ChangeInfo[]);
-          return response as ChangeInfo[];
-        }
-      });
+        return responseArray;
+      } else {
+        iterateOverChanges(response as ChangeInfo[]);
+        return response as ChangeInfo[];
+      }
+    });
   }
 
   /**
@@ -1135,7 +1143,7 @@
     });
   }
 
-  _getChangesOptionsHex(config?: ServerInfo) {
+  _getChangesOptionsHex() {
     if (
       window.DEFAULT_DETAIL_HEXES &&
       window.DEFAULT_DETAIL_HEXES.dashboardPage
@@ -1146,9 +1154,6 @@
       ListChangesOption.LABELS,
       ListChangesOption.DETAILED_ACCOUNTS,
     ];
-    if (!config?.change?.enable_attention_set) {
-      options.push(ListChangesOption.REVIEWED);
-    }
 
     return listChangesOptionsToHex(...options);
   }
@@ -1157,7 +1162,8 @@
     if (
       window.DEFAULT_DETAIL_HEXES &&
       window.DEFAULT_DETAIL_HEXES.changePage &&
-      (!config || !(config.receive && config.receive.enable_signed_push))
+      (!config || !(config.receive && config.receive.enable_signed_push)) &&
+      !this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)
     ) {
       return window.DEFAULT_DETAIL_HEXES.changePage;
     }
@@ -1178,6 +1184,9 @@
     if (config?.receive?.enable_signed_push) {
       options.push(ListChangesOption.PUSH_CERTIFICATES);
     }
+    if (this.flagService?.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI)) {
+      options.push(ListChangesOption.SUBMIT_REQUIREMENTS);
+    }
     return listChangesOptionsToHex(...options);
   }
 
@@ -1218,10 +1227,10 @@
         };
         return this._restApiHelper.fetchRawJSON(req).then(response => {
           if (response?.status === 304) {
-            return (parsePrefixedJSON(
+            return parsePrefixedJSON(
               // urlWithParams already cached
               this._etags.getCachedPayload(urlWithParams)!
-            ) as unknown) as ChangeInfo;
+            ) as unknown as ChangeInfo;
           }
 
           if (response && !response.ok) {
@@ -1243,11 +1252,9 @@
             }
             this._etags.collect(urlWithParams, response, payload.raw);
             // TODO(TS): Why it is always change info?
-            this._maybeInsertInLookup(
-              (payload.parsed as unknown) as ChangeInfo
-            );
+            this._maybeInsertInLookup(payload.parsed as unknown as ChangeInfo);
 
-            return (payload.parsed as unknown) as ChangeInfo;
+            return payload.parsed as unknown as ChangeInfo;
           });
         });
       }
@@ -1527,11 +1534,11 @@
       `/projects/${encodedRepo}/tags` + `?n=${n}&S=${offset}` + encodedFilter;
     // TODO(kaspern): Rename rest api from /projects/ to /repos/ once backend
     // supports it.
-    return (this._restApiHelper.fetchJSON({
+    return this._restApiHelper.fetchJSON({
       url,
       errFn,
       anonymizedUrl: '/projects/*/tags',
-    }) as unknown) as Promise<TagInfo[]>;
+    }) as unknown as Promise<TagInfo[]>;
   }
 
   getPlugins(
@@ -1582,13 +1589,13 @@
     projectName: RepoName,
     projectInfo: ProjectAccessInput
   ): Promise<ChangeInfo> {
-    return (this._restApiHelper.send({
+    return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: `/projects/${encodeURIComponent(projectName)}/access:review`,
       body: projectInfo,
       parseResponse: true,
       anonymizedUrl: '/projects/*/access:review',
-    }) as unknown) as Promise<ChangeInfo>;
+    }) as unknown as Promise<ChangeInfo>;
   }
 
   getSuggestedGroups(
@@ -1885,7 +1892,7 @@
     baseChange?: ChangeId,
     baseCommit?: string
   ) {
-    return (this._restApiHelper.send({
+    return this._restApiHelper.send({
       method: HttpMethod.POST,
       url: '/changes/',
       body: {
@@ -1900,7 +1907,7 @@
       },
       parseResponse: true,
       reportUrlAsIs: true,
-    }) as unknown) as Promise<ChangeInfo | undefined>;
+    }) as unknown as Promise<ChangeInfo | undefined>;
   }
 
   getFileContent(
@@ -1930,7 +1937,7 @@
       // X-FYI-Content-Type header of the response.
       const type = res.headers.get('X-FYI-Content-Type');
       return this.getResponseObject(res).then(content => {
-        const strContent = (content as unknown) as string | null;
+        const strContent = content as unknown as string | null;
         return {content: strContent, type, ok: true};
       });
     });
@@ -2132,22 +2139,6 @@
     });
   }
 
-  saveChangeReviewed(
-    changeNum: NumericChangeId,
-    reviewed: boolean
-  ): Promise<Response | undefined> {
-    return this.getConfig().then(config => {
-      const isAttentionSetEnabled =
-        !!config && !!config.change && config.change.enable_attention_set;
-      if (isAttentionSetEnabled) return;
-      return this._getChangeURLAndSend({
-        changeNum,
-        method: HttpMethod.PUT,
-        endpoint: reviewed ? '/reviewed' : '/unreviewed',
-      });
-    });
-  }
-
   send(
     method: HttpMethod,
     url: string,
@@ -2325,12 +2316,18 @@
         return {};
       }
       if (!basePatchNum && !patchNum && !path) {
-        return this._getDiffComments(changeNum, '/drafts');
+        return this._getDiffComments(changeNum, '/drafts', {
+          'enable-context': true,
+          'context-padding': 3,
+        });
       }
       return this._getDiffComments(
         changeNum,
         '/drafts',
-        undefined,
+        {
+          'enable-context': true,
+          'context-padding': 3,
+        },
         basePatchNum,
         patchNum,
         path
@@ -2777,28 +2774,28 @@
   }
 
   setChangeTopic(changeNum: NumericChangeId, topic?: string): Promise<string> {
-    return (this._getChangeURLAndSend({
+    return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.PUT,
       endpoint: '/topic',
       body: {topic},
       parseResponse: true,
       reportUrlAsIs: true,
-    }) as unknown) as Promise<string>;
+    }) as unknown as Promise<string>;
   }
 
   setChangeHashtag(
     changeNum: NumericChangeId,
     hashtag: HashtagsInput
   ): Promise<Hashtag[]> {
-    return (this._getChangeURLAndSend({
+    return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.POST,
       endpoint: '/hashtags',
       body: hashtag,
       parseResponse: true,
       reportUrlAsIs: true,
-    }) as unknown) as Promise<Hashtag[]>;
+    }) as unknown as Promise<Hashtag[]>;
   }
 
   deleteAccountHttpPassword() {
@@ -2810,20 +2807,20 @@
   }
 
   generateAccountHttpPassword(): Promise<Password> {
-    return (this._restApiHelper.send({
+    return this._restApiHelper.send({
       method: HttpMethod.PUT,
       url: '/accounts/self/password.http',
       body: {generate: true},
       parseResponse: true,
       reportUrlAsIs: true,
-    }) as Promise<unknown>) as Promise<Password>;
+    }) as Promise<unknown> as Promise<Password>;
   }
 
   getAccountSSHKeys() {
-    return (this._fetchSharedCacheURL({
+    return this._fetchSharedCacheURL({
       url: '/accounts/self/sshkeys',
       reportUrlAsIs: true,
-    }) as Promise<unknown>) as Promise<SshKeyInfo[] | undefined>;
+    }) as Promise<unknown> as Promise<SshKeyInfo[] | undefined>;
   }
 
   addAccountSSHKey(key: string): Promise<SshKeyInfo> {
@@ -2840,9 +2837,9 @@
         if (!response || (response.status < 200 && response.status >= 300)) {
           return Promise.reject(new Error('error'));
         }
-        return (this.getResponseObject(
+        return this.getResponseObject(
           response
-        ) as unknown) as Promise<SshKeyInfo>;
+        ) as unknown as Promise<SshKeyInfo>;
       })
       .then(obj => {
         if (!obj || !obj.valid) {
@@ -2861,10 +2858,10 @@
   }
 
   getAccountGPGKeys() {
-    return (this._restApiHelper.fetchJSON({
+    return this._restApiHelper.fetchJSON({
       url: '/accounts/self/gpgkeys',
       reportUrlAsIs: true,
-    }) as Promise<unknown>) as Promise<Record<string, GpgKeyInfo>>;
+    }) as Promise<unknown> as Promise<Record<string, GpgKeyInfo>>;
   }
 
   addAccountGPGKey(key: GpgKeysInput) {
@@ -3009,7 +3006,7 @@
     commentID: UrlEncodedCommentId,
     reason: string
   ) {
-    return (this._getChangeURLAndSend({
+    return this._getChangeURLAndSend({
       changeNum,
       method: HttpMethod.POST,
       patchNum,
@@ -3017,7 +3014,7 @@
       body: {reason},
       parseResponse: true,
       anonymizedEndpoint: '/comments/*/delete',
-    }) as unknown) as Promise<CommentInfo>;
+    }) as unknown as Promise<CommentInfo>;
   }
 
   /**
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 7cbcaef..a60a1ef 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,7 +16,7 @@
  */
 
 import '../../../test/common-test-setup-karma.js';
-import {addListenerForTest, mockPromise} from '../../../test/test-utils.js';
+import {addListenerForTest, mockPromise, stubAuth} 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';
@@ -264,7 +264,7 @@
 
   test('server error', () => {
     const getResponseObjectStub = sinon.stub(element, 'getResponseObject');
-    window.fetch.returns(Promise.resolve({ok: false}));
+    stubAuth('fetch').returns(Promise.resolve({ok: false}));
     const serverErrorEventPromise = new Promise(resolve => {
       addListenerForTest(document, 'server-error', resolve);
     });
@@ -386,7 +386,8 @@
       });
 
   test('savPreferences normalizes download scheme', () => {
-    const sendStub = sinon.stub(element._restApiHelper, 'send');
+    const sendStub = sinon.stub(element._restApiHelper, 'send').returns(
+        Promise.resolve(new Response()));
     element.savePreferences({download_scheme: 'HTTP'});
     assert.isTrue(sendStub.called);
     assert.equal(sendStub.lastCall.args[0].body.download_scheme, 'http');
@@ -832,7 +833,7 @@
   });
 
   test('gerrit auth is used', () => {
-    sinon.stub(appContext.authService, 'fetch').returns(Promise.resolve());
+    stubAuth('fetch').returns(Promise.resolve());
     element._restApiHelper.fetchJSON({url: 'foo'});
     assert(appContext.authService.fetch.called);
   });
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 89abd57..8db6606 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
@@ -86,7 +86,7 @@
       // so that we spare more round trips to the server when the app loads
       // initially.
       Object.entries(window.INITIAL_DATA).forEach(e =>
-        this._cache().set(e[0], (e[1] as unknown) as ParsedJSON)
+        this._cache().set(e[0], e[1] as unknown as ParsedJSON)
       );
     }
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
index 3a5a587..70bd369 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper_test.js
@@ -18,6 +18,7 @@
 import '../../../../test/common-test-setup-karma.js';
 import {SiteBasedCache, FetchPromisesCache, GrRestApiHelper} from './gr-rest-api-helper.js';
 import {appContext} from '../../../../services/app-context.js';
+import {stubAuth} from '../../../../test/test-utils.js';
 
 suite('gr-rest-api-helper tests', () => {
   let helper;
@@ -25,6 +26,7 @@
   let cache;
   let fetchPromisesCache;
   let originalCanonicalPath;
+  let authFetchStub;
 
   setup(() => {
     cache = new SiteBasedCache();
@@ -38,7 +40,7 @@
     };
 
     const testJSON = ')]}\'\n{"hello": "bonjour"}';
-    sinon.stub(window, 'fetch').returns(Promise.resolve({
+    authFetchStub = stubAuth('fetch').returns(Promise.resolve({
       ok: true,
       text() {
         return Promise.resolve(testJSON);
@@ -55,8 +57,6 @@
 
   suite('fetchJSON()', () => {
     test('Sets header to accept application/json', () => {
-      const authFetchStub = sinon.stub(helper._auth, 'fetch')
-          .returns(Promise.resolve());
       helper.fetchJSON({url: '/dummy/url'});
       assert.isTrue(authFetchStub.called);
       assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
@@ -64,8 +64,6 @@
     });
 
     test('Use header option accept when provided', () => {
-      const authFetchStub = sinon.stub(helper._auth, 'fetch')
-          .returns(Promise.resolve());
       const headers = new Headers();
       headers.append('Accept', '*/*');
       const fetchOptions = {headers};
@@ -142,7 +140,7 @@
 
   test('request callbacks can be canceled', () => {
     let cancelCalled = false;
-    window.fetch.returns(Promise.resolve({
+    authFetchStub.returns(Promise.resolve({
       body: {
         cancel() { cancelCalled = true; },
       },
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.ts
index 95e06c0..a603ec6 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
@@ -180,8 +180,8 @@
     if (isParserBatchWithNonEmptyUpdates(batch)) {
       newUpdates.push(batch);
     }
-    ((this.result
-      .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[]) = newUpdates;
+    (this.result
+      .reviewer_updates as unknown as ParserBatchWithNonEmptyUpdates[]) = newUpdates;
     return newUpdates;
   }
 
@@ -227,15 +227,15 @@
    * @see https://gerrit-review.googlesource.com/c/94490/
    */
   _formatUpdates() {
-    const reviewerUpdates = (this.result
-      .reviewer_updates as unknown) as ParserBatchWithNonEmptyUpdates[];
+    const reviewerUpdates = this.result
+      .reviewer_updates as unknown as ParserBatchWithNonEmptyUpdates[];
     for (const update of reviewerUpdates) {
       const groupedReviewers = this._groupUpdatesByMessage(update.updates);
       const newUpdates: {message: string; reviewers: AccountInfo[]}[] = [];
       for (const [message, reviewers] of Object.entries(groupedReviewers)) {
         newUpdates.push({message, reviewers});
       }
-      ((update as unknown) as FormattedReviewerUpdateInfo).updates = newUpdates;
+      (update as unknown as FormattedReviewerUpdateInfo).updates = newUpdates;
     }
   }
 
@@ -245,8 +245,8 @@
    * TODO(viktard): Remove when server-side serves reviewer updates like so.
    */
   _advanceUpdates() {
-    const updates = (this.result
-      .reviewer_updates as unknown) as FormattedReviewerUpdateInfo[];
+    const updates = this.result
+      .reviewer_updates as unknown as FormattedReviewerUpdateInfo[];
     const messages = this.result.messages;
     messages.forEach((message, index) => {
       const messageDate = parseDate(message.date).getTime();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
index 861381f..571272d 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.ts
@@ -34,7 +34,7 @@
   }
 
   @property({type: String, notify: true})
-  bindValue?: string;
+  bindValue?: string | number;
 
   get nativeSelect() {
     // gr-select is not a shadow component
@@ -49,14 +49,12 @@
     // It's possible to have a value of 0.
     if (this.bindValue !== undefined) {
       // Set for chrome/safari so it happens instantly
-      this.nativeSelect.value = this.bindValue;
+      this.nativeSelect.value = String(this.bindValue);
       // Async needed for firefox to populate value. It was trying to do it
       // before options from a dom-repeat were rendered previously.
       // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
       setTimeout(() => {
-        // TODO(TS): maybe should check for undefined before assigning
-        // or fallback to ''
-        this.nativeSelect.value = this.bindValue!;
+        this.nativeSelect.value = String(this.bindValue);
       }, 1);
     }
   }
@@ -65,7 +63,7 @@
     this.bindValue = this.nativeSelect.value;
   }
 
-  focus() {
+  override focus() {
     this.nativeSelect.focus();
   }
 
@@ -75,8 +73,7 @@
     this.addEventListener('dom-change', () => this._updateValue());
   }
 
-  /** @override */
-  ready() {
+  override ready() {
     super.ready();
     // If not set via the property, set bind-value to the element value.
     if (this.bindValue === undefined && this.nativeSelect.options.length > 0) {
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
similarity index 75%
rename from polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
rename to polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
index c697850..245d7df 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.ts
@@ -15,41 +15,42 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-select.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
+import '../../../test/common-test-setup-karma';
+import './gr-select';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrSelect} from './gr-select';
 
 const basicFixture = fixtureFromTemplate(html`
-<gr-select>
-      <select>
-        <option value="1">One</option>
-        <option value="2">Two</option>
-        <option value="3">Three</option>
-      </select>
-    </gr-select>
+  <gr-select>
+    <select>
+      <option value="1">One</option>
+      <option value="2">Two</option>
+      <option value="3">Three</option>
+    </select>
+  </gr-select>
 `);
 
 const noOptionsFixture = fixtureFromTemplate(html`
-<gr-select>
-      <select>
-      </select>
-    </gr-select>
+  <gr-select>
+    <select></select>
+  </gr-select>
 `);
 
 suite('gr-select tests', () => {
-  let element;
+  let element: GrSelect;
 
   setup(() => {
-    element = basicFixture.instantiate();
+    element = basicFixture.instantiate() as GrSelect;
   });
 
   test('bindValue must be set to the first option value', () => {
     assert.equal(element.bindValue, '1');
+    assert.equal(element.nativeSelect.value, '1');
   });
 
   test('value of 0 should still trigger value updates', () => {
-    element.bindValue = 0;
-    assert.equal(element.nativeSelect.value, 0);
+    element.bindValue = '0';
+    assert.equal(element.nativeSelect.value, '');
   });
 
   test('bidirectional binding property-to-attribute', () => {
@@ -82,9 +83,11 @@
     // Now change the value.
     element.nativeSelect.value = '3';
     element.dispatchEvent(
-        new CustomEvent('change', {
-          composed: true, bubbles: true,
-        }));
+      new CustomEvent('change', {
+        composed: true,
+        bubbles: true,
+      })
+    );
 
     // It should be updated.
     assert.equal(element.nativeSelect.value, '3');
@@ -93,10 +96,10 @@
   });
 
   suite('gr-select no options tests', () => {
-    let element;
+    let element: GrSelect;
 
     setup(() => {
-      element = noOptionsFixture.instantiate();
+      element = noOptionsFixture.instantiate() as GrSelect;
     });
 
     test('bindValue must not be changed', () => {
@@ -104,4 +107,3 @@
     });
   });
 });
-
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
index a23cb1a..7ed000b 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command.ts
@@ -14,11 +14,12 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import '../../../styles/shared-styles';
 import '../gr-copy-clipboard/gr-copy-clipboard';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-shell-command_html';
-import {customElement, property} from '@polymer/decorators';
+import {GrCopyClipboard} from '../gr-copy-clipboard/gr-copy-clipboard';
+import {queryAndAssert} from '../../../utils/common-util';
+import {sharedStyles} from '../../../styles/shared-styles';
+import {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -27,23 +28,73 @@
 }
 
 @customElement('gr-shell-command')
-class GrShellCommand extends PolymerElement {
-  static get template() {
-    return htmlTemplate;
-  }
-
+export class GrShellCommand extends LitElement {
   @property({type: String})
   command: string | undefined;
 
   @property({type: String})
   label: string | undefined;
 
+  @property({type: String})
+  tooltip = '';
+
+  static override get styles() {
+    return [
+      sharedStyles,
+      css`
+        .commandContainer {
+          margin-bottom: var(--spacing-m);
+        }
+        .commandContainer {
+          background-color: var(--shell-command-background-color);
+          /* Should be spacing-m larger than the :before width. */
+          padding: var(--spacing-m) var(--spacing-m) var(--spacing-m)
+            calc(3 * var(--spacing-m) + 0.5em);
+          position: relative;
+          width: 100%;
+        }
+        .commandContainer:before {
+          content: '$';
+          position: absolute;
+          display: block;
+          box-sizing: border-box;
+          background: var(--shell-command-decoration-background-color);
+          top: 0;
+          bottom: 0;
+          left: 0;
+          /* Should be spacing-m smaller than the .commandContainer padding-left. */
+          width: calc(2 * var(--spacing-m) + 0.5em);
+          /* Should vertically match the padding of .commandContainer. */
+          padding: var(--spacing-m);
+          /* Should roughly match the height of .commandContainer without padding. */
+          line-height: 26px;
+        }
+        .commandContainer gr-copy-clipboard::part(text-container-style) {
+          border: none;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    const label = this.label ?? '';
+    return html` <label>${label}</label>
+      <div class="commandContainer">
+        <gr-copy-clipboard
+          .text="${this.command}"
+          hasTooltip
+          buttonTitle="${this.tooltip}"
+        ></gr-copy-clipboard>
+      </div>`;
+  }
+
   focusOnCopy() {
-    if (this.shadowRoot !== null) {
-      const copyClipboard = this.shadowRoot.querySelector('gr-copy-clipboard');
-      if (copyClipboard !== null) {
-        copyClipboard.focusOnCopy();
-      }
+    const copyClipboard = queryAndAssert<GrCopyClipboard>(
+      this,
+      'gr-copy-clipboard'
+    );
+    if (copyClipboard) {
+      copyClipboard.focusOnCopy();
     }
   }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
deleted file mode 100644
index ef76999..0000000
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_html.ts
+++ /dev/null
@@ -1,58 +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">
-    .commandContainer {
-      margin-bottom: var(--spacing-m);
-    }
-    .commandContainer {
-      background-color: var(--shell-command-background-color);
-      /* Should be spacing-m larger than the :before width. */
-      padding: var(--spacing-m) var(--spacing-m) var(--spacing-m)
-        calc(3 * var(--spacing-m) + 0.5em);
-      position: relative;
-      width: 100%;
-    }
-    .commandContainer:before {
-      content: '$';
-      position: absolute;
-      display: block;
-      box-sizing: border-box;
-      background: var(--shell-command-decoration-background-color);
-      top: 0;
-      bottom: 0;
-      left: 0;
-      /* Should be spacing-m smaller than the .commandContainer padding-left. */
-      width: calc(2 * var(--spacing-m) + 0.5em);
-      /* Should vertically match the padding of .commandContainer. */
-      padding: var(--spacing-m);
-      /* Should roughly match the height of .commandContainer without padding. */
-      line-height: 26px;
-    }
-    .commandContainer gr-copy-clipboard {
-      --text-container-style: {
-        border: none;
-      }
-    }
-  </style>
-  <label>[[label]]</label>
-  <div class="commandContainer">
-    <gr-copy-clipboard text="[[command]]"></gr-copy-clipboard>
-  </div>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
similarity index 63%
rename from polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
rename to polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
index de9f243..a50b60b 100644
--- a/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-shell-command/gr-shell-command_test.ts
@@ -15,27 +15,30 @@
  * limitations under the License.
  */
 
-import '../../../test/common-test-setup-karma.js';
-import './gr-shell-command.js';
+import '../../../test/common-test-setup-karma';
+import './gr-shell-command';
+import {GrShellCommand} from './gr-shell-command';
+import {GrCopyClipboard} from '../gr-copy-clipboard/gr-copy-clipboard';
+import {queryAndAssert} from '../../../test/test-utils';
 
 const basicFixture = fixtureFromElement('gr-shell-command');
 
 suite('gr-shell-command tests', () => {
-  let element;
+  let element: GrShellCommand;
 
-  setup(() => {
+  setup(async () => {
     element = basicFixture.instantiate();
-    element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+    element.command = `git fetch http://gerrit@localhost:8080/a/test-project
         refs/changes/05/5/1 && git checkout FETCH_HEAD`;
-    flush();
+    await flush();
   });
 
   test('focusOnCopy', () => {
-    const focusStub = sinon.stub(element.shadowRoot
-        .querySelector('gr-copy-clipboard'),
-    'focusOnCopy');
+    const focusStub = sinon.stub(
+      queryAndAssert<GrCopyClipboard>(element, 'gr-copy-clipboard')!,
+      'focusOnCopy'
+    );
     element.focusOnCopy();
     assert.isTrue(focusStub.called);
   });
 });
-
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 bf10121..9e6b42a 100644
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.ts
@@ -17,18 +17,21 @@
 import '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
 import '../gr-cursor-manager/gr-cursor-manager';
 import '../gr-overlay/gr-overlay';
-import '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
 import '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
 import '../../../styles/shared-styles';
 import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {PolymerElement} from '@polymer/polymer/polymer-element';
 import {htmlTemplate} from './gr-textarea_html';
-import {KeyboardShortcutMixin} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
 import {appContext} from '../../../services/app-context';
 import {customElement, property} from '@polymer/decorators';
 import {ReportingService} from '../../../services/gr-reporting/gr-reporting';
 import {IronAutogrowTextareaElement} from '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
-import {GrAutocompleteDropdown} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {
+  GrAutocompleteDropdown,
+  Item,
+  ItemSelectedEvent,
+} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+import {addShortcut, Key} from '../../../utils/dom-util';
 
 const MAX_ITEMS_DROPDOWN = 10;
 
@@ -56,11 +59,8 @@
   {value: '😜', match: 'winking tongue ;)'},
 ];
 
-interface EmojiSuggestion {
-  value: string;
+interface EmojiSuggestion extends Item {
   match: string;
-  dataValue?: string;
-  text?: string;
 }
 
 interface ValueChangeEvent {
@@ -76,8 +76,15 @@
   };
 }
 
+declare global {
+  interface HTMLElementEventMap {
+    'item-selected': CustomEvent<ItemSelectedEvent>;
+    'bind-value-changed': CustomEvent<ValueChangeEvent>;
+  }
+}
+
 @customElement('gr-textarea')
-export class GrTextarea extends KeyboardShortcutMixin(PolymerElement) {
+export class GrTextarea extends PolymerElement {
   static get template() {
     return htmlTemplate;
   }
@@ -85,8 +92,8 @@
   /**
    * @event bind-value-changed
    */
-  @property({type: Boolean})
-  autocomplete?: boolean;
+  @property({type: String})
+  autocomplete?: string;
 
   @property({type: Boolean})
   disabled?: boolean;
@@ -101,7 +108,7 @@
   placeholder?: string;
 
   @property({type: String, notify: true, observer: '_handleTextChanged'})
-  text?: string;
+  text = '';
 
   @property({type: Boolean})
   hideBorder = false;
@@ -122,13 +129,13 @@
   _currentSearchString?: string;
 
   @property({type: Boolean})
-  _hideAutocomplete = true;
+  _hideEmojiAutocomplete = true;
 
   @property({type: Number})
-  _index?: number;
+  _index: number | null = null;
 
   @property({type: Array})
-  _suggestions?: EmojiSuggestion[];
+  _suggestions: EmojiSuggestion[] = [];
 
   @property({type: Number})
   readonly _verticalOffset = 20;
@@ -138,23 +145,40 @@
 
   disableEnterKeyForSelectingEmoji = false;
 
-  get keyBindings() {
-    return {
-      esc: '_handleEscKey',
-      tab: '_handleEnterByKey',
-      enter: '_handleEnterByKey',
-      up: '_handleUpKey',
-      down: '_handleDownKey',
-    };
-  }
+  /** Called in disconnectedCallback. */
+  private cleanups: (() => void)[] = [];
 
   constructor() {
     super();
     this.reporting = appContext.reportingService;
   }
 
-  /** @override */
-  ready() {
+  override disconnectedCallback() {
+    super.disconnectedCallback();
+    for (const cleanup of this.cleanups) cleanup();
+    this.cleanups = [];
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    this.cleanups.push(
+      addShortcut(this, {key: Key.UP}, e => this._handleUpKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.DOWN}, e => this._handleDownKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.TAB}, e => this._handleTabKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ENTER}, e => this._handleEnterByKey(e))
+    );
+    this.cleanups.push(
+      addShortcut(this, {key: Key.ESC}, e => this._handleEscKey(e))
+    );
+  }
+
+  override ready() {
     super.ready();
     if (this.monospace) {
       this.classList.add('monospace');
@@ -186,7 +210,7 @@
   }
 
   _handleEscKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete) {
+    if (this._hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
@@ -195,7 +219,7 @@
   }
 
   _handleUpKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete) {
+    if (this._hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
@@ -206,7 +230,7 @@
   }
 
   _handleDownKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete) {
+    if (this._hideEmojiAutocomplete) {
       return;
     }
     e.preventDefault();
@@ -216,8 +240,10 @@
     this.disableEnterKeyForSelectingEmoji = false;
   }
 
-  _handleEnterByKey(e: KeyboardEvent) {
-    if (this._hideAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+  _handleTabKey(e: KeyboardEvent) {
+    // Tab should have normal behavior if the picker is closed or if the user
+    // has only typed ':'.
+    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
       return;
     }
     e.preventDefault();
@@ -225,8 +251,23 @@
     this._setEmoji(this.$.emojiSuggestions.getCurrentText());
   }
 
-  _handleEmojiSelect(e: CustomEvent) {
-    this._setEmoji(e.detail.selected.dataset['value']);
+  _handleEnterByKey(e: KeyboardEvent) {
+    // Enter should have newline behavior if the picker is closed or if the user
+    // has only typed ':'. Also make sure that shortcuts aren't clobbered.
+    if (this._hideEmojiAutocomplete || this.disableEnterKeyForSelectingEmoji) {
+      this.indent(e);
+      return;
+    }
+
+    e.preventDefault();
+    e.stopPropagation();
+    this._setEmoji(this.$.emojiSuggestions.getCurrentText());
+  }
+
+  _handleEmojiSelect(e: CustomEvent<ItemSelectedEvent>) {
+    if (e.detail.selected?.dataset['value']) {
+      this._setEmoji(e.detail.selected?.dataset['value']);
+    }
   }
 
   _setEmoji(text: string) {
@@ -257,7 +298,7 @@
    * this allows the dropdown to appear near where the user is typing.
    */
   _updateCaratPosition() {
-    this._hideAutocomplete = false;
+    this._hideEmojiAutocomplete = false;
     if (typeof this.$.textarea.value === 'string') {
       this.$.hiddenText.textContent = this.$.textarea.value.substr(
         0,
@@ -271,15 +312,6 @@
     this._openEmojiDropdown();
   }
 
-  _getFontSize() {
-    const fontSizePx = getComputedStyle(this).fontSize || '12px';
-    return Number(fontSizePx.substr(0, fontSizePx.length - 2));
-  }
-
-  _getScrollTop() {
-    return document.body.scrollTop;
-  }
-
   /**
    * _handleKeydown used for key handling in the this.$.textarea AND all child
    * autocomplete options.
@@ -361,7 +393,7 @@
     const suggestions = [];
     for (const suggestion of matchedSuggestions) {
       suggestion.dataValue = suggestion.value;
-      suggestion.text = suggestion.value + ' ' + suggestion.match;
+      suggestion.text = `${suggestion.value} ${suggestion.match}`;
       suggestions.push(suggestion);
     }
     this.set('_suggestions', suggestions);
@@ -384,7 +416,7 @@
     // hide and reset the autocomplete dropdown.
     flush();
     this._currentSearchString = '';
-    this._hideAutocomplete = true;
+    this._hideEmojiAutocomplete = true;
     this.closeDropdown();
     this._colonIndex = null;
     this.$.textarea.textarea.focus();
@@ -395,6 +427,34 @@
       new CustomEvent('value-changed', {detail: {value: text}})
     );
   }
+
+  private indent(e: KeyboardEvent): void {
+    if (!document.queryCommandSupported('insertText')) {
+      return;
+    }
+    // When nothing is selected, selectionStart is the caret position. We want
+    // the indentation level of the current line, not the end of the text which
+    // may be different.
+    const currentLine = this.$.textarea.textarea.value
+      .substr(0, this.$.textarea.selectionStart)
+      .split('\n')
+      .pop();
+    const currentLineIndentation = currentLine?.match(/^\s*/)?.[0];
+    if (!currentLineIndentation) {
+      return;
+    }
+
+    // Stops the normal newline being added afterwards since we are adding it
+    // ourselves.
+    e.preventDefault();
+
+    // MDN says that execCommand is deprecated, but the replacements are still
+    // WIP (Input Events Level 2). The queryCommandSupported check should ensure
+    // that entering newlines will work even if this indent feature breaks.
+    // Directly replacing the text is possible, but would destroy the undo/redo
+    // queue.
+    document.execCommand('insertText', false, '\n' + currentLineIndentation);
+  }
 }
 
 declare global {
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
deleted file mode 100644
index 5b3a2b2..0000000
--- a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.js
+++ /dev/null
@@ -1,343 +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 './gr-textarea.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromElement('gr-textarea');
-
-const monospaceFixture = fixtureFromTemplate(html`
-<gr-textarea monospace="true"></gr-textarea>
-`);
-
-const hideBorderFixture = fixtureFromTemplate(html`
-<gr-textarea hide-border="true"></gr-textarea>
-`);
-
-suite('gr-textarea tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    sinon.stub(element.reporting, 'reportInteraction');
-  });
-
-  test('monospace is set properly', () => {
-    assert.isFalse(element.classList.contains('monospace'));
-  });
-
-  test('hideBorder is set properly', () => {
-    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
-  });
-
-  test('emoji selector is not open with the textarea lacks focus', () => {
-    element.$.textarea.selectionStart = 1;
-    element.$.textarea.selectionEnd = 1;
-    element.text = ':';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
-  });
-
-  test('emoji selector is not open when a general text is entered', () => {
-    MockInteractions.focus(element.$.textarea);
-    element.$.textarea.selectionStart = 9;
-    element.$.textarea.selectionEnd = 9;
-    element.text = 'some text';
-    assert.isFalse(!element.$.emojiSuggestions.isHidden);
-  });
-
-  test('emoji selector opens when a colon is typed & the textarea has focus',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-
-  test('emoji selector opens when a colon is typed after space',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 2;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ' :';
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 1);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-
-  test('emoji selector doesn\`t open when a colon is typed after character',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 5;
-        element.$.textarea.selectionEnd = 5;
-        element.text = 'test:';
-        flush();
-        assert.isTrue(element.$.emojiSuggestions.isHidden);
-        assert.isTrue(element._hideAutocomplete);
-      });
-
-  test('emoji selector opens when a colon is typed and some substring',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        element.text = ':';
-        element.$.textarea.selectionStart = 2;
-        element.$.textarea.selectionEnd = 2;
-        element.text = ':t';
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, 't');
-      });
-
-  test('emoji selector opens when a colon is typed in middle of text',
-      () => {
-        MockInteractions.focus(element.$.textarea);
-        // Needed for Safari tests. selectionStart is not updated when text is
-        // updated.
-        element.$.textarea.selectionStart = 1;
-        element.$.textarea.selectionEnd = 1;
-        // Since selectionStart is on Chrome set always on end of text, we
-        // stub it to 1
-        const text = ': hello';
-        sinon.stub(element.$, 'textarea').value( {
-          selectionStart: 1,
-          value: text,
-          textarea: {
-            focus: () => {},
-          },
-        });
-        element.text = text;
-        flush();
-        assert.isFalse(element.$.emojiSuggestions.isHidden);
-        assert.equal(element._colonIndex, 0);
-        assert.isFalse(element._hideAutocomplete);
-        assert.equal(element._currentSearchString, '');
-      });
-  test('emoji selector closes when text changes before the colon', () => {
-    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
-    MockInteractions.focus(element.$.textarea);
-    flush();
-    element.$.textarea.selectionStart = 10;
-    element.$.textarea.selectionEnd = 10;
-    element.text = 'test test ';
-    element.$.textarea.selectionStart = 12;
-    element.$.textarea.selectionEnd = 12;
-    element.text = 'test test :';
-    element.$.textarea.selectionStart = 15;
-    element.$.textarea.selectionEnd = 15;
-    element.text = 'test test :smi';
-
-    assert.equal(element._currentSearchString, 'smi');
-    assert.isFalse(resetStub.called);
-    element.text = 'test test test :smi';
-    assert.isTrue(resetStub.called);
-  });
-
-  test('_resetEmojiDropdown', () => {
-    const closeSpy = sinon.spy(element, 'closeDropdown');
-    element._resetEmojiDropdown();
-    assert.equal(element._currentSearchString, '');
-    assert.isTrue(element._hideAutocomplete);
-    assert.equal(element._colonIndex, null);
-
-    element.$.emojiSuggestions.open();
-    flush();
-    element._resetEmojiDropdown();
-    assert.isTrue(closeSpy.called);
-  });
-
-  test('_determineSuggestions', () => {
-    const emojiText = 'tear';
-    const formatSpy = sinon.spy(element, '_formatSuggestions');
-    element._determineSuggestions(emojiText);
-    assert.isTrue(formatSpy.called);
-    assert.isTrue(formatSpy.lastCall.calledWithExactly(
-        [{dataValue: '😂', value: '😂', match: 'tears :\')',
-          text: '😂 tears :\')'},
-        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
-        ]));
-  });
-
-  test('_formatSuggestions', () => {
-    const matchedSuggestions = [{value: '😢', match: 'tear'},
-      {value: '😂', match: 'tears'}];
-    element._formatSuggestions(matchedSuggestions);
-    assert.deepEqual(
-        [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
-          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
-        element._suggestions);
-  });
-
-  test('_handleEmojiSelect', () => {
-    element.$.textarea.selectionStart = 16;
-    element.$.textarea.selectionEnd = 16;
-    element.text = 'test test :tears';
-    element._colonIndex = 10;
-    const selectedItem = {dataset: {value: '😂'}};
-    const event = {detail: {selected: selectedItem}};
-    element._handleEmojiSelect(event);
-    assert.equal(element.text, 'test test 😂');
-  });
-
-  test('_updateCaratPosition', () => {
-    element.$.textarea.selectionStart = 4;
-    element.$.textarea.selectionEnd = 4;
-    element.text = 'test';
-    element._updateCaratPosition();
-    assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
-        element.$.caratSpan.outerHTML);
-  });
-
-  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
-    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-    element.$.emojiSuggestions.dispatchEvent(
-        new CustomEvent('dropdown-closed', {
-          composed: true, bubbles: true,
-        }));
-    assert.isTrue(resetSpy.called);
-  });
-
-  test('_onValueChanged fires bind-value-changed', () => {
-    const listenerStub = sinon.stub();
-    const eventObject = {currentTarget: {focused: false}};
-    element.addEventListener('bind-value-changed', listenerStub);
-    element._onValueChanged(eventObject);
-    assert.isTrue(listenerStub.called);
-  });
-
-  suite('keyboard shortcuts', () => {
-    function setupDropdown(callback) {
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 2;
-      element.text = ':1';
-      flush();
-    }
-
-    test('escape key', () => {
-      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-      assert.isFalse(resetSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
-      assert.isTrue(resetSpy.called);
-      assert.isFalse(!element.$.emojiSuggestions.isHidden);
-    });
-
-    test('up key', () => {
-      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-      assert.isFalse(upSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
-      assert.isTrue(upSpy.called);
-    });
-
-    test('down key', () => {
-      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-      assert.isFalse(downSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
-      assert.isTrue(downSpy.called);
-    });
-
-    test('enter key', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions,
-          'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      setupDropdown();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isTrue(enterSpy.called);
-      flush();
-      assert.equal(element.text, '💯');
-    });
-
-    test('enter key - ignored on just colon without more information', () => {
-      const enterSpy = sinon.spy(element.$.emojiSuggestions,
-          'getCursorTarget');
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-      MockInteractions.focus(element.$.textarea);
-      element.$.textarea.selectionStart = 1;
-      element.$.textarea.selectionEnd = 1;
-      element.text = ':';
-      flush();
-      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
-      assert.isFalse(enterSpy.called);
-    });
-  });
-
-  suite('gr-textarea monospace', () => {
-  // gr-textarea set monospace class in the ready() method.
-  // In Polymer2, ready() is called from the fixture(...) method,
-  // If ready() is called again later, some nested elements doesn't
-  // handle it correctly. A separate test-fixture is used to set
-  // properties before ready() is called.
-
-    let element;
-
-    setup(() => {
-      element = monospaceFixture.instantiate();
-    });
-
-    test('monospace is set properly', () => {
-      assert.isTrue(element.classList.contains('monospace'));
-    });
-  });
-
-  suite('gr-textarea hideBorder', () => {
-  // gr-textarea set noBorder class in the ready() method.
-  // In Polymer2, ready() is called from the fixture(...) method,
-  // If ready() is called again later, some nested elements doesn't
-  // handle it correctly. A separate test-fixture is used to set
-  // properties before ready() is called.
-
-    let element;
-
-    setup(() => {
-      element = hideBorderFixture.instantiate();
-    });
-
-    test('hideBorder is set properly', () => {
-      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
new file mode 100644
index 0000000..318c720
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.ts
@@ -0,0 +1,407 @@
+/**
+ * @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';
+import './gr-textarea';
+import {GrTextarea} from './gr-textarea';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {ItemSelectedEvent} from '../gr-autocomplete-dropdown/gr-autocomplete-dropdown';
+
+const basicFixture = fixtureFromElement('gr-textarea');
+
+const monospaceFixture = fixtureFromTemplate(html`
+  <gr-textarea monospace="true"></gr-textarea>
+`);
+
+const hideBorderFixture = fixtureFromTemplate(html`
+  <gr-textarea hide-border="true"></gr-textarea>
+`);
+
+suite('gr-textarea tests', () => {
+  let element: GrTextarea;
+
+  setup(() => {
+    element = basicFixture.instantiate();
+    sinon.stub(element.reporting, 'reportInteraction');
+  });
+
+  test('monospace is set properly', () => {
+    assert.isFalse(element.classList.contains('monospace'));
+  });
+
+  test('hideBorder is set properly', () => {
+    assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+  });
+
+  test('emoji selector is not open with the textarea lacks focus', () => {
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector is not open when a general text is entered', () => {
+    MockInteractions.focus(element.$.textarea);
+    element.$.textarea.selectionStart = 9;
+    element.$.textarea.selectionEnd = 9;
+    element.text = 'some text';
+    assert.isFalse(!element.$.emojiSuggestions.isHidden);
+  });
+
+  test('emoji selector opens when a colon is typed & the textarea has focus', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 0);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, '');
+  });
+
+  test('emoji selector opens when a colon is typed after space', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 2;
+    element.$.textarea.selectionEnd = 2;
+    element.text = ' :';
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 1);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, '');
+  });
+
+  test('emoji selector doesn`t open when a colon is typed after character', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 5;
+    element.$.textarea.selectionEnd = 5;
+    element.text = 'test:';
+    flush();
+    assert.isTrue(element.$.emojiSuggestions.isHidden);
+    assert.isTrue(element._hideEmojiAutocomplete);
+  });
+
+  test('emoji selector opens when a colon is typed and some substring', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    element.text = ':';
+    element.$.textarea.selectionStart = 2;
+    element.$.textarea.selectionEnd = 2;
+    element.text = ':t';
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 0);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, 't');
+  });
+
+  test('emoji selector opens when a colon is typed in middle of text', () => {
+    MockInteractions.focus(element.$.textarea);
+    // Needed for Safari tests. selectionStart is not updated when text is
+    // updated.
+    element.$.textarea.selectionStart = 1;
+    element.$.textarea.selectionEnd = 1;
+    // Since selectionStart is on Chrome set always on end of text, we
+    // stub it to 1
+    const text = ': hello';
+    sinon.stub(element.$, 'textarea').value({
+      selectionStart: 1,
+      value: text,
+      textarea: {
+        focus: () => {},
+      },
+    });
+    element.text = text;
+    flush();
+    assert.isFalse(element.$.emojiSuggestions.isHidden);
+    assert.equal(element._colonIndex, 0);
+    assert.isFalse(element._hideEmojiAutocomplete);
+    assert.equal(element._currentSearchString, '');
+  });
+  test('emoji selector closes when text changes before the colon', () => {
+    const resetStub = sinon.stub(element, '_resetEmojiDropdown');
+    MockInteractions.focus(element.$.textarea);
+    flush();
+    element.$.textarea.selectionStart = 10;
+    element.$.textarea.selectionEnd = 10;
+    element.text = 'test test ';
+    element.$.textarea.selectionStart = 12;
+    element.$.textarea.selectionEnd = 12;
+    element.text = 'test test :';
+    element.$.textarea.selectionStart = 15;
+    element.$.textarea.selectionEnd = 15;
+    element.text = 'test test :smi';
+
+    assert.equal(element._currentSearchString, 'smi');
+    assert.isFalse(resetStub.called);
+    element.text = 'test test test :smi';
+    assert.isTrue(resetStub.called);
+  });
+
+  test('_resetEmojiDropdown', () => {
+    const closeSpy = sinon.spy(element, 'closeDropdown');
+    element._resetEmojiDropdown();
+    assert.equal(element._currentSearchString, '');
+    assert.isTrue(element._hideEmojiAutocomplete);
+    assert.equal(element._colonIndex, null);
+
+    element.$.emojiSuggestions.open();
+    flush();
+    element._resetEmojiDropdown();
+    assert.isTrue(closeSpy.called);
+  });
+
+  test('_determineSuggestions', () => {
+    const emojiText = 'tear';
+    const formatSpy = sinon.spy(element, '_formatSuggestions');
+    element._determineSuggestions(emojiText);
+    assert.isTrue(formatSpy.called);
+    assert.isTrue(
+      formatSpy.lastCall.calledWithExactly([
+        {
+          dataValue: '😂',
+          value: '😂',
+          match: "tears :')",
+          text: "😂 tears :')",
+        },
+        {dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+      ])
+    );
+  });
+
+  test('_formatSuggestions', () => {
+    const matchedSuggestions = [
+      {value: '😢', match: 'tear'},
+      {value: '😂', match: 'tears'},
+    ];
+    element._formatSuggestions(matchedSuggestions);
+    assert.deepEqual(
+      [
+        {value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+        {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'},
+      ],
+      element._suggestions
+    );
+  });
+
+  test('_handleEmojiSelect', () => {
+    element.$.textarea.selectionStart = 16;
+    element.$.textarea.selectionEnd = 16;
+    element.text = 'test test :tears';
+    element._colonIndex = 10;
+    const selectedItem = {dataset: {value: '😂'}} as unknown as HTMLElement;
+    const event = new CustomEvent<ItemSelectedEvent>('item-selected', {
+      detail: {trigger: 'click', selected: selectedItem},
+    });
+    element._handleEmojiSelect(event);
+    assert.equal(element.text, 'test test 😂');
+  });
+
+  test('_updateCaratPosition', () => {
+    element.$.textarea.selectionStart = 4;
+    element.$.textarea.selectionEnd = 4;
+    element.text = 'test';
+    element._updateCaratPosition();
+    assert.deepEqual(
+      element.$.hiddenText.innerHTML,
+      element.text + element.$.caratSpan.outerHTML
+    );
+  });
+
+  test('newline receives matching indentation', async () => {
+    const indentCommand = sinon.stub(document, 'execCommand');
+    element.$.textarea.value = '    a';
+    element._handleEnterByKey(
+      new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13})
+    );
+    await flush();
+    assert.deepEqual(indentCommand.args[0], ['insertText', false, '\n    ']);
+  });
+
+  test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+    const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+    element.$.emojiSuggestions.dispatchEvent(
+      new CustomEvent('dropdown-closed', {
+        composed: true,
+        bubbles: true,
+      })
+    );
+    assert.isTrue(resetSpy.called);
+  });
+
+  test('_onValueChanged fires bind-value-changed', () => {
+    const listenerStub = sinon.stub();
+    const eventObject = new CustomEvent('bind-value-changed', {
+      detail: {currentTarget: {focused: false}, value: ''},
+    });
+    element.addEventListener('bind-value-changed', listenerStub);
+    element._onValueChanged(eventObject);
+    assert.isTrue(listenerStub.called);
+  });
+
+  suite('keyboard shortcuts', () => {
+    function setupDropdown() {
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 2;
+      element.text = ':1';
+      flush();
+    }
+
+    test('escape key', () => {
+      const resetSpy = sinon.spy(element, '_resetEmojiDropdown');
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        27,
+        null,
+        'Escape'
+      );
+      assert.isFalse(resetSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        27,
+        null,
+        'Escape'
+      );
+      assert.isTrue(resetSpy.called);
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    });
+
+    test('up key', () => {
+      const upSpy = sinon.spy(element.$.emojiSuggestions, 'cursorUp');
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        38,
+        null,
+        'ArrowUp'
+      );
+      assert.isFalse(upSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        38,
+        null,
+        'ArrowUp'
+      );
+      assert.isTrue(upSpy.called);
+    });
+
+    test('down key', () => {
+      const downSpy = sinon.spy(element.$.emojiSuggestions, 'cursorDown');
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        40,
+        null,
+        'ArrowDown'
+      );
+      assert.isFalse(downSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        40,
+        null,
+        'ArrowDown'
+      );
+      assert.isTrue(downSpy.called);
+    });
+
+    test('enter key', () => {
+      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        13,
+        null,
+        'Enter'
+      );
+      assert.isFalse(enterSpy.called);
+      setupDropdown();
+      MockInteractions.pressAndReleaseKeyOn(
+        element.$.textarea,
+        13,
+        null,
+        'Enter'
+      );
+      assert.isTrue(enterSpy.called);
+      flush();
+      assert.equal(element.text, '💯');
+    });
+
+    test('enter key - ignored on just colon without more information', () => {
+      const enterSpy = sinon.spy(element.$.emojiSuggestions, 'getCursorTarget');
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      flush();
+      MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+      assert.isFalse(enterSpy.called);
+    });
+  });
+
+  suite('gr-textarea monospace', () => {
+    // gr-textarea set monospace class in the ready() method.
+    // In Polymer2, ready() is called from the fixture(...) method,
+    // If ready() is called again later, some nested elements doesn't
+    // handle it correctly. A separate test-fixture is used to set
+    // properties before ready() is called.
+
+    let element: GrTextarea;
+
+    setup(() => {
+      element = monospaceFixture.instantiate() as GrTextarea;
+    });
+
+    test('monospace is set properly', () => {
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+  });
+
+  suite('gr-textarea hideBorder', () => {
+    // gr-textarea set noBorder class in the ready() method.
+    // In Polymer2, ready() is called from the fixture(...) method,
+    // If ready() is called again later, some nested elements doesn't
+    // handle it correctly. A separate test-fixture is used to set
+    // properties before ready() is called.
+
+    let element: GrTextarea;
+
+    setup(() => {
+      element = hideBorderFixture.instantiate() as GrTextarea;
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+    });
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
index feed8a6..0585aec8 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.ts
@@ -15,10 +15,13 @@
  * limitations under the License.
  */
 import '../gr-icons/gr-icons';
-import {PolymerElement} from '@polymer/polymer/polymer-element';
-import {htmlTemplate} from './gr-tooltip-content_html';
-import {TooltipMixin} from '../../../mixins/gr-tooltip-mixin/gr-tooltip-mixin';
-import {customElement, property} from '@polymer/decorators';
+import '../gr-tooltip/gr-tooltip';
+import {getRootElement} from '../../../scripts/rootElement';
+import {GrTooltip} from '../gr-tooltip/gr-tooltip';
+import {css, html, LitElement, PropertyValues} from 'lit';
+import {customElement, property, state} from 'lit/decorators';
+
+const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
 
 declare global {
   interface HTMLElementTagNameMap {
@@ -26,18 +29,202 @@
   }
 }
 
-/**
- * Transclude anything inside and wrap them to support tooltip functionality.
- */
 @customElement('gr-tooltip-content')
-export class GrTooltipContent extends TooltipMixin(PolymerElement) {
-  static get template() {
-    return htmlTemplate;
-  }
+export class GrTooltipContent extends LitElement {
+  @property({type: Boolean, attribute: 'has-tooltip', reflect: true})
+  hasTooltip = false;
 
-  @property({type: String, reflectToAttribute: true})
+  @property({type: Boolean, attribute: 'position-below', reflect: true})
+  positionBelow = false;
+
+  @property({type: String, attribute: 'max-width', reflect: true})
   maxWidth?: string;
 
-  @property({type: Boolean})
+  @property({type: Boolean, attribute: 'show-icon'})
   showIcon = false;
+
+  // Should be private but used in tests.
+  @state()
+  isTouchDevice = 'ontouchstart' in document.documentElement;
+
+  // Should be private but used in tests.
+  tooltip: GrTooltip | null = null;
+
+  @state()
+  private originalTitle = '';
+
+  private hasSetupTooltipListeners = false;
+
+  private readonly windowScrollHandler: () => void;
+
+  private readonly showHandler: () => void;
+
+  private readonly hideHandler: (e: Event) => void;
+
+  // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
+  constructor() {
+    super();
+    this.windowScrollHandler = () => this._handleWindowScroll();
+    this.showHandler = () => this._handleShowTooltip();
+    this.hideHandler = (e: Event | undefined) => this._handleHideTooltip(e);
+  }
+
+  override disconnectedCallback() {
+    this._handleHideTooltip(undefined);
+    this.removeEventListener('mouseenter', this.showHandler);
+    window.removeEventListener('scroll', this.windowScrollHandler);
+    super.disconnectedCallback();
+  }
+
+  static override get styles() {
+    return [
+      css`
+        iron-icon {
+          width: var(--line-height-normal);
+          height: var(--line-height-normal);
+          vertical-align: top;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    return html`
+      <slot></slot>
+      ${this.renderIcon()}
+    `;
+  }
+
+  renderIcon() {
+    if (!this.showIcon) return;
+    return html`<iron-icon icon="gr-icons:info"></iron-icon>`;
+  }
+
+  override updated(changedProperties: PropertyValues) {
+    if (changedProperties.has('hasTooltip')) {
+      this.setupTooltipListeners();
+    }
+  }
+
+  private setupTooltipListeners() {
+    if (!this.hasTooltip) {
+      if (this.hasSetupTooltipListeners) {
+        // if attribute set to false, remove the listener
+        this.removeEventListener('mouseenter', this.showHandler);
+        this.hasSetupTooltipListeners = false;
+      }
+      return;
+    }
+
+    if (this.hasSetupTooltipListeners) {
+      return;
+    }
+    this.hasSetupTooltipListeners = true;
+    this.addEventListener('mouseenter', this.showHandler);
+  }
+
+  _handleShowTooltip() {
+    if (this.isTouchDevice) {
+      return;
+    }
+
+    if (
+      !this.hasAttribute('title') ||
+      this.getAttribute('title') === '' ||
+      this.tooltip
+    ) {
+      return;
+    }
+
+    // Store the title attribute text then set it to an empty string to
+    // prevent it from showing natively.
+    this.originalTitle = this.getAttribute('title') || '';
+    this.setAttribute('title', '');
+
+    const tooltip = document.createElement('gr-tooltip');
+    tooltip.text = this.originalTitle;
+    tooltip.maxWidth = this.getAttribute('max-width') || '';
+    tooltip.positionBelow = this.hasAttribute('position-below');
+
+    // Set visibility to hidden before appending to the DOM so that
+    // calculations can be made based on the element’s size.
+    tooltip.style.visibility = 'hidden';
+    getRootElement().appendChild(tooltip);
+    this._positionTooltip(tooltip);
+    tooltip.style.visibility = 'initial';
+
+    this.tooltip = tooltip;
+    window.addEventListener('scroll', this.windowScrollHandler);
+    this.addEventListener('mouseleave', this.hideHandler);
+    this.addEventListener('click', this.hideHandler);
+    tooltip.addEventListener('mouseleave', this.hideHandler);
+  }
+
+  _handleHideTooltip(e: Event | undefined) {
+    if (this.isTouchDevice) {
+      return;
+    }
+    if (!this.hasAttribute('title') || !this.originalTitle) {
+      return;
+    }
+    // Do not hide if mouse left this or this.tooltip and came to this or
+    // this.tooltip
+    if (
+      (e as MouseEvent)?.relatedTarget === this.tooltip ||
+      (e as MouseEvent)?.relatedTarget === this
+    ) {
+      return;
+    }
+
+    window.removeEventListener('scroll', this.windowScrollHandler);
+    this.removeEventListener('mouseleave', this.hideHandler);
+    this.removeEventListener('click', this.hideHandler);
+    this.setAttribute('title', this.originalTitle);
+    this.tooltip?.removeEventListener('mouseleave', this.hideHandler);
+
+    if (this.tooltip?.parentNode) {
+      this.tooltip.parentNode.removeChild(this.tooltip);
+    }
+    this.tooltip = null;
+  }
+
+  _handleWindowScroll() {
+    if (!this.tooltip) {
+      return;
+    }
+    // This wait is needed for tooltips to be positioned correctly in Firefox
+    // and Safari.
+    this.updateComplete.then(() => this._positionTooltip(this.tooltip));
+  }
+
+  // private but used in tests.
+  async _positionTooltip(tooltip: GrTooltip | null) {
+    if (tooltip === null) return;
+    const rect = this.getBoundingClientRect();
+    const boxRect = tooltip.getBoundingClientRect();
+    if (!tooltip.parentElement) {
+      return;
+    }
+    const parentRect = tooltip.parentElement.getBoundingClientRect();
+    const top = rect.top - parentRect.top;
+    const left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+    const right = parentRect.width - left - boxRect.width;
+    if (left < 0) {
+      tooltip.updateStyles({
+        '--gr-tooltip-arrow-center-offset': `${left}px`,
+      });
+    } else if (right < 0) {
+      tooltip.updateStyles({
+        '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
+      });
+    }
+    tooltip.style.left = `${Math.max(0, left)}px`;
+
+    if (!this.positionBelow) {
+      tooltip.style.top = `${Math.max(0, top)}px`;
+      tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
+    } else {
+      tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
+    }
+  }
 }
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
deleted file mode 100644
index 952420d..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_html.ts
+++ /dev/null
@@ -1,30 +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>
-    iron-icon {
-      width: var(--line-height-normal);
-      height: var(--line-height-normal);
-      vertical-align: top;
-    }
-  </style>
-  <slot></slot
-  ><!--
- --><iron-icon icon="gr-icons:info" hidden$="[[!showIcon]]"></iron-icon>
-`;
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
index f905eaa..8d3bbb0 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.js
@@ -17,35 +17,162 @@
 
 import '../../../test/common-test-setup-karma.js';
 import './gr-tooltip-content.js';
-import {dom} from '@polymer/polymer/lib/legacy/polymer.dom.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
 
-const basicFixture = fixtureFromTemplate(html`
-<gr-tooltip-content>
-    </gr-tooltip-content>
-`);
+const basicFixture = fixtureFromElement('gr-tooltip-content');
 
 suite('gr-tooltip-content tests', () => {
   let element;
-  setup(() => {
+
+  function makeTooltip(tooltipRect, parentRect) {
+    return {
+      getBoundingClientRect() { return tooltipRect; },
+      updateStyles: sinon.stub(),
+      style: {left: 0, top: 0},
+      parentElement: {
+        getBoundingClientRect() { return parentRect; },
+      },
+    };
+  }
+
+  setup(async () => {
     element = basicFixture.instantiate();
+    element.title = 'title';
+    await element.updateComplete;
   });
 
   test('icon is not visible by default', () => {
-    assert.equal(dom(element.root)
-        .querySelector('iron-icon').hidden, true);
+    assert.isNotOk(element.shadowRoot.querySelector('iron-icon'));
   });
 
-  test('position-below attribute is reflected', () => {
+  test('icon is visible with showIcon property', async () => {
+    element.showIcon = true;
+    await element.updateComplete;
+    assert.isOk(element.shadowRoot.querySelector('iron-icon'));
+  });
+
+  test('position-below attribute is reflected', async () => {
     assert.isFalse(element.hasAttribute('position-below'));
     element.positionBelow = true;
+    await element.updateComplete;
     assert.isTrue(element.hasAttribute('position-below'));
   });
 
-  test('icon is visible with showIcon property', () => {
-    element.showIcon = true;
-    assert.equal(dom(element.root)
-        .querySelector('iron-icon').hidden, false);
+  test('normal position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 100, width: 200};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 50},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isFalse(tooltip.updateStyles.called);
+    assert.equal(tooltip.style.left, '175px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('left side position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 10, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '0px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('right side position', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 950, width: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '100px');
+  });
+
+  test('position to bottom', () => {
+    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
+      return {top: 100, left: 950, width: 50, height: 50};
+    });
+    const tooltip = makeTooltip(
+        {height: 30, width: 120},
+        {top: 0, left: 0, width: 1000});
+
+    element.positionBelow = true;
+    element._positionTooltip(tooltip);
+    assert.isTrue(tooltip.updateStyles.called);
+    const offset = tooltip.updateStyles
+        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
+    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
+    assert.equal(tooltip.style.left, '915px');
+    assert.equal(tooltip.style.top, '157.2px');
+  });
+
+  test('hides tooltip when detached', async () => {
+    sinon.stub(element, '_handleHideTooltip');
+    element.remove();
+    await element.updateComplete;
+    assert.isTrue(element._handleHideTooltip.called);
+  });
+
+  test('sets up listeners when has-tooltip is changed', async () => {
+    const addListenerStub = sinon.stub(element, 'addEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    assert.isTrue(addListenerStub.called);
+  });
+
+  test('clean up listeners when has-tooltip changed to false', async () => {
+    const removeListenerStub = sinon.stub(element, 'removeEventListener');
+    element.hasTooltip = true;
+    await element.updateComplete;
+    element.hasTooltip = false;
+    await element.updateComplete;
+    assert.isTrue(removeListenerStub.called);
+  });
+
+  test('do not display tooltips on touch devices', async () => {
+    // On touch devices, tooltips should not be shown.
+    element.isTouchDevice = true;
+    await element.updateComplete;
+
+    // fire mouse-enter
+    element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
+
+    // On other devices, tooltips should be shown.
+    element.isTouchDevice = false;
+
+    // fire mouse-enter
+    element._handleShowTooltip();
+    await element.updateComplete;
+    assert.isOk(element.tooltip);
+
+    // fire mouse-enter
+    element._handleHideTooltip();
+    await element.updateComplete;
+    assert.isNotOk(element.tooltip);
   });
 });
 
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
deleted file mode 100644
index 65a442b..0000000
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.js
+++ /dev/null
@@ -1,57 +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 './gr-tooltip.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-const basicFixture = fixtureFromTemplate(html`
-<gr-tooltip>
-    </gr-tooltip>
-`);
-
-suite('gr-tooltip tests', () => {
-  let element;
-  setup(async () => {
-    element = basicFixture.instantiate();
-    await flush();
-  });
-
-  test('max-width is respected if set', () => {
-    element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
-        ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
-    element.maxWidth = '50px';
-    assert.equal(getComputedStyle(element).width, '50px');
-  });
-
-  test('the correct arrow is displayed', () => {
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionBelow')).display,
-    'none');
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionAbove'))
-        .display, 'none');
-    element.positionBelow = true;
-    assert.notEqual(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionBelow'))
-        .display, 'none');
-    assert.equal(getComputedStyle(element.shadowRoot
-        .querySelector('.arrowPositionAbove'))
-        .display, 'none');
-  });
-});
-
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
new file mode 100644
index 0000000..65ceab1
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.ts
@@ -0,0 +1,67 @@
+/**
+ * @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';
+import './gr-tooltip';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import {GrTooltip} from './gr-tooltip';
+
+const basicFixture = fixtureFromTemplate(html` <gr-tooltip> </gr-tooltip> `);
+
+suite('gr-tooltip tests', () => {
+  let element: GrTooltip;
+  setup(async () => {
+    element = basicFixture.instantiate() as GrTooltip;
+    await flush();
+  });
+
+  test('max-width is respected if set', () => {
+    element.text =
+      'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
+      ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
+    element.maxWidth = '50px';
+    assert.equal(getComputedStyle(element).width, '50px');
+  });
+
+  test('the correct arrow is displayed', () => {
+    assert.equal(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionBelow')!
+      ).display,
+      'none'
+    );
+    assert.notEqual(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionAbove')!
+      ).display,
+      'none'
+    );
+    element.positionBelow = true;
+    assert.notEqual(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionBelow')!
+      ).display,
+      'none'
+    );
+    assert.equal(
+      getComputedStyle(
+        element.shadowRoot!.querySelector('.arrowPositionAbove')!
+      ).display,
+      'none'
+    );
+  });
+});
diff --git a/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
new file mode 100644
index 0000000..9013088
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-vote-chip/gr-vote-chip.ts
@@ -0,0 +1,164 @@
+/**
+ * @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 {LitElement, css, html} from 'lit';
+import {customElement, property} from 'lit/decorators';
+import {
+  ApprovalInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+  LabelInfo,
+} from '../../../api/rest-api';
+import {appContext} from '../../../services/app-context';
+import {KnownExperimentId} from '../../../services/flags/flags';
+import {
+  classForLabelStatus,
+  getLabelStatus,
+  valueString,
+} from '../../../utils/label-util';
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'gr-vote-chip': GrVoteChip;
+  }
+}
+
+@customElement('gr-vote-chip')
+export class GrVoteChip extends LitElement {
+  @property({type: Object})
+  vote?: ApprovalInfo;
+
+  @property({type: Object})
+  label?: LabelInfo;
+
+  /** Show vote as a stack of same votes. */
+  @property({type: Boolean})
+  more = false;
+
+  private readonly flagsService = appContext.flagsService;
+
+  static override get styles() {
+    return [
+      css`
+        .vote-chip.max {
+          background-color: var(--vote-color-approved);
+          padding: 2px;
+        }
+        .vote-chip.max.more {
+          padding: 1px;
+          border: 1px solid var(--vote-outline-recommended);
+        }
+        .vote-chip.min {
+          background-color: var(--vote-color-rejected);
+          padding: 2px;
+        }
+        .vote-chip.min.more {
+          padding: 1px;
+          border: 1px solid var(--vote-outline-disliked);
+        }
+        .vote-chip.positive,
+        .chip-angle.max,
+        .chip-angle.positive {
+          background-color: var(--vote-color-recommended);
+          border: 1px solid var(--vote-outline-recommended);
+          color: var(--chip-color);
+        }
+        .vote-chip.negative,
+        .chip-angle.min,
+        .chip-angle.negative {
+          background-color: var(--vote-color-disliked);
+          border: 1px solid var(--vote-outline-disliked);
+          color: var(--chip-color);
+        }
+        .vote-chip,
+        .chip-angle {
+          display: flex;
+          width: var(--gr-vote-chip-width, 16px);
+          height: var(--gr-vote-chip-height, 16px);
+          font-size: var(--font-size-small);
+          justify-content: center;
+          padding: 1px;
+          border-radius: var(--border-radius);
+          line-height: var(--gr-vote-chip-width, 16px);
+        }
+        .vote-chip {
+          position: relative;
+          z-index: 2;
+        }
+        .chip-angle {
+          position: absolute;
+          top: 2px;
+          left: 2px;
+          z-index: 1;
+        }
+        .container {
+          position: relative;
+        }
+      `,
+    ];
+  }
+
+  override render() {
+    if (!this.flagsService.isEnabled(KnownExperimentId.SUBMIT_REQUIREMENTS_UI))
+      return;
+
+    const renderValue = this.renderValue();
+    if (!renderValue) return;
+
+    return html`<span class="container">
+      <div class="vote-chip ${this.computeClass()} ${this.more ? 'more' : ''}">
+        ${renderValue}
+      </div>
+      ${this.more
+        ? html`<div class="chip-angle ${this.computeClass()}">
+            ${renderValue}
+          </div>`
+        : ''}
+    </span>`;
+  }
+
+  private renderValue() {
+    if (!this.label) {
+      return '';
+    } else if (isDetailedLabelInfo(this.label)) {
+      if (this.vote?.value) {
+        return valueString(this.vote.value);
+      }
+    } else if (isQuickLabelInfo(this.label)) {
+      if (this.label.approved) {
+        return '👍️';
+      } else if (this.label.rejected) {
+        return '👎️';
+      }
+    }
+    return '';
+  }
+
+  private computeClass() {
+    if (!this.label) {
+      return '';
+    } else if (isDetailedLabelInfo(this.label)) {
+      if (this.vote?.value) {
+        const status = getLabelStatus(this.label, this.vote.value);
+        return classForLabelStatus(status);
+      }
+    } else if (isQuickLabelInfo(this.label)) {
+      const status = getLabelStatus(this.label);
+      return classForLabelStatus(status);
+    }
+    return '';
+  }
+}
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 b876f2e..f533bbd 100644
--- a/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
+++ b/polygerrit-ui/app/elements/shared/revision-info/revision-info.ts
@@ -63,6 +63,10 @@
     return this.getParentCountMap()[patchNum as number];
   }
 
+  isMergeCommit(patchNum: PatchSetNum) {
+    return this.getParentCount(patchNum) > 1;
+  }
+
   /**
    * Get the commit ID of the (0-offset) indexed parent in the given revision
    * number.
diff --git a/polygerrit-ui/app/embed/gr-diff.ts b/polygerrit-ui/app/embed/gr-diff.ts
index 6999ef8..422667a4 100644
--- a/polygerrit-ui/app/embed/gr-diff.ts
+++ b/polygerrit-ui/app/embed/gr-diff.ts
@@ -21,14 +21,24 @@
 // variables. If an application wants to use Polymer global variable -
 // the app must assign/import it and do not rely on the Polymer variable
 // exposed by shared gr-diff component.
+import '../api/embed';
 import '../scripts/bundled-polymer';
 import '../elements/diff/gr-diff/gr-diff';
 import '../elements/diff/gr-diff-cursor/gr-diff-cursor';
-import {initDiffAppContext} from './gr-diff-app-context-init';
+import {TokenHighlightLayer} from '../elements/diff/gr-diff-builder/token-highlight-layer';
+import {GrDiffCursor} from '../elements/diff/gr-diff-cursor/gr-diff-cursor';
 import {GrAnnotation} from '../elements/diff/gr-diff-highlight/gr-annotation';
+import {initDiffAppContext} from './gr-diff-app-context-init';
 
 // Setup appContext for diff.
 // TODO (dmfilippov): find a better solution
 initDiffAppContext();
 // Setup global variables for existing usages of this component
+window.grdiff = {
+  GrAnnotation,
+  GrDiffCursor,
+  TokenHighlightLayer,
+};
+
+// TODO(oler): Remove when clients have adjusted to namespaced globals above
 window.GrAnnotation = GrAnnotation;
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
deleted file mode 100644
index 28b7a50..0000000
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
-import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-import {property} from '@polymer/decorators';
-import {ServerInfo} from '../../types/common';
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const ChangeTableMixin = dedupingMixin(
-  <T extends Constructor<PolymerElement>>(
-    superClass: T
-  ): T & Constructor<ChangeTableMixinInterface> => {
-    /**
-     * @polymer
-     * @mixinClass
-     */
-    class Mixin extends superClass {
-      @property({type: Array})
-      readonly columnNames: string[] = [
-        'Subject',
-        'Status',
-        'Owner',
-        'Assignee',
-        'Reviewers',
-        'Comments',
-        'Repo',
-        'Branch',
-        'Updated',
-        'Size',
-      ];
-
-      isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]) {
-        if (!columnsToDisplay || !columnToCheck) {
-          return false;
-        }
-        return !columnsToDisplay.includes(columnToCheck);
-      }
-
-      /**
-       * Is the column disabled by a server config or experiment? For example the
-       * assignee feature might be disabled and thus the corresponding column is
-       * also disabled.
-       *
-       */
-      isColumnEnabled(
-        column: string,
-        config: ServerInfo,
-        experiments: string[]
-      ) {
-        if (!config || !config.change) return true;
-        if (column === 'Assignee') return !!config.change.enable_assignee;
-        if (column === 'Comments')
-          return experiments.includes('comments-column');
-        if (column === 'Reviewers') return !!config.change.enable_attention_set;
-        return true;
-      }
-
-      /**
-       * @return enabled columns, see isColumnEnabled().
-       */
-      getEnabledColumns(
-        columns: string[],
-        config: ServerInfo,
-        experiments: string[]
-      ) {
-        return columns.filter(col =>
-          this.isColumnEnabled(col, config, experiments)
-        );
-      }
-
-      /**
-       * The Project column was renamed to Repo, but some users may have
-       * preferences that use its old name. If that column is found, rename it
-       * before use.
-       *
-       * @return If the column was renamed, returns a new array
-       * with the corrected name. Otherwise, it returns the original param.
-       */
-      renameProjectToRepoColumn(columns: string[]) {
-        const projectIndex = columns.indexOf('Project');
-        if (projectIndex === -1) {
-          return columns;
-        }
-        const newColumns = [...columns];
-        newColumns[projectIndex] = 'Repo';
-        return newColumns;
-      }
-    }
-
-    return Mixin;
-  }
-);
-
-export interface ChangeTableMixinInterface {
-  readonly columnNames: string[];
-  isColumnHidden(columnToCheck?: string, columnsToDisplay?: string[]): boolean;
-  isColumnEnabled(
-    column: string,
-    config: ServerInfo,
-    experiments: string[]
-  ): boolean;
-  getEnabledColumns(
-    columns: string[],
-    config: ServerInfo,
-    experiments: 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
deleted file mode 100644
index 0d6b4ad..0000000
--- a/polygerrit-ui/app/mixins/gr-change-table-mixin/gr-change-table-mixin_test.js
+++ /dev/null
@@ -1,79 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {ChangeTableMixin} from './gr-change-table-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-class GrChangeTableMixinTestElement extends
-  ChangeTableMixin(PolymerElement) {
-  static get is() { return 'gr-change-table-mixin-test-element'; }
-}
-
-customElements.define(GrChangeTableMixinTestElement.is,
-    GrChangeTableMixinTestElement);
-
-const basicFixture = fixtureFromElement(
-    'gr-change-table-mixin-test-element');
-
-suite('gr-change-table-mixin tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('isColumnHidden', () => {
-    const columnToCheck = 'Repo';
-    let columnsToDisplay = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Repo',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
-
-    columnsToDisplay = [
-      'Subject',
-      'Status',
-      'Owner',
-      'Assignee',
-      'Branch',
-      'Updated',
-      'Size',
-    ];
-    assert.isTrue(element.isColumnHidden(columnToCheck, columnsToDisplay));
-  });
-
-  test('renameProjectToRepoColumn maps Project to Repo', () => {
-    const columns = [
-      'Subject',
-      'Status',
-      'Owner',
-    ];
-    assert.deepEqual(element.renameProjectToRepoColumn(columns),
-        columns.slice(0));
-    assert.deepEqual(
-        element.renameProjectToRepoColumn(columns.concat(['Project'])),
-        columns.slice(0).concat(['Repo']));
-  });
-});
-
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
deleted file mode 100644
index af89194..0000000
--- a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin.ts
+++ /dev/null
@@ -1,78 +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 {encodeURL, getBaseUrl} from '../../utils/url-util';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
-import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const ListViewMixin = dedupingMixin(
-  <T extends Constructor<PolymerElement>>(
-    superClass: T
-  ): T & Constructor<ListViewMixinInterface> => {
-    /**
-     * @polymer
-     * @mixinClass
-     */
-    class Mixin extends superClass {
-      computeLoadingClass(loading: boolean): string {
-        return loading ? 'loading' : '';
-      }
-
-      computeShownItems<T>(items: T[]): T[] {
-        return items.slice(0, 25);
-      }
-
-      getUrl(path: string, item: string) {
-        return getBaseUrl() + path + encodeURL(item, true);
-      }
-
-      getFilterValue<T extends ListViewParams>(params: T): string {
-        if (!params) {
-          return '';
-        }
-        return params.filter || '';
-      }
-
-      getOffsetValue<T extends ListViewParams>(params: T): number {
-        if (params?.offset) {
-          return Number(params.offset);
-        }
-        return 0;
-      }
-    }
-
-    return Mixin;
-  }
-);
-
-export interface ListViewMixinInterface {
-  computeLoadingClass(loading: boolean): string;
-  computeShownItems<T>(items: T[]): T[];
-  getUrl(path: string, item: string): string;
-  getFilterValue<T extends ListViewParams>(params: T): string;
-  getOffsetValue<T extends ListViewParams>(params: T): number;
-}
-
-export interface ListViewParams {
-  filter?: string | null;
-  offset?: number | string;
-}
diff --git a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js b/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
deleted file mode 100644
index 407f29f..0000000
--- a/polygerrit-ui/app/mixins/gr-list-view-mixin/gr-list-view-mixin_test.js
+++ /dev/null
@@ -1,79 +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 {ListViewMixin} from './gr-list-view-mixin.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-const basicFixture = fixtureFromElement(
-    'gr-list-view-mixin-test-element');
-
-class GrListViewMixinTestElement extends
-  ListViewMixin(PolymerElement) {
-  static get is() { return 'gr-list-view-mixin-test-element'; }
-}
-
-customElements.define(GrListViewMixinTestElement.is,
-    GrListViewMixinTestElement);
-
-suite('gr-list-view-mixin tests', () => {
-  let element;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('computeLoadingClass', () => {
-    assert.equal(element.computeLoadingClass(true), 'loading');
-    assert.equal(element.computeLoadingClass(false), '');
-  });
-
-  test('computeShownItems', () => {
-    const myArr = new Array(26);
-    assert.equal(element.computeShownItems(myArr).length, 25);
-  });
-
-  test('getUrl', () => {
-    assert.equal(element.getUrl('/path/to/something/', 'item'),
-        '/path/to/something/item');
-    assert.equal(element.getUrl('/path/to/something/', 'item%test'),
-        '/path/to/something/item%2525test');
-  });
-
-  test('getFilterValue', () => {
-    let params;
-    assert.equal(element.getFilterValue(params), '');
-
-    params = {filter: null};
-    assert.equal(element.getFilterValue(params), '');
-
-    params = {filter: 'test'};
-    assert.equal(element.getFilterValue(params), 'test');
-  });
-
-  test('getOffsetValue', () => {
-    let params;
-    assert.equal(element.getOffsetValue(params), 0);
-
-    params = {offset: null};
-    assert.equal(element.getOffsetValue(params), 0);
-
-    params = {offset: 1};
-    assert.equal(element.getOffsetValue(params), 1);
-  });
-});
-
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
deleted file mode 100644
index e60c614..0000000
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin.ts
+++ /dev/null
@@ -1,221 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-import '../../elements/shared/gr-tooltip/gr-tooltip';
-import {flush} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {getRootElement} from '../../scripts/rootElement';
-import {property, observe} from '@polymer/decorators';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
-import {GrTooltip} from '../../elements/shared/gr-tooltip/gr-tooltip';
-import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
-
-const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
-
-/** The interface corresponding to TooltipMixin */
-export interface TooltipMixinInterface {
-  hasTooltip: boolean;
-  positionBelow: boolean;
-  _isTouchDevice: boolean;
-  _tooltip: GrTooltip | null;
-  _titleText: string;
-  _hasSetupTooltipListeners: boolean;
-}
-
-/**
- * @polymer
- * @mixinFunction
- */
-export const TooltipMixin = dedupingMixin(
-  <T extends Constructor<PolymerElement>>(
-    superClass: T
-  ): T & Constructor<TooltipMixinInterface> => {
-    /**
-     * @polymer
-     * @mixinClass
-     */
-    class Mixin extends superClass {
-      @property({type: Boolean})
-      hasTooltip = false;
-
-      @property({type: Boolean, reflectToAttribute: true})
-      positionBelow = false;
-
-      @property({type: Boolean})
-      _isTouchDevice = 'ontouchstart' in document.documentElement;
-
-      @property({type: Object})
-      _tooltip: GrTooltip | null = null;
-
-      @property({type: String})
-      _titleText = '';
-
-      @property({type: Boolean})
-      _hasSetupTooltipListeners = false;
-
-      // Handler for mouseenter event
-      private mouseenterHandler?: (e: MouseEvent) => void;
-
-      // Handler for scrolling on window
-      private readonly windowScrollHandler: () => void;
-
-      // Handler for showing the tooltip, will be attached to certain events
-      private readonly showHandler: () => void;
-
-      // Handler for hiding the tooltip, will be attached to certain events
-      private readonly hideHandler: () => void;
-
-      // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
-      constructor(..._: any[]) {
-        super();
-        this.windowScrollHandler = () => this._handleWindowScroll();
-        this.showHandler = () => this._handleShowTooltip();
-        this.hideHandler = () => this._handleHideTooltip();
-      }
-
-      /** @override */
-      disconnectedCallback() {
-        // NOTE: if you define your own `detached` in your component
-        // then this won't take affect (as its not a class yet)
-        this._handleHideTooltip();
-        if (this.mouseenterHandler) {
-          this.removeEventListener('mouseenter', this.mouseenterHandler);
-        }
-        window.removeEventListener('scroll', this.windowScrollHandler);
-        super.disconnectedCallback();
-      }
-
-      @observe('hasTooltip')
-      _setupTooltipListeners() {
-        if (!this.mouseenterHandler) {
-          this.mouseenterHandler = this.showHandler;
-        }
-
-        if (!this.hasTooltip) {
-          // if attribute set to false, remove the listener
-          this.removeEventListener('mouseenter', this.mouseenterHandler);
-          this._hasSetupTooltipListeners = false;
-          return;
-        }
-
-        if (this._hasSetupTooltipListeners) {
-          return;
-        }
-        this._hasSetupTooltipListeners = true;
-
-        this.addEventListener('mouseenter', this.mouseenterHandler);
-      }
-
-      _handleShowTooltip() {
-        if (this._isTouchDevice) {
-          return;
-        }
-
-        if (
-          !this.hasAttribute('title') ||
-          this.getAttribute('title') === '' ||
-          this._tooltip
-        ) {
-          return;
-        }
-
-        // Store the title attribute text then set it to an empty string to
-        // prevent it from showing natively.
-        this._titleText = this.getAttribute('title') || '';
-        this.setAttribute('title', '');
-
-        const tooltip = document.createElement('gr-tooltip');
-        tooltip.text = this._titleText;
-        tooltip.maxWidth = this.getAttribute('max-width') || '';
-        tooltip.positionBelow = this.hasAttribute('position-below');
-
-        // Set visibility to hidden before appending to the DOM so that
-        // calculations can be made based on the element’s size.
-        tooltip.style.visibility = 'hidden';
-        getRootElement().appendChild(tooltip);
-        this._positionTooltip(tooltip);
-        tooltip.style.visibility = 'initial';
-
-        this._tooltip = tooltip;
-        window.addEventListener('scroll', this.windowScrollHandler);
-        this.addEventListener('mouseleave', this.hideHandler);
-        this.addEventListener('click', this.hideHandler);
-      }
-
-      _handleHideTooltip() {
-        if (this._isTouchDevice) {
-          return;
-        }
-        if (!this.hasAttribute('title') || !this._titleText) {
-          return;
-        }
-
-        window.removeEventListener('scroll', this.windowScrollHandler);
-        this.removeEventListener('mouseleave', this.hideHandler);
-        this.removeEventListener('click', this.hideHandler);
-        this.setAttribute('title', this._titleText);
-
-        if (this._tooltip?.parentNode) {
-          this._tooltip.parentNode.removeChild(this._tooltip);
-        }
-        this._tooltip = null;
-      }
-
-      _handleWindowScroll() {
-        if (!this._tooltip) {
-          return;
-        }
-
-        this._positionTooltip(this._tooltip);
-      }
-
-      _positionTooltip(tooltip: GrTooltip) {
-        // This flush is needed for tooltips to be positioned correctly in Firefox
-        // and Safari.
-        flush();
-        const rect = this.getBoundingClientRect();
-        const boxRect = tooltip.getBoundingClientRect();
-        if (!tooltip.parentElement) {
-          return;
-        }
-        const parentRect = tooltip.parentElement.getBoundingClientRect();
-        const top = rect.top - parentRect.top;
-        const left =
-          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-        const right = parentRect.width - left - boxRect.width;
-        if (left < 0) {
-          tooltip.updateStyles({
-            '--gr-tooltip-arrow-center-offset': `${left}px`,
-          });
-        } else if (right < 0) {
-          tooltip.updateStyles({
-            '--gr-tooltip-arrow-center-offset': `${-0.5 * right}px`,
-          });
-        }
-        tooltip.style.left = `${Math.max(0, left)}px`;
-
-        if (!this.positionBelow) {
-          tooltip.style.top = `${Math.max(0, top)}px`;
-          tooltip.style.transform = `translateY(calc(-100% - ${BOTTOM_OFFSET}px))`;
-        } else {
-          tooltip.style.top = `${top + rect.height + BOTTOM_OFFSET}px`;
-        }
-      }
-    }
-
-    return Mixin;
-  }
-);
diff --git a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js b/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
deleted file mode 100644
index 209c83af..0000000
--- a/polygerrit-ui/app/mixins/gr-tooltip-mixin/gr-tooltip-mixin_test.js
+++ /dev/null
@@ -1,137 +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 {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {TooltipMixin} from './gr-tooltip-mixin.js';
-
-const basicFixture = fixtureFromElement('gr-tooltip-mixin-element');
-
-class GrTooltipMixinTestElement extends TooltipMixin(PolymerElement) {
-  static get is() {
-    return 'gr-tooltip-mixin-element';
-  }
-}
-
-customElements.define(GrTooltipMixinTestElement.is,
-    GrTooltipMixinTestElement);
-
-suite('gr-tooltip-mixin tests', () => {
-  let element;
-
-  function makeTooltip(tooltipRect, parentRect) {
-    return {
-      getBoundingClientRect() { return tooltipRect; },
-      updateStyles: sinon.stub(),
-      style: {left: 0, top: 0},
-      parentElement: {
-        getBoundingClientRect() { return parentRect; },
-      },
-    };
-  }
-
-  setup(() => {
-    element = basicFixture.instantiate();
-  });
-
-  test('normal position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 100, width: 200};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 50},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isFalse(tooltip.updateStyles.called);
-    assert.equal(tooltip.style.left, '175px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('left side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 10, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '0px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('right side position', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '100px');
-  });
-
-  test('position to bottom', () => {
-    sinon.stub(element, 'getBoundingClientRect').callsFake(() => {
-      return {top: 100, left: 950, width: 50, height: 50};
-    });
-    const tooltip = makeTooltip(
-        {height: 30, width: 120},
-        {top: 0, left: 0, width: 1000});
-
-    element.positionBelow = true;
-    element._positionTooltip(tooltip);
-    assert.isTrue(tooltip.updateStyles.called);
-    const offset = tooltip.updateStyles
-        .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
-    assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
-    assert.equal(tooltip.style.left, '915px');
-    assert.equal(tooltip.style.top, '157.2px');
-  });
-
-  test('hides tooltip when detached', () => {
-    sinon.stub(element, '_handleHideTooltip');
-    element.remove();
-    flush();
-    assert.isTrue(element._handleHideTooltip.called);
-  });
-
-  test('sets up listeners when has-tooltip is changed', () => {
-    const addListenerStub = sinon.stub(element, 'addEventListener');
-    element.hasTooltip = true;
-    assert.isTrue(addListenerStub.called);
-  });
-
-  test('clean up listeners when has-tooltip changed to false', () => {
-    const removeListenerStub = sinon.stub(element, 'removeEventListener');
-    element.hasTooltip = true;
-    element.hasTooltip = false;
-    assert.isTrue(removeListenerStub.called);
-  });
-});
-
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
new file mode 100644
index 0000000..fd4da4f
--- /dev/null
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin.ts
@@ -0,0 +1,493 @@
+/**
+ * @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 {getRootElement} from '../../scripts/rootElement';
+import {Constructor} from '../../utils/common-util';
+import {LitElement, PropertyValues} from 'lit';
+import {property, query} from 'lit/decorators';
+import {ShowAlertEventDetail} from '../../types/events';
+import {debounce, DelayedTask} from '../../utils/async-util';
+import {hovercardStyles} from '../../styles/gr-hovercard-styles';
+import {sharedStyles} from '../../styles/shared-styles';
+
+interface ReloadEventDetail {
+  clearPatchset?: boolean;
+}
+
+const HOVER_CLASS = 'hovered';
+const HIDE_CLASS = 'hide';
+
+/**
+ * ID for the container element.
+ */
+const containerId = 'gr-hovercard-container';
+
+export function getHovercardContainer(
+  options: {createIfNotExists: boolean} = {createIfNotExists: false}
+): HTMLElement | null {
+  let container = getRootElement().querySelector<HTMLElement>(
+    `#${containerId}`
+  );
+  if (!container && options.createIfNotExists) {
+    // If it does not exist, create and initialize the hovercard container.
+    container = document.createElement('div');
+    container.setAttribute('id', containerId);
+    getRootElement().appendChild(container);
+  }
+  return container;
+}
+
+/**
+ * How long should we wait before showing the hovercard when the user hovers
+ * over the element?
+ */
+const SHOW_DELAY_MS = 550;
+
+/**
+ * How long should we wait before hiding the hovercard when the user moves from
+ * target to the hovercard.
+ *
+ * Note: this should be lower than SHOW_DELAY_MS to avoid flickering.
+ */
+const HIDE_DELAY_MS = 500;
+
+/**
+ * The mixin for hovercard behavior.
+ *
+ * @example
+ *
+ * class YourComponent extends hovercardBehaviorMixin(
+ *  LitElement)
+ *
+ * @see gr-hovercard.ts
+ *
+ * // following annotations are required for polylint
+ * @lit
+ * @mixinFunction
+ */
+export const HovercardMixin = <T extends Constructor<LitElement>>(
+  superClass: T
+) => {
+  /**
+   * @lit
+   * @mixinClass
+   */
+  class Mixin extends superClass {
+    @query('#container')
+    topElement?: HTMLElement;
+
+    @property({type: Object})
+    _target: HTMLElement | null = null;
+
+    // Determines whether or not the hovercard is visible.
+    @property({type: Boolean})
+    _isShowing = false;
+
+    // The `id` of the element that the hovercard is anchored to.
+    @property({type: String})
+    for?: string;
+
+    /**
+     * The spacing between the top of the hovercard and the element it is
+     * anchored to.
+     */
+    @property({type: Number})
+    offset = 14;
+
+    /**
+     * Positions the hovercard to the top, right, bottom, left, bottom-left,
+     * bottom-right, top-left, or top-right of its content.
+     */
+    @property({type: String})
+    position = 'right';
+
+    @property({type: Object})
+    container: HTMLElement | null = null;
+
+    // Private but used in tests.
+    hideTask?: DelayedTask;
+
+    showTask?: DelayedTask;
+
+    isScheduledToShow?: boolean;
+
+    isScheduledToHide?: boolean;
+
+    static get styles() {
+      return [sharedStyles, hovercardStyles];
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    constructor(...args: any[]) {
+      super(...args);
+      // show the hovercard if mouse moves to hovercard
+      // this will cancel pending hide as well
+      this.addEventListener('mouseenter', this.show);
+      // when leave hovercard, hide it immediately
+      this.addEventListener('mouseleave', this.hide);
+    }
+
+    override connectedCallback() {
+      super.connectedCallback();
+      // We have to cache the target because when we this.container.appendChild
+      // in show we can not pick the container as target when we reconnect.
+      if (!this._target) {
+        this._target = this.target;
+        this.addTargetEventListeners();
+      }
+
+      this.container = getHovercardContainer({createIfNotExists: true});
+    }
+
+    override disconnectedCallback() {
+      this.cancelShowTask();
+      this.cancelHideTask();
+      super.disconnectedCallback();
+    }
+
+    private addTargetEventListeners() {
+      // We intentionally listen on 'mousemove' instead of 'mouseenter', because
+      // otherwise the target appearing under the mouse cursor would also
+      // trigger the hovercard, which can annoying for the user, for example
+      // when added reviewer chips appear in the reply dialog via keyboard
+      // interaction.
+      this._target?.addEventListener('mousemove', this.debounceShow);
+      this._target?.addEventListener('focus', this.debounceShow);
+      this._target?.addEventListener('mouseleave', this.debounceHide);
+      this._target?.addEventListener('blur', this.debounceHide);
+      this._target?.addEventListener('click', this.hide);
+    }
+
+    private removeTargetEventListeners() {
+      this._target?.removeEventListener('mousemove', this.debounceShow);
+      this._target?.removeEventListener('focus', this.debounceShow);
+      this._target?.removeEventListener('mouseleave', this.debounceHide);
+      this._target?.removeEventListener('blur', this.debounceHide);
+      this._target?.removeEventListener('click', this.hide);
+    }
+
+    /**
+     * Responds to a change in the `for` value and gets the updated `target`
+     * element for the hovercard.
+     */
+    override updated(changedProperties: PropertyValues) {
+      super.updated(changedProperties);
+      if (changedProperties.has('for')) {
+        this.removeTargetEventListeners();
+        this._target = this.target;
+        this.addTargetEventListeners();
+      }
+    }
+
+    readonly debounceHide = () => {
+      this.cancelShowTask();
+      if (!this._isShowing || this.isScheduledToHide) return;
+      this.isScheduledToHide = true;
+      this.hideTask = debounce(
+        this.hideTask,
+        () => {
+          // This happens when hide immediately through click or mouse leave
+          // on the hovercard
+          if (!this.isScheduledToHide) return;
+          this.hide();
+        },
+        HIDE_DELAY_MS
+      );
+    };
+
+    cancelHideTask() {
+      if (!this.hideTask) return;
+      this.hideTask.cancel();
+      this.isScheduledToHide = false;
+      this.hideTask = undefined;
+    }
+
+    /**
+     * Hovercard elements are created outside of <gr-app>, so if you want to fire
+     * events, then you probably want to do that through the target element.
+     */
+
+    dispatchEventThroughTarget(eventName: string): void;
+
+    dispatchEventThroughTarget(
+      eventName: 'show-alert',
+      detail: ShowAlertEventDetail
+    ): void;
+
+    dispatchEventThroughTarget(
+      eventName: 'reload',
+      detail: ReloadEventDetail
+    ): void;
+
+    dispatchEventThroughTarget(eventName: string, detail?: unknown) {
+      if (!detail) detail = {};
+      if (this._target)
+        this._target.dispatchEvent(
+          new CustomEvent(eventName, {
+            detail,
+            bubbles: true,
+            composed: true,
+          })
+        );
+    }
+
+    /**
+     * Returns the target element that the hovercard is anchored to (the `id` of
+     * the `for` property).
+     */
+    get target(): HTMLElement {
+      const parentNode = this.parentNode;
+      // If the parentNode is a document fragment, then we need to use the host.
+      const ownerRoot = this.getRootNode() as ShadowRoot;
+      let target;
+      if (this.for) {
+        target = ownerRoot.querySelector('#' + this.for);
+      } else {
+        target =
+          !parentNode || parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE
+            ? ownerRoot.host
+            : parentNode;
+      }
+      return target as HTMLElement;
+    }
+
+    /**
+     * Hides/closes the hovercard. This occurs when the user triggers the
+     * `mouseleave` event on the hovercard's `target` element (as long as the
+     * user is not hovering over the hovercard).
+     *
+     */
+    readonly hide = (e?: MouseEvent) => {
+      this.cancelHideTask();
+      this.cancelShowTask();
+      if (!this._isShowing) {
+        return;
+      }
+
+      // If the user is now hovering over the hovercard or the user is returning
+      // from the hovercard but now hovering over the target (to stop an annoying
+      // flicker effect), just return.
+      if (e) {
+        if (
+          e.relatedTarget === this ||
+          (e.target === this && e.relatedTarget === this._target)
+        ) {
+          return;
+        }
+      }
+
+      // Mark that the hovercard is not visible and do not allow focusing
+      this._isShowing = false;
+
+      // Clear styles in preparation for the next time we need to show the card
+      this.classList.remove(HOVER_CLASS);
+
+      // Reset and remove the hovercard from the DOM
+      this.style.cssText = '';
+      this.topElement?.setAttribute('tabindex', '-1');
+
+      // Remove the hovercard from the container, given that it is still a child
+      // of the container.
+      if (this.container?.contains(this)) {
+        this.container.removeChild(this);
+      }
+    };
+
+    /**
+     * Shows/opens the hovercard with a fixed delay.
+     */
+    readonly debounceShow = () => {
+      this.debounceShowBy(SHOW_DELAY_MS);
+    };
+
+    /**
+     * Shows/opens the hovercard with the given delay.
+     */
+    debounceShowBy(delayMs: number) {
+      this.cancelHideTask();
+      if (this._isShowing || this.isScheduledToShow) return;
+      this.isScheduledToShow = true;
+      this.showTask = debounce(
+        this.showTask,
+        () => {
+          // This happens when the mouse leaves the target before the delay is over.
+          if (!this.isScheduledToShow) return;
+          this.show();
+        },
+        delayMs
+      );
+    }
+
+    cancelShowTask() {
+      if (!this.showTask) return;
+      this.showTask.cancel();
+      this.isScheduledToShow = false;
+      this.showTask = undefined;
+    }
+
+    /**
+     * Shows/opens the hovercard. This occurs when the user triggers the
+     * `mousenter` event on the hovercard's `target` element.
+     */
+    readonly show = async () => {
+      this.cancelHideTask();
+      this.cancelShowTask();
+      if (this._isShowing || !this.container) {
+        return;
+      }
+
+      // Mark that the hovercard is now visible
+      this._isShowing = true;
+      this.setAttribute('tabindex', '0');
+
+      // Add it to the DOM and calculate its position
+      this.container.appendChild(this);
+      // We temporarily hide the hovercard until we have found the correct
+      // position for it.
+      this.classList.add(HIDE_CLASS);
+      this.classList.add(HOVER_CLASS);
+      // Make sure that the hovercard actually rendered and all dom-if
+      // statements processed, so that we can measure the (invisible)
+      // hovercard properly in updatePosition().
+      await new Promise<void>(r => {
+        setTimeout(r, 0);
+      });
+      this.updatePosition();
+      this.classList.remove(HIDE_CLASS);
+    };
+
+    updatePosition() {
+      const positionsToTry = new Set([
+        this.position,
+        'right',
+        'bottom-right',
+        'top-right',
+        'bottom',
+        'top',
+        'bottom-left',
+        'top-left',
+        'left',
+      ]);
+      for (const position of positionsToTry) {
+        this.updatePositionTo(position);
+        if (this._isInsideViewport()) return;
+      }
+      console.warn('Could not find a visible position for the hovercard.');
+    }
+
+    _isInsideViewport() {
+      const thisRect = this.getBoundingClientRect();
+      if (thisRect.top < 0) return false;
+      if (thisRect.left < 0) return false;
+      const docuRect = document.documentElement.getBoundingClientRect();
+      if (thisRect.bottom > docuRect.height) return false;
+      if (thisRect.right > docuRect.width) return false;
+      return true;
+    }
+
+    /**
+     * Updates the hovercard's position based the current position of the `target`
+     * element.
+     *
+     * The hovercard is supposed to stay open if the user hovers over it.
+     * To keep it open when the user moves away from the target, the bounding
+     * rects of the target and hovercard must touch or overlap.
+     *
+     * NOTE: You do not need to directly call this method unless you need to
+     * update the position of the tooltip while it is already visible (the
+     * target element has moved and the tooltip is still open).
+     */
+    updatePositionTo(position: string) {
+      if (!this._target) {
+        return;
+      }
+
+      // Make sure that thisRect will not get any paddings and such included
+      // in the width and height of the bounding client rect.
+      this.style.cssText = '';
+
+      const docuRect = document.documentElement.getBoundingClientRect();
+      const targetRect = this._target.getBoundingClientRect();
+      const thisRect = this.getBoundingClientRect();
+
+      const targetLeft = targetRect.left - docuRect.left;
+      const targetTop = targetRect.top - docuRect.top;
+
+      let hovercardLeft;
+      let hovercardTop;
+
+      switch (position) {
+        case 'top':
+          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+          hovercardTop = targetTop - thisRect.height - this.offset;
+          break;
+        case 'bottom':
+          hovercardLeft = targetLeft + (targetRect.width - thisRect.width) / 2;
+          hovercardTop = targetTop + targetRect.height + this.offset;
+          break;
+        case 'left':
+          hovercardLeft = targetLeft - thisRect.width - this.offset;
+          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+          break;
+        case 'right':
+          hovercardLeft = targetLeft + targetRect.width + this.offset;
+          hovercardTop = targetTop + (targetRect.height - thisRect.height) / 2;
+          break;
+        case 'bottom-right':
+          hovercardLeft = targetLeft + targetRect.width + this.offset;
+          hovercardTop = targetTop;
+          break;
+        case 'bottom-left':
+          hovercardLeft = targetLeft - thisRect.width - this.offset;
+          hovercardTop = targetTop;
+          break;
+        case 'top-left':
+          hovercardLeft = targetLeft - thisRect.width - this.offset;
+          hovercardTop = targetTop + targetRect.height - thisRect.height;
+          break;
+        case 'top-right':
+          hovercardLeft = targetLeft + targetRect.width + this.offset;
+          hovercardTop = targetTop + targetRect.height - thisRect.height;
+          break;
+      }
+
+      this.style.left = `${hovercardLeft}px`;
+      this.style.top = `${hovercardTop}px`;
+    }
+  }
+
+  return Mixin as T & Constructor<HovercardMixinInterface>;
+};
+
+export interface HovercardMixinInterface {
+  for?: string;
+  offset: number;
+  _target: HTMLElement | null;
+  _isShowing: boolean;
+  dispatchEventThroughTarget(eventName: string, detail?: unknown): void;
+  show(): void;
+
+  // Used for tests
+  hide(e: MouseEvent): void;
+  container: HTMLElement | null;
+  hideTask?: DelayedTask;
+  showTask?: DelayedTask;
+  position: string;
+  debounceShowBy(delayMs: number): void;
+  updatePosition(): void;
+  isScheduledToShow?: boolean;
+  isScheduledToHide?: boolean;
+}
diff --git a/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
new file mode 100644
index 0000000..dd86b38
--- /dev/null
+++ b/polygerrit-ui/app/mixins/hovercard-mixin/hovercard-mixin_test.ts
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * 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.
+ */
+
+import '../../test/common-test-setup-karma.js';
+import {HovercardMixin} from './hovercard-mixin.js';
+import {html, LitElement} from 'lit';
+import {customElement} from 'lit/decorators';
+import {MockPromise, mockPromise} from '../../test/test-utils.js';
+
+const base = HovercardMixin(LitElement);
+
+declare global {
+  interface HTMLElementTagNameMap {
+    'hovercard-mixin-test': HovercardMixinTest;
+  }
+}
+
+@customElement('hovercard-mixin-test')
+class HovercardMixinTest extends base {
+  constructor() {
+    super();
+    this.for = 'foo';
+  }
+
+  override render() {
+    return html`<div id="container"><slot></slot></div>`;
+  }
+}
+
+const basicFixture = fixtureFromElement('hovercard-mixin-test');
+
+suite('gr-hovercard tests', () => {
+  let element: HovercardMixinTest;
+
+  let button: HTMLElement;
+  let testPromise: MockPromise;
+
+  setup(() => {
+    testPromise = mockPromise();
+    button = document.createElement('button');
+    button.innerHTML = 'Hello';
+    button.setAttribute('id', 'foo');
+    document.body.appendChild(button);
+
+    element = basicFixture.instantiate();
+  });
+
+  teardown(() => {
+    element.hide(new MouseEvent('click'));
+    button?.remove();
+  });
+
+  test('updatePosition', async () => {
+    // Test that the correct style properties have at least been set.
+    element.position = 'bottom';
+    element.updatePosition();
+    await element.updateComplete;
+    assert.typeOf(element.style.getPropertyValue('left'), 'string');
+    assert.typeOf(element.style.getPropertyValue('top'), 'string');
+    assert.typeOf(element.style.getPropertyValue('paddingTop'), 'string');
+    assert.typeOf(element.style.getPropertyValue('marginTop'), 'string');
+
+    const parentRect = document.documentElement.getBoundingClientRect();
+    const targetRect = element!._target!.getBoundingClientRect();
+    const thisRect = element.getBoundingClientRect();
+
+    const targetLeft = targetRect.left - parentRect.left;
+    const targetTop = targetRect.top - parentRect.top;
+
+    const pixelCompare = (pixel: string) =>
+      Math.round(parseInt(pixel.substring(0, pixel.length - 1), 10));
+
+    assert.equal(
+      pixelCompare(element.style.left),
+      pixelCompare(`${targetLeft + (targetRect.width - thisRect.width) / 2}px`)
+    );
+    assert.equal(
+      pixelCompare(element.style.top),
+      pixelCompare(`${targetTop + targetRect.height + element.offset}px`)
+    );
+  });
+
+  test('hide', () => {
+    element.hide(new MouseEvent('click'));
+    const style = getComputedStyle(element);
+    assert.isFalse(element._isShowing);
+    assert.isFalse(element.classList.contains('hovered'));
+    assert.equal(style.display, 'none');
+    assert.notEqual(element.container, element.parentNode);
+  });
+
+  test('show', async () => {
+    await element.show();
+    await element.updateComplete;
+    const style = getComputedStyle(element);
+    assert.isTrue(element._isShowing);
+    assert.isTrue(element.classList.contains('hovered'));
+    assert.equal(style.opacity, '1');
+    assert.equal(style.visibility, 'visible');
+  });
+
+  test('debounceShow does not show immediately', async () => {
+    element.debounceShowBy(100);
+    setTimeout(() => testPromise.resolve(), 0);
+    await testPromise;
+    assert.isFalse(element._isShowing);
+  });
+
+  test('debounceShow shows after delay', async () => {
+    element.debounceShowBy(1);
+    setTimeout(() => testPromise.resolve(), 10);
+    await testPromise;
+    assert.isTrue(element._isShowing);
+  });
+
+  test('card is scheduled to show on enter and hides on leave', async () => {
+    const button = document.querySelector('button');
+    const enterPromise = mockPromise();
+    button!.addEventListener('mousemove', () => enterPromise.resolve());
+    const leavePromise = mockPromise();
+    button!.addEventListener('mouseleave', () => leavePromise.resolve());
+
+    assert.isFalse(element._isShowing);
+    button!.dispatchEvent(new CustomEvent('mousemove'));
+
+    await enterPromise;
+    await flush();
+    assert.isTrue(element.isScheduledToShow);
+    element!.showTask!.flush();
+    assert.isTrue(element._isShowing);
+    assert.isFalse(element.isScheduledToShow);
+
+    button!.dispatchEvent(new CustomEvent('mouseleave'));
+
+    await leavePromise;
+    assert.isTrue(element.isScheduledToHide);
+    assert.isTrue(element._isShowing);
+    element!.hideTask!.flush();
+    assert.isFalse(element.isScheduledToShow);
+    assert.isFalse(element._isShowing);
+  });
+
+  test('card should disappear on click', async () => {
+    const button = document.querySelector('button');
+    const enterPromise = mockPromise();
+    const clickPromise = mockPromise();
+    button!.addEventListener('mousemove', () => enterPromise.resolve());
+    button!.addEventListener('click', () => clickPromise.resolve());
+
+    assert.isFalse(element._isShowing);
+
+    button!.dispatchEvent(new CustomEvent('mousemove'));
+
+    await enterPromise;
+    await flush();
+    assert.isTrue(element.isScheduledToShow);
+    button!.click();
+
+    await clickPromise;
+    assert.isFalse(element.isScheduledToShow);
+    assert.isFalse(element._isShowing);
+  });
+});
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 7b740fb..f133c116 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
@@ -14,1088 +14,133 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-/*
-
-How to Add a Keyboard Shortcut
-==============================
-
-A keyboard shortcut is composed of the following parts:
-
-  1. A semantic identifier (e.g. OPEN_CHANGE, NEXT_PAGE)
-  2. Documentation for the keyboard shortcut help dialog
-  3. A binding between key combos and the semantic identifier
-  4. A binding between the semantic identifier and a listener
-
-Parts (1) and (2) for all shortcuts are defined in this file. The semantic
-identifier is declared in the Shortcut enum near the head of this script:
-
-  const Shortcut = {
-    // ...
-    TOGGLE_LEFT_PANE: 'TOGGLE_LEFT_PANE',
-    // ...
-  };
-
-Immediately following the Shortcut enum definition, there is a _describe
-function defined which is then invoked many times to populate the help dialog.
-Add a new invocation here to document the shortcut:
-
-  _describe(Shortcut.TOGGLE_LEFT_PANE, ShortcutSection.DIFFS,
-      'Hide/show left diff');
-
-When an attached view binds one or more key combos to this shortcut, the help
-dialog will display this text in the given section (in this case, "Diffs"). See
-the ShortcutSection enum immediately below for the list of supported sections.
-
-Part (3), the actual key bindings, are declared by gr-app. In the future, this
-system may be expanded to allow key binding customizations by plugins or user
-preferences. Key bindings are defined in the following forms:
-
-  // Ordinary shortcut with a single binding.
-  this.bindShortcut(
-      Shortcut.TOGGLE_LEFT_PANE, 'shift+a');
-
-  // Ordinary shortcut with multiple bindings.
-  this.bindShortcut(
-      Shortcut.CURSOR_NEXT_FILE, 'j', 'down');
-
-  // A "go-key" keyboard shortcut, which is combined with a previously and
-  // continuously pressed "go" key (the go-key is hard-coded as 'g').
-  this.bindShortcut(
-      Shortcut.GO_TO_OPENED_CHANGES, SPECIAL_SHORTCUT.GO_KEY, 'o');
-
-  // A "doc-only" keyboard shortcut. This declares the key-binding for help
-  // dialog purposes, but doesn't actually implement the binding. It is up
-  // to some element to implement this binding using iron-a11y-keys-behavior's
-  // keyBindings property.
-  this.bindShortcut(
-      Shortcut.EXPAND_ALL_COMMENT_THREADS, SPECIAL_SHORTCUT.DOC_ONLY, 'e');
-
-Part (4), the listener definitions, are declared by the view or element that
-implements the shortcut behavior. This is done by implementing a method named
-keyboardShortcuts() in an element that mixes in this behavior, returning an
-object that maps semantic identifiers (as property names) to listener method
-names, like this:
-
-  keyboardShortcuts() {
-    return {
-      [Shortcut.TOGGLE_LEFT_PANE]: '_handleToggleLeftPane',
-    };
-  },
-
-You can implement key bindings in an element that is hosted by a view IF that
-element is always attached exactly once under that view (e.g. the search bar in
-gr-app). When that is not the case, you will have to define a doc-only binding
-in gr-app, declare the shortcut in the view that hosts the element, and use
-iron-a11y-keys-behavior's keyBindings attribute to implement the binding in the
-element. An example of this is in comment threads. A diff view supports actions
-on comment threads, but there may be zero or many comment threads attached at
-any given point. So the shortcut is declared as doc-only by the diff view and
-by gr-app, and actually implemented by gr-comment-thread.
-
-NOTE: doc-only shortcuts will not be customizable in the same way that other
-shortcuts are.
-*/
-
-import {IronA11yKeysBehavior} from '@polymer/iron-a11y-keys-behavior/iron-a11y-keys-behavior';
-import {dom, EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class';
-import {dedupingMixin} from '@polymer/polymer/lib/utils/mixin';
 import {property} from '@polymer/decorators';
 import {PolymerElement} from '@polymer/polymer';
-import {Constructor} from '../../utils/common-util';
+import {check, Constructor} from '../../utils/common-util';
+import {appContext} from '../../services/app-context';
 import {
-  CustomKeyboardEvent,
-  ShortcutTriggeredEventDetail,
-} from '../../types/events';
+  Shortcut,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+} from '../../services/shortcuts/shortcuts-config';
+import {
+  SectionView,
+  ShortcutListener,
+} from '../../services/shortcuts/shortcuts-service';
 
-/** Enum for all special shortcuts */
-export enum SPECIAL_SHORTCUT {
-  DOC_ONLY = 'DOC_ONLY',
-  GO_KEY = 'GO_KEY',
-  V_KEY = 'V_KEY',
-}
-
-// The maximum age of a keydown event to be used in a jump navigation. This
-// is only for cases when the keyup event is lost.
-const GO_KEY_TIMEOUT_MS = 1000;
-
-const V_KEY_TIMEOUT_MS = 1000;
-
-const THROTTLE_INTERVAL_MS = 500;
-
-/**
- * Enum for all shortcut sections, where that shortcut should be applied to.
- */
-export enum ShortcutSection {
-  ACTIONS = 'Actions',
-  DIFFS = 'Diffs',
-  EVERYWHERE = 'Global Shortcuts',
-  FILE_LIST = 'File list',
-  NAVIGATION = 'Navigation',
-  REPLY_DIALOG = 'Reply dialog',
-}
-
-/**
- * Enum for all possible shortcut names.
- */
-export enum Shortcut {
-  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
-  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
-  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
-  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
-  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
-  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
-
-  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
-  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
-  OPEN_CHANGE = 'OPEN_CHANGE',
-  NEXT_PAGE = 'NEXT_PAGE',
-  PREV_PAGE = 'PREV_PAGE',
-  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
-  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
-  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
-
-  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
-  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
-  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
-  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
-  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
-  UP_TO_CHANGE = 'UP_TO_CHANGE',
-  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
-  REFRESH_CHANGE = 'REFRESH_CHANGE',
-  EDIT_TOPIC = 'EDIT_TOPIC',
-  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
-  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
-  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
-  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
-  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
-
-  NEXT_LINE = 'NEXT_LINE',
-  PREV_LINE = 'PREV_LINE',
-  VISIBLE_LINE = 'VISIBLE_LINE',
-  NEXT_CHUNK = 'NEXT_CHUNK',
-  PREV_CHUNK = 'PREV_CHUNK',
-  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',
-  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
-  LEFT_PANE = 'LEFT_PANE',
-  RIGHT_PANE = 'RIGHT_PANE',
-  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
-  NEW_COMMENT = 'NEW_COMMENT',
-  SAVE_COMMENT = 'SAVE_COMMENT',
-  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
-  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
-
-  NEXT_FILE = 'NEXT_FILE',
-  PREV_FILE = 'PREV_FILE',
-  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
-  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
-  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
-  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
-  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
-  OPEN_FILE = 'OPEN_FILE',
-  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
-  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
-  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
-  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
-  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
-
-  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
-  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
-
-  SEARCH = 'SEARCH',
-  SEND_REPLY = 'SEND_REPLY',
-  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
-  TOGGLE_BLAME = 'TOGGLE_BLAME',
-}
-
-export type SectionView = Array<{binding: string[][]; text: string}>;
-
-/**
- * The interface for listener for shortcut events.
- */
-export type ShortcutListener = (
-  viewMap?: Map<ShortcutSection, SectionView>
-) => void;
-
-interface ShortcutEnabledElement extends PolymerElement {
-  // TODO: should replace with Map so we can have proper type here
-  keyboardShortcuts(): {[shortcut: string]: string};
-}
-
-interface ShortcutHelpItem {
-  shortcut: Shortcut;
-  text: string;
-}
-
-// TODO(TS): rename to something more meaningful
-const _help = new Map<ShortcutSection, ShortcutHelpItem[]>();
-
-function _describe(shortcut: Shortcut, section: ShortcutSection, text: string) {
-  if (!_help.has(section)) {
-    _help.set(section, []);
-  }
-  const shortcuts = _help.get(section);
-  if (shortcuts) {
-    shortcuts.push({shortcut, text});
-  }
-}
-
-_describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search');
-_describe(
-  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
-  ShortcutSection.EVERYWHERE,
-  'Show this dialog'
-);
-_describe(
-  Shortcut.GO_TO_USER_DASHBOARD,
-  ShortcutSection.EVERYWHERE,
-  'Go to User Dashboard'
-);
-_describe(
-  Shortcut.GO_TO_OPENED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Opened Changes'
-);
-_describe(
-  Shortcut.GO_TO_MERGED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Merged Changes'
-);
-_describe(
-  Shortcut.GO_TO_ABANDONED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Abandoned Changes'
-);
-_describe(
-  Shortcut.GO_TO_WATCHED_CHANGES,
-  ShortcutSection.EVERYWHERE,
-  'Go to Watched Changes'
-);
-
-_describe(
-  Shortcut.CURSOR_NEXT_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select next change'
-);
-_describe(
-  Shortcut.CURSOR_PREV_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Select previous change'
-);
-_describe(
-  Shortcut.OPEN_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Show selected change'
-);
-_describe(Shortcut.NEXT_PAGE, ShortcutSection.ACTIONS, 'Go to next page');
-_describe(Shortcut.PREV_PAGE, ShortcutSection.ACTIONS, 'Go to previous page');
-_describe(
-  Shortcut.OPEN_REPLY_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open reply dialog to publish comments and add reviewers'
-);
-_describe(
-  Shortcut.OPEN_DOWNLOAD_DIALOG,
-  ShortcutSection.ACTIONS,
-  'Open download overlay'
-);
-_describe(
-  Shortcut.EXPAND_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Expand all messages'
-);
-_describe(
-  Shortcut.COLLAPSE_ALL_MESSAGES,
-  ShortcutSection.ACTIONS,
-  'Collapse all messages'
-);
-_describe(
-  Shortcut.REFRESH_CHANGE,
-  ShortcutSection.ACTIONS,
-  'Reload the change at the latest patch'
-);
-_describe(
-  Shortcut.TOGGLE_CHANGE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Mark/unmark change as reviewed'
-);
-_describe(
-  Shortcut.TOGGLE_FILE_REVIEWED,
-  ShortcutSection.ACTIONS,
-  'Toggle review flag on selected file'
-);
-_describe(
-  Shortcut.REFRESH_CHANGE_LIST,
-  ShortcutSection.ACTIONS,
-  'Refresh list of changes'
-);
-_describe(
-  Shortcut.TOGGLE_CHANGE_STAR,
-  ShortcutSection.ACTIONS,
-  'Star/unstar change'
-);
-_describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic');
-_describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.ACTIONS,
-  'Diff against base'
-);
-_describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff against latest patchset'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.ACTIONS,
-  'Diff base against left'
-);
-_describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff right against latest'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.ACTIONS,
-  'Diff base against latest'
-);
-
-_describe(Shortcut.NEXT_LINE, ShortcutSection.DIFFS, 'Go to next line');
-_describe(Shortcut.PREV_LINE, ShortcutSection.DIFFS, 'Go to previous line');
-_describe(
-  Shortcut.DIFF_AGAINST_BASE,
-  ShortcutSection.DIFFS,
-  'Diff against base'
-);
-_describe(
-  Shortcut.DIFF_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff against latest patchset'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LEFT,
-  ShortcutSection.DIFFS,
-  'Diff base against left'
-);
-_describe(
-  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff right against latest'
-);
-_describe(
-  Shortcut.DIFF_BASE_AGAINST_LATEST,
-  ShortcutSection.DIFFS,
-  'Diff base against latest'
-);
-_describe(
-  Shortcut.VISIBLE_LINE,
-  ShortcutSection.DIFFS,
-  'Move cursor to currently visible code'
-);
-_describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk');
-_describe(
-  Shortcut.PREV_CHUNK,
-  ShortcutSection.DIFFS,
-  'Go to previous diff chunk'
-);
-_describe(
-  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
-  ShortcutSection.DIFFS,
-  'Toggle all diff context'
-);
-_describe(
-  Shortcut.NEXT_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to next comment thread'
-);
-_describe(
-  Shortcut.PREV_COMMENT_THREAD,
-  ShortcutSection.DIFFS,
-  'Go to previous comment thread'
-);
-_describe(
-  Shortcut.EXPAND_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Expand all comment threads'
-);
-_describe(
-  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Collapse all comment threads'
-);
-_describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.DIFFS,
-  'Hide/Display all comment threads'
-);
-_describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane');
-_describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane');
-_describe(
-  Shortcut.TOGGLE_LEFT_PANE,
-  ShortcutSection.DIFFS,
-  'Hide/show left diff'
-);
-_describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment');
-_describe(Shortcut.SAVE_COMMENT, ShortcutSection.DIFFS, 'Save comment');
-_describe(
-  Shortcut.OPEN_DIFF_PREFS,
-  ShortcutSection.DIFFS,
-  'Show diff preferences'
-);
-_describe(
-  Shortcut.TOGGLE_DIFF_REVIEWED,
-  ShortcutSection.DIFFS,
-  'Mark/unmark file as reviewed'
-);
-_describe(
-  Shortcut.TOGGLE_DIFF_MODE,
-  ShortcutSection.DIFFS,
-  'Toggle unified/side-by-side diff'
-);
-_describe(
-  Shortcut.NEXT_UNREVIEWED_FILE,
-  ShortcutSection.DIFFS,
-  'Mark file as reviewed and go to next unreviewed file'
-);
-_describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame');
-
-_describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file');
-_describe(
-  Shortcut.PREV_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file'
-);
-_describe(
-  Shortcut.NEXT_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to next file that has comments'
-);
-_describe(
-  Shortcut.PREV_FILE_WITH_COMMENTS,
-  ShortcutSection.NAVIGATION,
-  'Go to previous file that has comments'
-);
-_describe(
-  Shortcut.OPEN_FIRST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to first file'
-);
-_describe(
-  Shortcut.OPEN_LAST_FILE,
-  ShortcutSection.NAVIGATION,
-  'Go to last file'
-);
-_describe(
-  Shortcut.UP_TO_DASHBOARD,
-  ShortcutSection.NAVIGATION,
-  'Up to dashboard'
-);
-_describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change');
-
-_describe(
-  Shortcut.CURSOR_NEXT_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select next file'
-);
-_describe(
-  Shortcut.CURSOR_PREV_FILE,
-  ShortcutSection.FILE_LIST,
-  'Select previous file'
-);
-_describe(Shortcut.OPEN_FILE, ShortcutSection.FILE_LIST, 'Go to selected file');
-_describe(
-  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
-  ShortcutSection.FILE_LIST,
-  'Show/hide all inline diffs'
-);
-_describe(
-  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
-  ShortcutSection.FILE_LIST,
-  'Hide/Display all comment threads'
-);
-_describe(
-  Shortcut.TOGGLE_INLINE_DIFF,
-  ShortcutSection.FILE_LIST,
-  'Show/hide selected inline diff'
-);
-
-_describe(Shortcut.SEND_REPLY, ShortcutSection.REPLY_DIALOG, 'Send reply');
-_describe(
-  Shortcut.EMOJI_DROPDOWN,
-  ShortcutSection.REPLY_DIALOG,
-  'Emoji dropdown'
-);
-
-// Must be declared outside behavior implementation to be accessed inside
-// behavior functions.
-
-function getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent {
-  const event = dom(e.detail ? e.detail.keyboardEvent : e);
-  // TODO(TS): worth checking if this still holds or not, if no, remove this.
-  // When e is a keyboardEvent, e.event is not null.
-  if ('event' in event && (event as CustomKeyboardEvent).event) {
-    return (event as CustomKeyboardEvent).event;
-  }
-  return event as CustomKeyboardEvent;
-}
-
-/**
- * Shortcut manager, holds all hosts, bindings and listeners.
- */
-export class ShortcutManager {
-  private readonly activeHosts = new Map<PolymerElement, Map<string, string>>();
-
-  private readonly bindings = new Map<Shortcut, string[]>();
-
-  public _testOnly_getBindings() {
-    return this.bindings;
-  }
-
-  public _testOnly_isEmpty() {
-    return this.activeHosts.size === 0 && this.listeners.size === 0;
-  }
-
-  private readonly listeners = new Set<ShortcutListener>();
-
-  bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
-    this.bindings.set(shortcut, bindings);
-  }
-
-  getBindingsForShortcut(shortcut: Shortcut) {
-    return this.bindings.get(shortcut);
-  }
-
-  attachHost(host: PolymerElement | ShortcutEnabledElement) {
-    if (!('keyboardShortcuts' in host)) {
-      return;
-    }
-    const shortcuts = host.keyboardShortcuts();
-    this.activeHosts.set(host, new Map(Object.entries(shortcuts)));
-    this.notifyListeners();
-    return shortcuts;
-  }
-
-  detachHost(host: PolymerElement) {
-    if (this.activeHosts.delete(host)) {
-      this.notifyListeners();
-      return true;
-    }
-    return false;
-  }
-
-  addListener(listener: ShortcutListener) {
-    this.listeners.add(listener);
-    listener(this.directoryView());
-  }
-
-  removeListener(listener: ShortcutListener) {
-    return this.listeners.delete(listener);
-  }
-
-  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
-    const bindings = _help.get(section);
-    let desc = '';
-    if (bindings) {
-      const binding = bindings.find(
-        binding => binding.shortcut === shortcutName
-      );
-      desc = binding ? binding.text : '';
-    }
-    return desc;
-  }
-
-  getShortcut(shortcutName: Shortcut) {
-    const bindings = this.bindings.get(shortcutName);
-    return bindings
-      ? bindings
-          .map(binding => this.describeBinding(binding).join('+'))
-          .join(',')
-      : '';
-  }
-
-  activeShortcutsBySection() {
-    const activeShortcuts = new Set<string>();
-    this.activeHosts.forEach(shortcuts => {
-      shortcuts.forEach((_, shortcut) => activeShortcuts.add(shortcut));
-    });
-
-    const activeShortcutsBySection = new Map<
-      ShortcutSection,
-      ShortcutHelpItem[]
-    >();
-    _help.forEach((shortcutList, section) => {
-      shortcutList.forEach(shortcutHelp => {
-        if (activeShortcuts.has(shortcutHelp.shortcut)) {
-          if (!activeShortcutsBySection.has(section)) {
-            activeShortcutsBySection.set(section, []);
-          }
-          // From previous condition, the `get(section)`
-          // should always return a valid result
-          activeShortcutsBySection.get(section)!.push(shortcutHelp);
-        }
-      });
-    });
-    return activeShortcutsBySection;
-  }
-
-  directoryView() {
-    const view = new Map<ShortcutSection, SectionView>();
-    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
-      const sectionView: Array<{binding: string[][]; text: string}> = [];
-      shortcutHelps.forEach(shortcutHelp => {
-        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
-        if (!bindingDesc) {
-          return;
-        }
-        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
-          sectionView.push({
-            binding: bindingDesc,
-            text: shortcutHelp.text,
-          });
-        });
-      });
-      view.set(section, sectionView);
-    });
-    return view;
-  }
-
-  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
-    if (
-      bindingDesc.length === 1 ||
-      this.comboSetDisplayWidth(bindingDesc) < 21
-    ) {
-      return [bindingDesc];
-    }
-    // Find the largest prefix of bindings that is under the
-    // size threshold.
-    const head = [bindingDesc[0]];
-    for (let i = 1; i < bindingDesc.length; i++) {
-      head.push(bindingDesc[i]);
-      if (this.comboSetDisplayWidth(head) >= 21) {
-        head.pop();
-        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
-      }
-    }
-    return [];
-  }
-
-  comboSetDisplayWidth(bindingDesc: string[][]) {
-    const bindingSizer = (binding: string[]) =>
-      binding.reduce((acc, key) => acc + key.length, 0);
-    // Width is the sum of strings + (n-1) * 2 to account for the word
-    // "or" joining them.
-    return (
-      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
-      2 * (bindingDesc.length - 1)
-    );
-  }
-
-  describeBindings(shortcut: Shortcut): string[][] | null {
-    const bindings = this.bindings.get(shortcut);
-    if (!bindings) {
-      return null;
-    }
-    // TODO(TS): should check base on length to differentiate two
-    // cases
-    if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['g'].concat(binding));
-    }
-    if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
-      return bindings
-        .slice(1)
-        .map(binding => this._describeKey(binding))
-        .map(binding => ['v'].concat(binding));
-    }
-
-    return bindings
-      .filter(binding => binding !== SPECIAL_SHORTCUT.DOC_ONLY)
-      .map(binding => this.describeBinding(binding));
-  }
-
-  _describeKey(key: string) {
-    switch (key) {
-      case 'shift':
-        return 'Shift';
-      case 'meta':
-        return 'Meta';
-      case 'ctrl':
-        return 'Ctrl';
-      case 'enter':
-        return 'Enter';
-      case 'up':
-        return '\u2191'; // ↑
-      case 'down':
-        return '\u2193'; // ↓
-      case 'left':
-        return '\u2190'; // ←
-      case 'right':
-        return '\u2192'; // →
-      default:
-        return key;
-    }
-  }
-
-  describeBinding(binding: string) {
-    // single key bindings
-    if (binding.length === 1) {
-      return [binding];
-    }
-    return binding
-      .split(':')[0]
-      .split('+')
-      .map(part => this._describeKey(part));
-  }
-
-  notifyListeners() {
-    const view = this.directoryView();
-    this.listeners.forEach(listener => listener(view));
-  }
-}
-
-const shortcutManager = new ShortcutManager();
-
-/**
- * Enum for supported modifiers.
- */
-export enum Modifier {
-  SHIFT_KEY = 'shiftKey',
-  CTRL_KEY = 'ctrlKey',
-  META_KEY = 'metaKey',
-  // Add when you need it
-}
-
-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;
-}
-/**
- * @polymer
- * @mixinFunction
- */
-const InternalKeyboardShortcutMixin = dedupingMixin(
-  <T extends Constructor<PolymerElement> & IronA11yKeysMixinConstructor>(
-    superClass: T
-  ): T & Constructor<KeyboardShortcutMixinInterface> => {
-    /**
-     * @polymer
-     * @mixinClass
-     */
-    class Mixin extends superClass {
-      @property({type: Number})
-      _shortcut_go_key_last_pressed: number | null = null;
-
-      @property({type: Number})
-      _shortcut_v_key_last_pressed: number | null = null;
-
-      @property({type: Object})
-      _shortcut_go_table: Map<string, string> = new Map<string, string>();
-
-      @property({type: Object})
-      _shortcut_v_table: Map<string, string> = new Map<string, string>();
-
-      Shortcut = Shortcut;
-
-      ShortcutSection = ShortcutSection;
-
-      modifierPressed(event: CustomKeyboardEvent) {
-        /* We are checking for g/v as modifiers pressed. There are cases such as
-         * pressing v and then /, where we want the handler for / to be triggered.
-         * TODO(dhruvsri): find a way to support that keyboard combination
-         */
-        const e = getKeyboardEvent(event);
-        return (
-          e.altKey ||
-          e.ctrlKey ||
-          e.metaKey ||
-          e.shiftKey ||
-          !!this._inGoKeyMode() ||
-          !!this.inVKeyMode()
-        );
-      }
-
-      isModifierPressed(e: CustomKeyboardEvent, modifier: Modifier) {
-        return getKeyboardEvent(e)[modifier];
-      }
-
-      shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent) {
-        const e = getKeyboardEvent(event);
-        // TODO(TS): maybe override the EventApi, narrow it down to Element always
-        const target = (dom(e) as EventApi).rootTarget as Element;
-        const tagName = target.tagName;
-        const type = target.getAttribute('type');
-        if (
-          // Suppress shortcuts on <input> and <textarea>, but not on
-          // checkboxes, because we want to enable workflows like 'click
-          // mark-reviewed and then press ] to go to the next file'.
-          (tagName === 'INPUT' && type !== 'checkbox') ||
-          tagName === 'TEXTAREA' ||
-          // Suppress shortcuts if the key is 'enter'
-          // and target is an anchor or button.
-          (e.keyCode === 13 && (tagName === 'A' || tagName === 'BUTTON'))
-        ) {
-          return true;
-        }
-        const path = e.composedPath();
-        for (let i = 0; path && i < path.length; i++) {
-          // TODO(TS): narrow this down to Element from EventTarget first
-          if ((path[i] as Element).tagName === 'GR-OVERLAY') {
-            return true;
-          }
-        }
-        const detail: ShortcutTriggeredEventDetail = {
-          event: e,
-          goKey: this._inGoKeyMode(),
-          vKey: this.inVKeyMode(),
-        };
-        this.dispatchEvent(
-          new CustomEvent('shortcut-triggered', {
-            detail,
-            composed: true,
-            bubbles: true,
-          })
-        );
-        return false;
-      }
-
-      // Alias for getKeyboardEvent.
-      getKeyboardEvent(e: CustomKeyboardEvent) {
-        return getKeyboardEvent(e);
-      }
-
-      // TODO(TS): maybe remove, no reference in the code base
-      getRootTarget(e: CustomKeyboardEvent) {
-        // TODO(TS): worth checking if we can limit this to EventApi only
-        // dom currently returns DomNativeApi|EventApi
-        return (dom(getKeyboardEvent(e)) as EventApi).rootTarget;
-      }
-
-      bindShortcut(shortcut: Shortcut, ...bindings: string[]) {
-        shortcutManager.bindShortcut(shortcut, ...bindings);
-      }
-
-      createTitle(shortcutName: Shortcut, section: ShortcutSection) {
-        const desc = shortcutManager.getDescription(section, shortcutName);
-        const shortcut = shortcutManager.getShortcut(shortcutName);
-        return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
-      }
-
-      _throttleWrap(fn: (e: Event) => void) {
-        let lastCall: number | undefined;
-        return (e: Event) => {
-          if (
-            lastCall !== undefined &&
-            Date.now() - lastCall < THROTTLE_INTERVAL_MS
-          ) {
-            return;
-          }
-          lastCall = Date.now();
-          fn(e);
-        };
-      }
-
-      _addOwnKeyBindings(shortcut: Shortcut, handler: string) {
-        const bindings = shortcutManager.getBindingsForShortcut(shortcut);
-        if (!bindings) {
-          return;
-        }
-        if (bindings[0] === SPECIAL_SHORTCUT.DOC_ONLY) {
-          return;
-        }
-        if (bindings[0] === SPECIAL_SHORTCUT.GO_KEY) {
-          bindings
-            .slice(1)
-            .forEach(binding => this._shortcut_go_table.set(binding, handler));
-        } else if (bindings[0] === SPECIAL_SHORTCUT.V_KEY) {
-          // for each binding added with the go/v key, we set the handler to be
-          // handleVKeyAction. handleVKeyAction then looks up in th
-          // shortcut_table to see what the relevant handler should be
-          bindings
-            .slice(1)
-            .forEach(binding => this._shortcut_v_table.set(binding, handler));
-        } else {
-          this.addOwnKeyBinding(bindings.join(' '), handler);
-        }
-      }
-
-      /** @override */
-      connectedCallback() {
-        super.connectedCallback();
-        const shortcuts = shortcutManager.attachHost(this);
-        if (!shortcuts) {
-          return;
-        }
-
-        for (const key of Object.keys(shortcuts)) {
-          // TODO(TS): not needed if convert shortcuts to Map
-          this._addOwnKeyBindings(key as Shortcut, shortcuts[key]);
-        }
-
-        // each component that uses this behaviour must be aware if go key is
-        // pressed or not, since it needs to check it as a modifier
-        this.addOwnKeyBinding('g:keydown', '_handleGoKeyDown');
-        this.addOwnKeyBinding('g:keyup', '_handleGoKeyUp');
-
-        // If any of the shortcuts utilized GO_KEY, then they are handled
-        // directly by this behavior.
-        if (this._shortcut_go_table.size > 0) {
-          this._shortcut_go_table.forEach((_, key) => {
-            this.addOwnKeyBinding(key, '_handleGoAction');
-          });
-        }
-
-        this.addOwnKeyBinding('v:keydown', '_handleVKeyDown');
-        this.addOwnKeyBinding('v:keyup', '_handleVKeyUp');
-        if (this._shortcut_v_table.size > 0) {
-          this._shortcut_v_table.forEach((_, key) => {
-            this.addOwnKeyBinding(key, '_handleVAction');
-          });
-        }
-      }
-
-      /** @override */
-      disconnectedCallback() {
-        if (shortcutManager.detachHost(this)) {
-          this.removeOwnKeyBindings();
-        }
-        super.disconnectedCallback();
-      }
-
-      keyboardShortcuts() {
-        return {};
-      }
-
-      addKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
-        shortcutManager.addListener(listener);
-      }
-
-      removeKeyboardShortcutDirectoryListener(listener: ShortcutListener) {
-        shortcutManager.removeListener(listener);
-      }
-
-      _handleVKeyDown(e: CustomKeyboardEvent) {
-        if (this.shouldSuppressKeyboardShortcut(e)) return;
-        this._shortcut_v_key_last_pressed = Date.now();
-      }
-
-      _handleVKeyUp() {
-        setTimeout(() => {
-          this._shortcut_v_key_last_pressed = null;
-        }, V_KEY_TIMEOUT_MS);
-      }
-
-      private inVKeyMode() {
-        return !!(
-          this._shortcut_v_key_last_pressed &&
-          Date.now() - this._shortcut_v_key_last_pressed <= V_KEY_TIMEOUT_MS
-        );
-      }
-
-      _handleVAction(e: CustomKeyboardEvent) {
-        if (
-          !this.inVKeyMode() ||
-          !this._shortcut_v_table.has(e.detail.key) ||
-          this.shouldSuppressKeyboardShortcut(e)
-        ) {
-          return;
-        }
-        e.preventDefault();
-        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);
-        }
-      }
-
-      _handleGoKeyDown(e: CustomKeyboardEvent) {
-        if (this.shouldSuppressKeyboardShortcut(e)) return;
-        this._shortcut_go_key_last_pressed = Date.now();
-      }
-
-      _handleGoKeyUp() {
-        // Set go_key_last_pressed to null `GO_KEY_TIMEOUT_MS` after keyup event
-        // so that users can trigger `g + i` by pressing g and i quickly.
-        setTimeout(() => {
-          this._shortcut_go_key_last_pressed = null;
-        }, GO_KEY_TIMEOUT_MS);
-      }
-
-      _inGoKeyMode() {
-        return !!(
-          this._shortcut_go_key_last_pressed &&
-          Date.now() - this._shortcut_go_key_last_pressed <= GO_KEY_TIMEOUT_MS
-        );
-      }
-
-      _handleGoAction(e: CustomKeyboardEvent) {
-        if (
-          !this._inGoKeyMode() ||
-          !this._shortcut_go_table.has(e.detail.key) ||
-          this.shouldSuppressKeyboardShortcut(e)
-        ) {
-          return;
-        }
-        e.preventDefault();
-        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);
-        }
-      }
-    }
-
-    return Mixin;
-  }
-);
+export {
+  Shortcut,
+  ShortcutSection,
+  SPECIAL_SHORTCUT,
+  ShortcutListener,
+  SectionView,
+};
 
-// The following doesn't work (IronA11yKeysBehavior crashes):
-// const KeyboardShortcutMixin = dedupingMixin(superClass => {
-//    class Mixin extends mixinBehaviors([IronA11yKeysBehavior], superClass) {
-//    ...
-//    }
-//    return Mixin;
-// }
-// This is a workaround
 export const KeyboardShortcutMixin = <T extends Constructor<PolymerElement>>(
   superClass: T
-): T & Constructor<KeyboardShortcutMixinInterface> =>
-  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
-  );
+) => {
+  /**
+   * @polymer
+   * @mixinClass
+   */
+  class Mixin extends superClass {
+    // This enables `Shortcut` to be used in the html template.
+    Shortcut = Shortcut;
+
+    // This enables `ShortcutSection` to be used in the html template.
+    ShortcutSection = ShortcutSection;
+
+    private readonly shortcuts = appContext.shortcutsService;
+
+    /** Used to disable shortcuts when the element is not visible. */
+    private observer?: IntersectionObserver;
+
+    /**
+     * Enabling shortcuts only when the element is visible (see `observer`
+     * above) is a great feature, but often what you want is for the *page* to
+     * be visible, not the specific child element that registers keyboard
+     * shortcuts. An example is the FileList in the ChangeView. So we allow
+     * a broader observer target to be specified here, and fall back to
+     * `this` as the default.
+     */
+    @property({type: Object})
+    observerTarget: Element = this;
+
+    /** Are shortcuts currently enabled? True only when element is visible. */
+    private bindingsEnabled = false;
+
+    override connectedCallback() {
+      super.connectedCallback();
+      this.createVisibilityObserver();
+      this.enableBindings();
+    }
+
+    override disconnectedCallback() {
+      this.destroyVisibilityObserver();
+      this.disableBindings();
+      super.disconnectedCallback();
+    }
+
+    /**
+     * Creates an intersection observer that enables bindings when the
+     * element is visible and disables them when the element is hidden.
+     */
+    private createVisibilityObserver() {
+      if (!this.hasKeyboardShortcuts()) return;
+      if (this.observer) return;
+      this.observer = new IntersectionObserver(entries => {
+        check(entries.length === 1, 'Expected one observer entry.');
+        const isVisible = entries[0].isIntersecting;
+        if (isVisible) {
+          this.enableBindings();
+        } else {
+          this.disableBindings();
+        }
+      });
+      this.observer.observe(this.observerTarget);
+    }
+
+    private destroyVisibilityObserver() {
+      if (this.observer) this.observer.unobserve(this.observerTarget);
+    }
+
+    /**
+     * Enables all the shortcuts returned by keyboardShortcuts().
+     * This is a private method being called when the element becomes
+     * connected or visible.
+     */
+    private enableBindings() {
+      if (!this.hasKeyboardShortcuts()) return;
+      if (this.bindingsEnabled) return;
+      this.bindingsEnabled = true;
+
+      this.shortcuts.attachHost(this, this.keyboardShortcuts());
+    }
+
+    /**
+     * Disables all the shortcuts returned by keyboardShortcuts().
+     * This is a private method being called when the element becomes
+     * disconnected or invisible.
+     */
+    private disableBindings() {
+      if (!this.bindingsEnabled) return;
+      this.bindingsEnabled = false;
+      this.shortcuts.detachHost(this);
+    }
+
+    private hasKeyboardShortcuts() {
+      return this.keyboardShortcuts().length > 0;
+    }
+
+    keyboardShortcuts(): ShortcutListener[] {
+      return [];
+    }
+  }
+
+  return Mixin as T & Constructor<KeyboardShortcutMixinInterface>;
+};
 
 /** The interface corresponding to KeyboardShortcutMixin */
 export interface KeyboardShortcutMixinInterface {
-  Shortcut: typeof Shortcut;
-  ShortcutSection: typeof ShortcutSection;
-  _shortcut_go_key_last_pressed: number | null;
-  _shortcut_v_key_last_pressed: number | null;
-  _shortcut_go_table: Map<string, string>;
-  _shortcut_v_table: Map<string, string>;
-  keyboardShortcuts(): {[key: string]: string | null};
-  createTitle(name: Shortcut, section: ShortcutSection): string;
-  bindShortcut(shortcut: Shortcut, ...bindings: string[]): void;
-  shouldSuppressKeyboardShortcut(event: CustomKeyboardEvent): boolean;
-  modifierPressed(event: CustomKeyboardEvent): boolean;
-  isModifierPressed(event: CustomKeyboardEvent, modifier: Modifier): boolean;
-  getKeyboardEvent(e: CustomKeyboardEvent): CustomKeyboardEvent;
-  addKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
-  removeKeyboardShortcutDirectoryListener(listener: ShortcutListener): void;
-  // TODO(TS): Remove underscore. Apparently not a private method.
-  _throttleWrap(eventListener: EventListener): EventListener;
-}
-
-export function _testOnly_getShortcutManagerInstance() {
-  return shortcutManager;
+  keyboardShortcuts(): ShortcutListener[];
 }
diff --git a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js b/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
deleted file mode 100644
index 180dbe7..0000000
--- a/polygerrit-ui/app/mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin_test.js
+++ /dev/null
@@ -1,452 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../../test/common-test-setup-karma.js';
-import {
-  KeyboardShortcutMixin, Shortcut,
-  ShortcutManager, ShortcutSection, SPECIAL_SHORTCUT,
-} from './keyboard-shortcut-mixin.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-
-const basicFixture =
-    fixtureFromElement('keyboard-shortcut-mixin-test-element');
-
-const withinOverlayFixture = fixtureFromTemplate(html`
-<gr-overlay>
-  <keyboard-shortcut-mixin-test-element>
-  </keyboard-shortcut-mixin-test-element>
-</gr-overlay>
-`);
-
-class GrKeyboardShortcutMixinTestElement extends
-  KeyboardShortcutMixin(PolymerElement) {
-  static get is() {
-    return 'keyboard-shortcut-mixin-test-element';
-  }
-
-  get keyBindings() {
-    return {
-      k: '_handleKey',
-      enter: '_handleKey',
-    };
-  }
-
-  _handleKey() {}
-}
-
-customElements.define(GrKeyboardShortcutMixinTestElement.is,
-    GrKeyboardShortcutMixinTestElement);
-
-suite('keyboard-shortcut-mixin tests', () => {
-  let element;
-  let overlay;
-
-  setup(() => {
-    element = basicFixture.instantiate();
-    overlay = withinOverlayFixture.instantiate();
-  });
-
-  suite('ShortcutManager', () => {
-    test('bindings management', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.deepEqual(
-          mgr.getBindingsForShortcut(NEXT_FILE),
-          [']', '}', 'right']);
-    });
-
-    test('getShortcut', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, ']', '}', 'right');
-      assert.equal(mgr.getShortcut(NEXT_FILE), '],},→');
-    });
-
-    test('getShortcut with modifiers', () => {
-      const mgr = new ShortcutManager();
-      const NEXT_FILE = Shortcut.NEXT_FILE;
-
-      assert.isUndefined(mgr.getBindingsForShortcut(NEXT_FILE));
-      mgr.bindShortcut(NEXT_FILE, 'Shift+a:key');
-      assert.equal(mgr.getShortcut(NEXT_FILE), 'Shift+a');
-    });
-
-    suite('binding descriptions', () => {
-      function mapToObject(m) {
-        const o = {};
-        m.forEach((v, k) => o[k] = v);
-        return o;
-      }
-
-      test('single combo description', () => {
-        const mgr = new ShortcutManager();
-        assert.deepEqual(mgr.describeBinding('a'), ['a']);
-        assert.deepEqual(mgr.describeBinding('a:keyup'), ['a']);
-        assert.deepEqual(mgr.describeBinding('ctrl+a'), ['Ctrl', 'a']);
-        assert.deepEqual(
-            mgr.describeBinding('ctrl+shift+up:keyup'),
-            ['Ctrl', 'Shift', '↑']);
-      });
-
-      test('combo set description', () => {
-        const mgr = new ShortcutManager();
-        assert.isNull(mgr.describeBindings(Shortcut.NEXT_FILE));
-
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
-            SPECIAL_SHORTCUT.GO_KEY, 'o');
-        assert.deepEqual(
-            mgr.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
-            [['g', 'o']]);
-
-        mgr.bindShortcut(Shortcut.NEXT_FILE, SPECIAL_SHORTCUT.DOC_ONLY,
-            ']', 'ctrl+shift+right:keyup');
-        assert.deepEqual(
-            mgr.describeBindings(Shortcut.NEXT_FILE),
-            [[']'], ['Ctrl', 'Shift', '→']]);
-
-        mgr.bindShortcut(Shortcut.PREV_FILE, '[');
-        assert.deepEqual(mgr.describeBindings(Shortcut.PREV_FILE), [['[']]);
-      });
-
-      test('combo set description width', () => {
-        const mgr = new ShortcutManager();
-        assert.strictEqual(mgr.comboSetDisplayWidth([['u']]), 1);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['g', 'o']]), 2);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['Shift', 'r']]), 6);
-        assert.strictEqual(mgr.comboSetDisplayWidth([['x'], ['y']]), 4);
-        assert.strictEqual(
-            mgr.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
-            12);
-      });
-
-      test('distribute shortcut help', () => {
-        const mgr = new ShortcutManager();
-        assert.deepEqual(mgr.distributeBindingDesc([['o']]), [[['o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['g', 'o']]),
-            [[['g', 'o']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
-            [[['ctrl', 'shift', 'meta', 'enter']]]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'shift', 'meta', 'enter'],
-              ['o'],
-            ]),
-            [
-              [['ctrl', 'shift', 'meta', 'enter']],
-              [['o']],
-            ]);
-        assert.deepEqual(
-            mgr.distributeBindingDesc([
-              ['ctrl', 'enter'],
-              ['meta', 'enter'],
-              ['ctrl', 's'],
-              ['meta', 's'],
-            ]),
-            [
-              [['ctrl', 'enter'], ['meta', 'enter']],
-              [['ctrl', 's'], ['meta', 's']],
-            ]);
-      });
-
-      test('active shortcuts by section', () => {
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
-        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES, 'g+o');
-        mgr.bindShortcut(Shortcut.SEARCH, '/');
-
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {});
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.NEXT_FILE]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.NEXT_LINE]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.SEARCH]: null,
-              [Shortcut.GO_TO_OPENED_CHANGES]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.activeShortcutsBySection()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {shortcut: Shortcut.NEXT_LINE, text: 'Go to next line'},
-              ],
-              [ShortcutSection.EVERYWHERE]: [
-                {shortcut: Shortcut.SEARCH, text: 'Search'},
-                {
-                  shortcut: Shortcut.GO_TO_OPENED_CHANGES,
-                  text: 'Go to Opened Changes',
-                },
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {shortcut: Shortcut.NEXT_FILE, text: 'Go to next file'},
-              ],
-            });
-      });
-
-      test('directory view', () => {
-        const mgr = new ShortcutManager();
-        mgr.bindShortcut(Shortcut.NEXT_FILE, ']');
-        mgr.bindShortcut(Shortcut.NEXT_LINE, 'j');
-        mgr.bindShortcut(Shortcut.GO_TO_OPENED_CHANGES,
-            SPECIAL_SHORTCUT.GO_KEY, 'o');
-        mgr.bindShortcut(Shortcut.SEARCH, '/');
-        mgr.bindShortcut(
-            Shortcut.SAVE_COMMENT, 'ctrl+enter', 'meta+enter',
-            'ctrl+s', 'meta+s');
-
-        assert.deepEqual(mapToObject(mgr.directoryView()), {});
-
-        mgr.attachHost({
-          keyboardShortcuts() {
-            return {
-              [Shortcut.GO_TO_OPENED_CHANGES]: null,
-              [Shortcut.NEXT_FILE]: null,
-              [Shortcut.NEXT_LINE]: null,
-              [Shortcut.SAVE_COMMENT]: null,
-              [Shortcut.SEARCH]: null,
-            };
-          },
-        });
-        assert.deepEqual(
-            mapToObject(mgr.directoryView()),
-            {
-              [ShortcutSection.DIFFS]: [
-                {binding: [['j']], text: 'Go to next line'},
-                {
-                  binding: [['Ctrl', 'Enter'], ['Meta', 'Enter']],
-                  text: 'Save comment',
-                },
-                {
-                  binding: [['Ctrl', 's'], ['Meta', 's']],
-                  text: 'Save comment',
-                },
-              ],
-              [ShortcutSection.EVERYWHERE]: [
-                {binding: [['/']], text: 'Search'},
-                {binding: [['g', 'o']], text: 'Go to Opened Changes'},
-              ],
-              [ShortcutSection.NAVIGATION]: [
-                {binding: [[']']], text: 'Go to next file'},
-              ],
-            });
-      });
-    });
-  });
-
-  test('doesn’t block kb shortcuts for non-allowed els', done => {
-    const divEl = document.createElement('div');
-    element.appendChild(divEl);
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-  });
-
-  test('blocks kb shortcuts for input els', done => {
-    const inputEl = document.createElement('input');
-    element.appendChild(inputEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-  });
-
-  test('doesn’t block kb shortcuts for checkboxes', done => {
-    const inputEl = document.createElement('input');
-    inputEl.setAttribute('type', 'checkbox');
-    element.appendChild(inputEl);
-    element._handleKey = e => {
-      assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(inputEl, 75, null, 'k');
-  });
-
-  test('blocks kb shortcuts for textarea els', done => {
-    const textareaEl = document.createElement('textarea');
-    element.appendChild(textareaEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
-  });
-
-  test('blocks kb shortcuts for anything in a gr-overlay', done => {
-    const divEl = document.createElement('div');
-    const element =
-        overlay.querySelector('keyboard-shortcut-mixin-test-element');
-    element.appendChild(divEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(divEl, 75, null, 'k');
-  });
-
-  test('blocks enter shortcut on an anchor', done => {
-    const anchorEl = document.createElement('a');
-    const element =
-        overlay.querySelector('keyboard-shortcut-mixin-test-element');
-    element.appendChild(anchorEl);
-    element._handleKey = e => {
-      assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
-      done();
-    };
-    MockInteractions.keyDownOn(anchorEl, 13, null, 'enter');
-  });
-
-  test('modifierPressed returns accurate values', () => {
-    const spy = sinon.spy(element, 'modifierPressed');
-    element._handleKey = e => {
-      element.modifierPressed(e);
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-  });
-
-  test('isModifierPressed returns accurate value', () => {
-    const spy = sinon.spy(element, 'isModifierPressed');
-    element._handleKey = e => {
-      element.isModifierPressed(e, 'shiftKey');
-    };
-    MockInteractions.keyDownOn(element, 75, 'shift', 'k');
-    assert.isTrue(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'meta', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, null, 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-    MockInteractions.keyDownOn(element, 75, 'alt', 'k');
-    assert.isFalse(spy.lastCall.returnValue);
-  });
-
-  suite('GO_KEY timing', () => {
-    let handlerStub;
-
-    setup(() => {
-      element._shortcut_go_table.set('a', '_handleA');
-      handlerStub = element._handleA = sinon.stub();
-      sinon.stub(Date, 'now').returns(10000);
-    });
-
-    test('success', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isTrue(handlerStub.calledOnce);
-      assert.strictEqual(handlerStub.lastCall.args[0], e);
-    });
-
-    test('go key not pressed', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = null;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('go key pressed too long ago', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 3000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('should suppress', () => {
-      const e = {detail: {key: 'a'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(true);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-
-    test('unrecognized key', () => {
-      const e = {detail: {key: 'f'}, preventDefault: () => {}};
-      sinon.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-      element._shortcut_go_key_last_pressed = 9000;
-      element._handleGoAction(e);
-      assert.isFalse(handlerStub.called);
-    });
-  });
-});
-
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses.ts b/polygerrit-ui/app/node_modules_licenses/licenses.ts
index 8dcb80e..ede84ff 100644
--- a/polygerrit-ui/app/node_modules_licenses/licenses.ts
+++ b/polygerrit-ui/app/node_modules_licenses/licenses.ts
@@ -45,6 +45,12 @@
 /** List of licenses texts. Add the licenses here if there is no text file with license
  * in package. For details - see comments for {@link LicenseInfo} and {@link PackageInfo} */
 class SharedLicenses {
+  public static Lit: LicenseInfo = {
+    name: "Lit",
+    type: LicenseTypes.Bsd3,
+    sharedLicenseFile: "lit.txt",
+  };
+
   public static Polymer2014: LicenseInfo = {
     name: "Polymer-2014",
     type: LicenseTypes.Bsd3,
@@ -97,6 +103,10 @@
 
 const packages: PackageInfo[] = [
   {
+    name: "@lit/reactive-element",
+    license: SharedLicenses.Lit,
+  },
+  {
     name: "@polymer/decorators",
     license: SharedLicenses.Polymer2017,
   },
@@ -240,6 +250,10 @@
     license: SharedLicenses.Polymer2015
   },
   {
+    name: "@polymer/paper-fab",
+    license: SharedLicenses.Polymer2015
+  },
+  {
     name: "@polymer/paper-icon-button",
     license: SharedLicenses.Polymer2015
   },
@@ -284,6 +298,14 @@
     license: SharedLicenses.Polymer2017
   },
   {
+    name: "@types/resemblejs",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@types/resize-observer-browser",
     license: {
       name: 'DefinitelyTyped',
@@ -292,6 +314,14 @@
     }
   },
   {
+    name: "@types/trusted-types",
+    license: {
+      name: 'DefinitelyTyped',
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE"
+    }
+  },
+  {
     name: "@webcomponents/shadycss",
     license: SharedLicenses.Polymer2017
   },
@@ -308,6 +338,14 @@
     }
   },
   {
+    name: "codemirror-minified",
+    license: {
+      name: "codemirror-minified",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  },
+  {
     name: "isarray",
     license: SharedLicenses.IsArray
   },
@@ -353,20 +391,16 @@
       "src/operators", "src/testing", "src/webSocket"],
   },
   {
+    name: "lit",
+    license: SharedLicenses.Lit,
+  },
+  {
     name: "lit-element",
-    license: {
-      name: "lit-element",
-      type: LicenseTypes.Bsd3,
-      packageLicenseFile: "LICENSE"
-    },
+    license: SharedLicenses.Lit,
   },
   {
     name: "lit-html",
-    license: {
-      name: "lit-html",
-      type: LicenseTypes.Bsd3,
-      packageLicenseFile: "LICENSE"
-    },
+    license: SharedLicenses.Lit,
   },
   {
     name: "tslib",
@@ -377,6 +411,22 @@
     },
     nonPackages: ["modules", "test/validateModuleExportsMatchCommonJS"],
   },
+  {
+    name: "resemblejs",
+    license: {
+      name: "resemblejs",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  },
+  {
+    name: "immer",
+    license: {
+      name: "immer",
+      type: LicenseTypes.Mit,
+      packageLicenseFile: "LICENSE",
+    }
+  }
 ];
 
 export default packages;
diff --git a/polygerrit-ui/app/node_modules_licenses/licenses/lit.txt b/polygerrit-ui/app/node_modules_licenses/licenses/lit.txt
new file mode 100644
index 0000000..c8ed226
--- /dev/null
+++ b/polygerrit-ui/app/node_modules_licenses/licenses/lit.txt
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) 2017 Google LLC. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+   list of conditions and the following disclaimer.
+
+2. 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.
+
+3. 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.
diff --git a/polygerrit-ui/app/node_modules_licenses/tsconfig.json b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
index c562a0c..f0a540b 100644
--- a/polygerrit-ui/app/node_modules_licenses/tsconfig.json
+++ b/polygerrit-ui/app/node_modules_licenses/tsconfig.json
@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
-    "target": "es6",
-    "module": "commonjs",
+    "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "strict": true,
diff --git a/polygerrit-ui/app/package.json b/polygerrit-ui/app/package.json
index fa59260..281a1eb 100644
--- a/polygerrit-ui/app/package.json
+++ b/polygerrit-ui/app/package.json
@@ -6,7 +6,6 @@
     "@polymer/decorators": "^3.0.0",
     "@polymer/font-roboto-local": "^3.0.2",
     "@polymer/iron-a11y-announcer": "^3.1.0",
-    "@polymer/iron-a11y-keys-behavior": "^3.0.1",
     "@polymer/iron-autogrow-textarea": "^3.0.3",
     "@polymer/iron-dropdown": "^3.0.1",
     "@polymer/iron-fit-behavior": "^3.1.0",
@@ -22,6 +21,7 @@
     "@polymer/paper-dialog-behavior": "^3.0.1",
     "@polymer/paper-dialog-scrollable": "^3.0.1",
     "@polymer/paper-dropdown-menu": "^3.2.0",
+    "@polymer/paper-fab": "^3.0.1",
     "@polymer/paper-input": "^3.2.1",
     "@polymer/paper-item": "^3.0.1",
     "@polymer/paper-listbox": "^3.0.1",
@@ -30,14 +30,18 @@
     "@polymer/paper-toggle-button": "^3.0.1",
     "@polymer/paper-tooltip": "^3.0.1",
     "@polymer/polymer": "^3.4.1",
+    "@types/resemblejs": "^3.2.0",
     "@types/resize-observer-browser": "^0.1.5",
     "@webcomponents/shadycss": "^1.10.2",
     "@webcomponents/webcomponentsjs": "^1.3.3",
-    "ba-linkify": "file:../../lib/ba-linkify/src/",
-    "lit-element": "^2.4.0",
+    "ba-linkify": "^1.0.1",
+    "codemirror-minified": "^5.62.2",
+    "immer": "^9.0.5",
+    "lit": "2.0.2",
     "page": "^1.11.6",
     "polymer-bridges": "file:../../polymer-bridges/",
     "polymer-resin": "^2.0.1",
+    "resemblejs": "^4.0.0",
     "rxjs": "^6.6.7"
   },
   "license": "Apache-2.0",
diff --git a/polygerrit-ui/app/polymer.json b/polygerrit-ui/app/polymer.json
index 02ffd26..4348ba8 100644
--- a/polygerrit-ui/app/polymer.json
+++ b/polygerrit-ui/app/polymer.json
@@ -9,10 +9,13 @@
   ],
   "lint": {
     "rules": ["polymer-3"],
-    "ignoreWarnings": ["deprecated-dom-call"],
+    "ignoreWarnings": [
+      "deprecated-dom-call",
+      "multiple-global-declarations"
+    ],
     "filesToIgnore": [
-        "**/gr-plugin-rest-api.js",
-        "**/.cache/**/gr-plugin-rest-api.js"
+      "**/gr-plugin-rest-api.js",
+      "**/.cache/**/gr-plugin-rest-api.js"
     ]
   }
 }
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
index feb1a82..401c0c3 100644
--- a/polygerrit-ui/app/rules.bzl
+++ b/polygerrit-ui/app/rules.bzl
@@ -1,87 +1,7 @@
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 
-def _get_ts_compiled_path(outdir, file_name):
-    """Calculates the typescript output path for a file_name.
-
-    Args:
-      outdir: the typescript output directory (relative to polygerrit-ui/app/)
-      file_name: the original file name (relative to polygerrit-ui/app/)
-
-    Returns:
-      String - the path to the file produced by the typescript compiler
-    """
-    if file_name.endswith(".js"):
-        return outdir + "/" + file_name
-    if file_name.endswith(".ts"):
-        return outdir + "/" + file_name[:-2] + "js"
-    fail("The file " + file_name + " has unsupported extension")
-
-def _get_ts_output_files(outdir, srcs):
-    """Calculates the files paths produced by the typescript compiler
-
-    Args:
-      outdir: the typescript output directory (relative to polygerrit-ui/app/)
-      srcs: list of input files (all paths relative to polygerrit-ui/app/)
-
-    Returns:
-      List of strings
-    """
-    result = []
-    for f in srcs:
-        if f.endswith(".d.ts"):
-            continue
-        result.append(_get_ts_compiled_path(outdir, f))
-    return result
-
-def compile_ts(name, srcs, ts_outdir, include_tests = False):
-    """Compiles srcs files with the typescript compiler
-
-    Args:
-      name: rule name
-      srcs: list of input files (.js, .d.ts and .ts)
-      ts_outdir: typescript output directory
-
-    Returns:
-      The list of compiled files
-    """
-    ts_rule_name = name + "_ts_compiled"
-
-    # List of files produced by the typescript compiler
-    generated_js = _get_ts_output_files(ts_outdir, srcs)
-
-    all_srcs = srcs + [
-        ":tsconfig.json",
-        ":tsconfig_bazel.json",
-        "@ui_npm//:node_modules",
-    ]
-    ts_project = "tsconfig_bazel.json"
-
-    if include_tests:
-        all_srcs = all_srcs + [
-            ":tsconfig_bazel_test.json",
-            "@ui_dev_npm//:node_modules",
-        ]
-        ts_project = "tsconfig_bazel_test.json"
-
-    # Run the compiler
-    native.genrule(
-        name = ts_rule_name,
-        srcs = all_srcs,
-        outs = generated_js,
-        cmd = " && ".join([
-            "$(location //tools/node_tools:tsc-bin) --project $(location :" +
-            ts_project +
-            ") --outdir $(RULEDIR)/" +
-            ts_outdir +
-            " --baseUrl ./external/ui_npm/node_modules/",
-        ]),
-        tools = ["//tools/node_tools:tsc-bin"],
-    )
-
-    return generated_js
-
-def polygerrit_bundle(name, srcs, outs, entry_point):
+def polygerrit_bundle(name, srcs, outs, entry_point, app_name):
     """Build .zip bundle from source code
 
     Args:
@@ -89,10 +9,10 @@
         srcs: source files
         outs: array with a single item - the output file name
         entry_point: application js entry-point
+        app_name: defines the application name. Bundled js code is added to .zip
+          archive with this name.
     """
 
-    app_name = entry_point.split(".js")[0].split("/").pop()  # eg: gr-app
-
     native.filegroup(
         name = app_name + "-full-src",
         srcs = srcs + [
@@ -142,19 +62,23 @@
             "//lib/fonts:robotofonts",
             "//lib/js:highlightjs__files",
             "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js",
+            "@ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js.map",
+            "@ui_npm//:node_modules/resemblejs/resemble.js",
             "@ui_npm//@polymer/font-roboto-local",
             "@ui_npm//:node_modules/@polymer/font-roboto-local/package.json",
         ],
         outs = outs,
         cmd = " && ".join([
             "FONT_DIR=$$(dirname $(location @ui_npm//:node_modules/@polymer/font-roboto-local/package.json))/fonts",
-            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs},elements}",
+            "mkdir -p $$TMP/polygerrit_ui/{styles/themes,fonts/{roboto,robotomono},bower_components/{highlightjs,webcomponentsjs,resemblejs},elements}",
             "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + app_name + ".$$ext; done",
             "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
             "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
             "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
             "for f in $(locations //lib/js:highlightjs__files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
             "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js",
+            "cp $(location @ui_npm//:node_modules/@webcomponents/webcomponentsjs/webcomponents-lite.js.map) $$TMP/polygerrit_ui/bower_components/webcomponentsjs/webcomponents-lite.js.map",
+            "cp $(location @ui_npm//:node_modules/resemblejs/resemble.js) $$TMP/polygerrit_ui/bower_components/resemblejs/resemble.js",
             "cp $$FONT_DIR/roboto/*.ttf $$TMP/polygerrit_ui/fonts/roboto/",
             "cp $$FONT_DIR/robotomono/*.ttf $$TMP/polygerrit_ui/fonts/robotomono/",
             "cd $$TMP",
diff --git a/polygerrit-ui/app/samples/repo-command.js b/polygerrit-ui/app/samples/repo-command.js
index acecd7d..6abb120 100644
--- a/polygerrit-ui/app/samples/repo-command.js
+++ b/polygerrit-ui/app/samples/repo-command.js
@@ -32,7 +32,7 @@
         margin-bottom: var(--spacing-xxl);
       }
       </style>
-      <h3>Plugin Bork</h3>
+      <h3 class="heading-3">Plugin Bork</h3>
       <gr-button on-click="_handleCommandTap">Bork</gr-button>
     `;
   }
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 66681ee..989bafb 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
@@ -35,17 +35,15 @@
     provider = new GrEmailSuggestionsProvider(appContext.restApiService);
   });
 
-  test('getSuggestions', done => {
+  test('getSuggestions', async () => {
     const getSuggestedAccountsStub =
         stubRestApi('getSuggestedAccounts').returns(
             Promise.resolve([account1, account2]));
 
-    provider.getSuggestions('Some input').then(res => {
-      assert.deepEqual(res, [account1, account2]);
-      assert.isTrue(getSuggestedAccountsStub.calledOnce);
-      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-      done();
-    });
+    const res = await provider.getSuggestions('Some input');
+    assert.deepEqual(res, [account1, account2]);
+    assert.isTrue(getSuggestedAccountsStub.calledOnce);
+    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
   });
 
   test('makeSuggestionItem', () => {
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 1a14abf..67f9433 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
@@ -36,7 +36,7 @@
     provider = new GrGroupSuggestionsProvider(appContext.restApiService);
   });
 
-  test('getSuggestions', done => {
+  test('getSuggestions', async () => {
     const getSuggestedAccountsStub =
         stubRestApi('getSuggestedGroups')
             .returns(Promise.resolve({
@@ -44,12 +44,10 @@
               'Other name': {id: 3, url: 'abcd'},
             }));
 
-    provider.getSuggestions('Some input').then(res => {
-      assert.deepEqual(res, [group1, group2]);
-      assert.isTrue(getSuggestedAccountsStub.calledOnce);
-      assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
-      done();
-    });
+    const res = await provider.getSuggestions('Some input');
+    assert.deepEqual(res, [group1, group2]);
+    assert.isTrue(getSuggestedAccountsStub.calledOnce);
+    assert.equal(getSuggestedAccountsStub.lastCall.args[0], 'Some input');
   });
 
   test('makeSuggestionItem', () => {
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 45116aa..a74adf6 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
@@ -49,7 +49,15 @@
   value: SuggestedReviewerInfo;
 }
 
-export class GrReviewerSuggestionsProvider {
+export interface ReviewerSuggestionsProvider {
+  init(): void;
+  getSuggestions(input: string): Promise<Suggestion[]>;
+  makeSuggestionItem(suggestion: Suggestion): SuggestionItem;
+}
+
+export class GrReviewerSuggestionsProvider
+  implements ReviewerSuggestionsProvider
+{
   static create(
     restApi: RestApiService,
     changeNumber: NumericChangeId,
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 d3cad45..762d36c 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
@@ -54,7 +54,7 @@
   let redundantSuggestion3;
   let change;
 
-  setup(done => {
+  setup(async () => {
     owner = makeAccount();
     existingReviewer1 = makeAccount();
     existingReviewer2 = makeAccount();
@@ -78,15 +78,15 @@
       },
     };
 
-    return flush(done);
+    await flush();
   });
 
   suite('allowAnyUser set to false', () => {
-    setup(done => {
+    setup(async () => {
       provider = GrReviewerSuggestionsProvider.create(
           appContext.restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.REVIEWER);
-      provider.init().then(done);
+      await provider.init();
     });
     suite('stubbed values for _getReviewerSuggestions', () => {
       let getChangeSuggestedReviewersStub;
@@ -165,17 +165,15 @@
         });
       });
 
-      test('getSuggestions', done => {
-        provider.getSuggestions()
-            .then(reviewers => {
-              // Default is no filtering.
-              assert.equal(reviewers.length, 6);
-              assert.deepEqual(reviewers,
-                  [redundantSuggestion1, redundantSuggestion2,
-                    redundantSuggestion3, suggestion1,
-                    suggestion2, suggestion3]);
-            })
-            .then(done);
+      test('getSuggestions', async () => {
+        const reviewers = await provider.getSuggestions();
+
+        // Default is no filtering.
+        assert.equal(reviewers.length, 6);
+        assert.deepEqual(reviewers,
+            [redundantSuggestion1, redundantSuggestion2,
+              redundantSuggestion3, suggestion1,
+              suggestion2, suggestion3]);
       });
 
       test('getSuggestions short circuits when logged out', () => {
@@ -190,41 +188,37 @@
       });
     });
 
-    test('getChangeSuggestedReviewers is used', done => {
+    test('getChangeSuggestedReviewers is used', async () => {
       const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
           .returns(Promise.resolve([]));
       const suggestAccountStub = stubRestApi('getSuggestedAccounts')
           .returns(Promise.resolve([]));
 
-      provider.getSuggestions('').then(() => {
-        assert.isTrue(suggestReviewerStub.calledOnce);
-        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
-        assert.isFalse(suggestAccountStub.called);
-        done();
-      });
+      await provider.getSuggestions('');
+      assert.isTrue(suggestReviewerStub.calledOnce);
+      assert.isTrue(suggestReviewerStub.calledWith(42, ''));
+      assert.isFalse(suggestAccountStub.called);
     });
   });
 
   suite('allowAnyUser set to true', () => {
-    setup(done => {
+    setup(async () => {
       provider = GrReviewerSuggestionsProvider.create(
           appContext.restApiService, change._number,
           SUGGESTIONS_PROVIDERS_USERS_TYPES.ANY);
-      provider.init().then(done);
+      await provider.init();
     });
 
-    test('getSuggestedAccounts is used', done => {
+    test('getSuggestedAccounts is used', async () => {
       const suggestReviewerStub = stubRestApi('getChangeSuggestedReviewers')
           .returns(Promise.resolve([]));
       const suggestAccountStub = stubRestApi('getSuggestedAccounts')
           .returns(Promise.resolve([]));
 
-      provider.getSuggestions('').then(() => {
-        assert.isFalse(suggestReviewerStub.called);
-        assert.isTrue(suggestAccountStub.calledOnce);
-        assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
-        done();
-      });
+      await provider.getSuggestions('');
+      assert.isFalse(suggestReviewerStub.called);
+      assert.isTrue(suggestAccountStub.calledOnce);
+      assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
     });
   });
 });
diff --git a/polygerrit-ui/app/services/app-context-init.ts b/polygerrit-ui/app/services/app-context-init.ts
index dab894e..3a6f7c5 100644
--- a/polygerrit-ui/app/services/app-context-init.ts
+++ b/polygerrit-ui/app/services/app-context-init.ts
@@ -25,6 +25,9 @@
 import {GrJsApiInterface} from '../elements/shared/gr-js-api-interface/gr-js-api-interface-element';
 import {GrStorageService} from './storage/gr-storage_impl';
 import {ConfigService} from './config/config-service';
+import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
 
 type ServiceName = keyof AppContext;
 type ServiceCreator<T> = () => T;
@@ -71,11 +74,15 @@
     reportingService: () => new GrReporting(appContext.flagsService),
     eventEmitter: () => new EventEmitter(),
     authService: () => new Auth(appContext.eventEmitter),
-    restApiService: () => new GrRestApiInterface(appContext.authService),
+    restApiService: () =>
+      new GrRestApiInterface(appContext.authService, appContext.flagsService),
     changeService: () => new ChangeService(),
-    checksService: () => new ChecksService(),
+    commentsService: () => new CommentsService(appContext.restApiService),
+    checksService: () => new ChecksService(appContext.reportingService),
     jsApiService: () => new GrJsApiInterface(),
     storageService: () => new GrStorageService(),
     configService: () => new ConfigService(),
+    userService: () => new UserService(appContext.restApiService),
+    shortcutsService: () => new ShortcutsService(appContext.reportingService),
   });
 }
diff --git a/polygerrit-ui/app/services/app-context.ts b/polygerrit-ui/app/services/app-context.ts
index fbc3115..e5828d6 100644
--- a/polygerrit-ui/app/services/app-context.ts
+++ b/polygerrit-ui/app/services/app-context.ts
@@ -24,6 +24,9 @@
 import {JsApiService} from '../elements/shared/gr-js-api-interface/gr-js-api-types';
 import {StorageService} from './storage/gr-storage';
 import {ConfigService} from './config/config-service';
+import {UserService} from './user/user-service';
+import {CommentsService} from './comments/comments-service';
+import {ShortcutsService} from './shortcuts/shortcuts-service';
 
 export interface AppContext {
   flagsService: FlagsService;
@@ -32,10 +35,13 @@
   authService: AuthService;
   restApiService: RestApiService;
   changeService: ChangeService;
+  commentsService: CommentsService;
   checksService: ChecksService;
   jsApiService: JsApiService;
   storageService: StorageService;
   configService: ConfigService;
+  userService: UserService;
+  shortcutsService: ShortcutsService;
 }
 
 /**
diff --git a/polygerrit-ui/app/services/change/change-model.ts b/polygerrit-ui/app/services/change/change-model.ts
index 7085d3c..962ef4d 100644
--- a/polygerrit-ui/app/services/change/change-model.ts
+++ b/polygerrit-ui/app/services/change/change-model.ts
@@ -48,7 +48,7 @@
 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
+  // are explicitly emitting an 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.
@@ -92,6 +92,11 @@
   distinctUntilChanged()
 );
 
+export const labels$ = change$.pipe(
+  map(change => change?.labels),
+  distinctUntilChanged()
+);
+
 export const latestPatchNum$ = change$.pipe(
   map(change => computeLatestPatchNum(computeAllPatchSets(change))),
   distinctUntilChanged()
@@ -105,12 +110,11 @@
  * 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]) => routerPatchNum || latestPatchNum
-  ),
-  distinctUntilChanged()
-);
+export const currentPatchNum$: Observable<PatchSetNum | undefined> =
+  changeAndRouterConsistent$.pipe(
+    withLatestFrom(routerPatchNum$, latestPatchNum$),
+    map(
+      ([_, routerPatchNum, latestPatchNum]) => routerPatchNum || latestPatchNum
+    ),
+    distinctUntilChanged()
+  );
diff --git a/polygerrit-ui/app/services/change/change-service.ts b/polygerrit-ui/app/services/change/change-service.ts
index c292fb5..0b9a1f2 100644
--- a/polygerrit-ui/app/services/change/change-service.ts
+++ b/polygerrit-ui/app/services/change/change-service.ts
@@ -15,10 +15,20 @@
  * limitations under the License.
  */
 import {routerChangeNum$} from '../router/router-model';
-import {updateState} from './change-model';
+import {change$, updateState} from './change-model';
 import {ParsedChangeInfo} from '../../types/types';
+import {appContext} from '../app-context';
+import {ChangeInfo} from '../../types/common';
+import {
+  computeAllPatchSets,
+  computeLatestPatchNum,
+} from '../../utils/patch-set-util';
 
 export class ChangeService {
+  private change?: ParsedChangeInfo;
+
+  private readonly restApiService = appContext.restApiService;
+
   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
@@ -26,6 +36,9 @@
     routerChangeNum$.subscribe(changeNum => {
       if (!changeNum) updateState(undefined);
     });
+    change$.subscribe(change => {
+      this.change = change;
+    });
   }
 
   /**
@@ -38,4 +51,44 @@
   updateChange(change: ParsedChangeInfo) {
     updateState(change);
   }
+
+  /**
+   * Typically you would just subscribe to change$ yourself to get updates. But
+   * sometimes it is nice to also be able to get the current ChangeInfo on
+   * demand. So here it is for your convenience.
+   */
+  getChange() {
+    return this.change;
+  }
+
+  /**
+   * Check whether there is no newer patch than the latest patch that was
+   * available when this change was loaded.
+   *
+   * @return A promise that yields true if the latest patch
+   *     has been loaded, and false if a newer patch has been uploaded in the
+   *     meantime. The promise is rejected on network error.
+   */
+  fetchChangeUpdates(change: ChangeInfo | ParsedChangeInfo) {
+    const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
+    return this.restApiService.getChangeDetail(change._number).then(detail => {
+      if (!detail) {
+        const error = new Error('Change detail not found.');
+        return Promise.reject(error);
+      }
+      const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
+      if (!actualLatest || !knownLatest) {
+        const error = new Error('Unable to check for latest patchset.');
+        return Promise.reject(error);
+      }
+      return {
+        isLatest: actualLatest <= knownLatest,
+        newStatus: change.status !== detail.status ? detail.status : null,
+        newMessages:
+          (change.messages || []).length < (detail.messages || []).length
+            ? detail.messages![detail.messages!.length - 1]
+            : undefined,
+      };
+    });
+  }
 }
diff --git a/polygerrit-ui/app/services/change/change-services_test.ts b/polygerrit-ui/app/services/change/change-services_test.ts
new file mode 100644
index 0000000..3e427ff
--- /dev/null
+++ b/polygerrit-ui/app/services/change/change-services_test.ts
@@ -0,0 +1,108 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {ChangeStatus} from '../../constants/constants';
+import '../../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createRevision,
+} from '../../test/test-data-generators';
+import {stubRestApi} from '../../test/test-utils';
+import {CommitId, PatchSetNum} from '../../types/common';
+import {ParsedChangeInfo} from '../../types/types';
+import {ChangeService} from './change-service';
+
+suite('change service tests', () => {
+  let changeService: ChangeService;
+  let knownChange: ParsedChangeInfo;
+  setup(() => {
+    changeService = new ChangeService();
+    knownChange = {
+      ...createChange(),
+      revisions: {
+        sha1: {
+          ...createRevision(1),
+          description: 'patch 1',
+          _number: 1 as PatchSetNum,
+        },
+        sha2: {
+          ...createRevision(2),
+          description: 'patch 2',
+          _number: 2 as PatchSetNum,
+        },
+      },
+      status: ChangeStatus.NEW,
+      current_revision: 'abc' as CommitId,
+      messages: [],
+    };
+  });
+
+  test('changeService.fetchChangeUpdates on latest', async () => {
+    stubRestApi('getChangeDetail').returns(Promise.resolve(knownChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeService.fetchChangeUpdates not on latest', async () => {
+    const actualChange = {
+      ...knownChange,
+      revisions: {
+        ...knownChange.revisions,
+        sha3: {
+          ...createRevision(3),
+          description: 'patch 3',
+          _number: 3 as PatchSetNum,
+        },
+      },
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isFalse(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeService.fetchChangeUpdates new status', async () => {
+    const actualChange = {
+      ...knownChange,
+      status: ChangeStatus.MERGED,
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.equal(result.newStatus, ChangeStatus.MERGED);
+    assert.isNotOk(result.newMessages);
+  });
+
+  test('changeService.fetchChangeUpdates new messages', async () => {
+    const actualChange = {
+      ...knownChange,
+      messages: [{...createChangeMessageInfo(), message: 'blah blah'}],
+    };
+    stubRestApi('getChangeDetail').returns(Promise.resolve(actualChange));
+    const result = await changeService.fetchChangeUpdates(knownChange);
+    assert.isTrue(result.isLatest);
+    assert.isNotOk(result.newStatus);
+    assert.deepEqual(result.newMessages, {
+      ...createChangeMessageInfo(),
+      message: 'blah blah',
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/checks/checks-model.ts b/polygerrit-ui/app/services/checks/checks-model.ts
index ecd56cb..75c24b6 100644
--- a/polygerrit-ui/app/services/checks/checks-model.ts
+++ b/polygerrit-ui/app/services/checks/checks-model.ts
@@ -21,15 +21,27 @@
   Category,
   CheckResult as CheckResultApi,
   CheckRun as CheckRunApi,
-  ChecksApiConfig,
   Link,
   LinkIcon,
   RunStatus,
+  TagColor,
 } from '../../api/checks';
 import {distinctUntilChanged, map} from 'rxjs/operators';
 import {PatchSetNumber} from '../../types/common';
 import {AttemptDetail, createAttemptMap} from './checks-util';
 import {assertIsDefined} from '../../utils/common-util';
+import {deepEqualStringDict, equalArray} from '../../utils/compare-util';
+
+/**
+ * The checks model maintains the state of checks for two patchsets: the latest
+ * and (if different) also for the one selected in the checks tab. So we need
+ * the distinction in a lot of places for checks about whether the code affects
+ * the checks data of the LATEST or the SELECTED patchset.
+ */
+export enum ChecksPatchset {
+  LATEST = 'LATEST',
+  SELECTED = 'SELECTED',
+}
 
 export interface CheckResult extends CheckResultApi {
   /**
@@ -41,6 +53,10 @@
 
 export interface CheckRun extends CheckRunApi {
   /**
+   * For convenience we attach the name of the plugin to each run.
+   */
+  pluginName: string;
+  /**
    * Internally we want to uniquely identify a result with an id, for example
    * when efficiently re-rendering lists of results in the UI.
    */
@@ -70,55 +86,112 @@
 interface ChecksProviderState {
   pluginName: string;
   loading: boolean;
+  /**
+   * Allows to distinguish whether loading:true is the *first* time of loading
+   * something for this provider. Or just a subsequent background update.
+   * Note that this is initially true even before loading is being set to true,
+   * so you may want to check loading && firstTimeLoad.
+   */
+  firstTimeLoad: boolean;
   /** Presence of errorMessage implicitly means that the provider is in ERROR state. */
   errorMessage?: string;
   /** Presence of loginCallback implicitly means that the provider is in NOT_LOGGED_IN state. */
   loginCallback?: () => void;
-  config?: ChecksApiConfig;
   runs: CheckRun[];
   actions: Action[];
   links: Link[];
 }
 
 interface ChecksState {
-  patchsetNumber?: PatchSetNumber;
-  providerNameToState: {
+  /**
+   * This is the patchset number selected by the user. The *latest* patchset
+   * can be picked up from the change model.
+   */
+  patchsetNumberSelected?: PatchSetNumber;
+  /** Checks data for the latest patchset. */
+  pluginStateLatest: {
+    [name: string]: ChecksProviderState;
+  };
+  /**
+   * Checks data for the selected patchset. Note that `checksSelected$` below
+   * falls back to the data for the latest patchset, if no patchset is selected.
+   */
+  pluginStateSelected: {
     [name: string]: ChecksProviderState;
   };
 }
 
 const initialState: ChecksState = {
-  providerNameToState: {},
+  pluginStateLatest: {},
+  pluginStateSelected: {},
 };
 
-const privateState$ = new BehaviorSubject(initialState);
+// Mutable for testing
+let privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+  privateState$ = new BehaviorSubject(initialState);
+}
+
+export function _testOnly_setState(state: ChecksState) {
+  privateState$.next(state);
+}
+
+export function _testOnly_getState() {
+  return privateState$.getValue();
+}
 
 // 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),
+export const checksSelectedPatchsetNumber$ = checksState$.pipe(
+  map(state => state.patchsetNumberSelected),
   distinctUntilChanged()
 );
 
-export const checksProviderState$ = checksState$.pipe(
-  map(state => state.providerNameToState),
+export const checksLatest$ = checksState$.pipe(
+  map(state => state.pluginStateLatest),
   distinctUntilChanged()
 );
 
-export const aPluginHasRegistered$ = checksProviderState$.pipe(
+export const checksSelected$ = checksState$.pipe(
+  map(state =>
+    state.patchsetNumberSelected
+      ? state.pluginStateSelected
+      : state.pluginStateLatest
+  ),
+  distinctUntilChanged()
+);
+
+export const aPluginHasRegistered$ = checksLatest$.pipe(
   map(state => Object.keys(state).length > 0),
   distinctUntilChanged()
 );
 
-export const someProvidersAreLoading$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingFirstTime$ = checksLatest$.pipe(
+  map(state =>
+    Object.values(state).some(
+      provider => provider.loading && provider.firstTimeLoad
+    )
+  ),
+  distinctUntilChanged()
+);
+
+export const someProvidersAreLoadingLatest$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).some(providerState => providerState.loading)
   ),
   distinctUntilChanged()
 );
 
-export const errorMessage$ = checksProviderState$.pipe(
+export const someProvidersAreLoadingSelected$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).some(providerState => providerState.loading)
+  ),
+  distinctUntilChanged()
+);
+
+export const errorMessageLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -128,7 +201,24 @@
   distinctUntilChanged()
 );
 
-export const loginCallback$ = checksProviderState$.pipe(
+export interface ErrorMessages {
+  /* Maps plugin name to error message. */
+  [name: string]: string;
+}
+
+export const errorMessagesLatest$ = checksLatest$.pipe(
+  map(state => {
+    const errorMessages: ErrorMessages = {};
+    for (const providerState of Object.values(state)) {
+      if (providerState.errorMessage === undefined) continue;
+      errorMessages[providerState.pluginName] = providerState.errorMessage;
+    }
+    return errorMessages;
+  }),
+  distinctUntilChanged(deepEqualStringDict)
+);
+
+export const loginCallbackLatest$ = checksLatest$.pipe(
   map(
     state =>
       Object.values(state).find(
@@ -138,7 +228,7 @@
   distinctUntilChanged()
 );
 
-export const allActions$ = checksProviderState$.pipe(
+export const topLevelActionsLatest$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allActions: Action[], providerState: ChecksProviderState) => [
@@ -147,22 +237,37 @@
       ],
       []
     )
-  )
+  ),
+  distinctUntilChanged<Action[]>(equalArray)
 );
 
-export const allLinks$ = checksProviderState$.pipe(
+export const topLevelActionsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state).reduce(
-      (allActions: Link[], providerState: ChecksProviderState) => [
+      (allActions: Action[], providerState: ChecksProviderState) => [
         ...allActions,
+        ...providerState.actions,
+      ],
+      []
+    )
+  ),
+  distinctUntilChanged<Action[]>(equalArray)
+);
+
+export const topLevelLinksSelected$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allLinks: Link[], providerState: ChecksProviderState) => [
+        ...allLinks,
         ...providerState.links,
       ],
       []
     )
-  )
+  ),
+  distinctUntilChanged<Link[]>(equalArray)
 );
 
-export const allRuns$ = checksProviderState$.pipe(
+export const allRunsLatestPatchset$ = checksLatest$.pipe(
   map(state =>
     Object.values(state).reduce(
       (allRuns: CheckRun[], providerState: ChecksProviderState) => [
@@ -171,14 +276,28 @@
       ],
       []
     )
-  )
+  ),
+  distinctUntilChanged<CheckRun[]>(equalArray)
 );
 
-export const allRunsLatest$ = allRuns$.pipe(
+export const allRunsSelectedPatchset$ = checksSelected$.pipe(
+  map(state =>
+    Object.values(state).reduce(
+      (allRuns: CheckRun[], providerState: ChecksProviderState) => [
+        ...allRuns,
+        ...providerState.runs,
+      ],
+      []
+    )
+  ),
+  distinctUntilChanged<CheckRun[]>(equalArray)
+);
+
+export const allRunsLatestPatchsetLatestAttempt$ = allRunsLatestPatchset$.pipe(
   map(runs => runs.filter(run => run.isLatestAttempt))
 );
 
-export const checkToPluginMap$ = checksProviderState$.pipe(
+export const checkToPluginMap$ = checksLatest$.pipe(
   map(state => {
     const map = new Map<string, string>();
     for (const [pluginName, providerState] of Object.entries(state)) {
@@ -190,7 +309,7 @@
   })
 );
 
-export const allResults$ = checksProviderState$.pipe(
+export const allResultsSelected$ = checksSelected$.pipe(
   map(state =>
     Object.values(state)
       .reduce(
@@ -212,14 +331,14 @@
 // model.
 export function updateStateSetProvider(
   pluginName: string,
-  config?: ChecksApiConfig
+  patchset: ChecksPatchset
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
     pluginName,
     loading: false,
-    config,
+    firstTimeLoad: true,
     runs: [],
     actions: [],
     links: [],
@@ -227,13 +346,14 @@
   privateState$.next(nextState);
 }
 
-// TODO(brohlfs): Remove all fake runs by end of April. They are just making
-// it easier to develop the UI and always see all the different types/states of
-// runs and results.
+// TODO(brohlfs): Remove all fake runs once the Checks UI is fully launched.
+//  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 = {
+  pluginName: 'f0',
   internalRunId: 'f0',
-  checkName: 'FAKE Error Finder',
+  checkName: 'FAKE Error Finder Finder Finder Finder Finder Finder Finder',
   labelName: 'Presubmit',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -252,30 +372,40 @@
       internalResultId: 'f0r1',
       category: Category.ERROR,
       summary: 'Running the mighty test has failed by crashing.',
+      message: 'Btw, 1 is also not equal to 3. Did you know?',
       actions: [
         {
           name: 'Ignore',
           tooltip: 'Ignore this result',
           primary: true,
-          callback: () => undefined,
+          callback: () => Promise.resolve({message: 'fake "ignore" triggered'}),
         },
         {
           name: 'Flag',
-          tooltip: 'Flag this result as not useful',
+          tooltip: 'Flag this result as totally absolutely really not useful',
           primary: true,
-          callback: () => undefined,
+          disabled: true,
+          callback: () => Promise.resolve({message: 'flag "flag" triggered'}),
         },
         {
           name: 'Upload',
           tooltip: 'Upload the result to the super cloud.',
           primary: false,
-          callback: () => undefined,
+          callback: () => Promise.resolve({message: 'fake "upload" triggered'}),
         },
       ],
-      tags: [{name: 'INTERRUPTED'}, {name: 'WINDOWS'}],
+      tags: [{name: 'INTERRUPTED', color: TagColor.BROWN}, {name: 'WINDOWS'}],
       links: [
-        {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
         {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
+        {
+          primary: true,
+          url: 'https://google.com',
+          icon: LinkIcon.DOWNLOAD_MOBILE,
+        },
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {primary: false, url: 'https://google.com', icon: LinkIcon.IMAGE},
         {primary: true, url: 'https://google.com', icon: LinkIcon.REPORT_BUG},
         {primary: true, url: 'https://google.com', icon: LinkIcon.HELP_PAGE},
         {primary: true, url: 'https://google.com', icon: LinkIcon.HISTORY},
@@ -286,8 +416,11 @@
 };
 
 export const fakeRun1: CheckRun = {
+  pluginName: 'f1',
   internalRunId: 'f1',
   checkName: 'FAKE Super Check',
+  statusLink: 'https://www.google.com/',
+  patchset: 1,
   labelName: 'Verified',
   isSingleAttempt: true,
   isLatestAttempt: true,
@@ -299,7 +432,27 @@
       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'}],
+      tags: [{name: 'INTERRUPTED', color: TagColor.PURPLE}, {name: 'WINDOWS'}],
+      codePointers: [
+        {
+          path: '/COMMIT_MSG',
+          range: {
+            start_line: 10,
+            start_character: 0,
+            end_line: 10,
+            end_character: 0,
+          },
+        },
+        {
+          path: 'polygerrit-ui/app/api/checks.ts',
+          range: {
+            start_line: 5,
+            start_character: 0,
+            end_line: 7,
+            end_character: 0,
+          },
+        },
+      ],
       links: [
         {primary: true, url: 'https://google.com', icon: LinkIcon.EXTERNAL},
         {primary: true, url: 'https://google.com', icon: LinkIcon.DOWNLOAD},
@@ -309,6 +462,18 @@
           icon: LinkIcon.DOWNLOAD_MOBILE,
         },
         {primary: true, url: 'https://google.com', icon: LinkIcon.IMAGE},
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'look at this',
+          icon: LinkIcon.IMAGE,
+        },
+        {
+          primary: false,
+          url: 'https://google.com',
+          tooltip: 'not at this',
+          icon: LinkIcon.IMAGE,
+        },
       ],
     },
   ],
@@ -316,6 +481,7 @@
 };
 
 export const fakeRun2: CheckRun = {
+  pluginName: 'f2',
   internalRunId: 'f2',
   checkName: 'FAKE Mega Analysis',
   statusDescription: 'This run is nearly completed, but not quite.',
@@ -334,17 +500,18 @@
       name: 'Re-Run',
       tooltip: 'More powerful run than before',
       primary: true,
-      callback: () => undefined,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
     },
     {
       name: 'Monetize',
       primary: true,
-      callback: () => undefined,
+      disabled: true,
+      callback: () => Promise.resolve({message: 'fake "monetize" triggered'}),
     },
     {
       name: 'Delete',
       primary: true,
-      callback: () => undefined,
+      callback: () => Promise.resolve({message: 'fake "delete" triggered'}),
     },
   ],
   results: [
@@ -366,6 +533,7 @@
 };
 
 export const fakeRun3: CheckRun = {
+  pluginName: 'f3',
   internalRunId: 'f3',
   checkName: 'FAKE Critical Observations',
   status: RunStatus.RUNNABLE,
@@ -375,9 +543,10 @@
 };
 
 export const fakeRun4_1: CheckRun = {
+  pluginName: 'f4',
   internalRunId: 'f4',
-  checkName: 'FAKE Elimination',
-  status: RunStatus.COMPLETED,
+  checkName: 'FAKE Elimination Long Long Long Long Long',
+  status: RunStatus.RUNNABLE,
   attempt: 1,
   isSingleAttempt: false,
   isLatestAttempt: false,
@@ -385,8 +554,9 @@
 };
 
 export const fakeRun4_2: CheckRun = {
+  pluginName: 'f4',
   internalRunId: 'f4',
-  checkName: 'FAKE Elimination',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
   status: RunStatus.COMPLETED,
   attempt: 2,
   isSingleAttempt: false,
@@ -402,8 +572,9 @@
 };
 
 export const fakeRun4_3: CheckRun = {
+  pluginName: 'f4',
   internalRunId: 'f4',
-  checkName: 'FAKE Elimination',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
   status: RunStatus.COMPLETED,
   attempt: 3,
   isSingleAttempt: false,
@@ -419,14 +590,15 @@
 };
 
 export const fakeRun4_4: CheckRun = {
+  pluginName: 'f4',
   internalRunId: 'f4',
-  checkName: 'FAKE Elimination',
+  checkName: 'FAKE Elimination Long Long Long Long Long',
   checkDescription: 'Shows you the possible eliminations.',
   checkLink: 'https://www.google.com',
-  status: RunStatus.RUNNING,
+  status: RunStatus.COMPLETED,
   statusDescription: 'Everything was eliminated already.',
   statusLink: 'https://www.google.com',
-  attempt: 4,
+  attempt: 40,
   scheduledTimestamp: new Date('2021-04-02T03:14:15'),
   startedTimestamp: new Date('2021-04-02T04:24:25'),
   finishedTimestamp: new Date('2021-04-02T04:25:44'),
@@ -438,71 +610,172 @@
       internalResultId: 'f44r0',
       category: Category.INFO,
       summary: 'Dont be afraid. All TODOs will be eliminated.',
+      actions: [
+        {
+          name: 'Re-Run',
+          tooltip: 'More powerful run than before with a long tooltip, really.',
+          primary: true,
+          callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
+        },
+      ],
+    },
+  ],
+  actions: [
+    {
+      name: 'Re-Run',
+      tooltip: 'small',
+      primary: true,
+      callback: () => Promise.resolve({message: 'fake "re-run" triggered'}),
     },
   ],
 };
 
+export function fakeRun4CreateAttempts(from: number, to: number): CheckRun[] {
+  const runs: CheckRun[] = [];
+  for (let i = from; i < to; i++) {
+    runs.push(fakeRun4CreateAttempt(i));
+  }
+  return runs;
+}
+
+export function fakeRun4CreateAttempt(attempt: number): CheckRun {
+  return {
+    pluginName: 'f4',
+    internalRunId: 'f4',
+    checkName: 'FAKE Elimination Long Long Long Long Long',
+    status: RunStatus.COMPLETED,
+    attempt,
+    isSingleAttempt: false,
+    isLatestAttempt: false,
+    attemptDetails: [],
+    results:
+      attempt % 2 === 0
+        ? [
+            {
+              internalResultId: 'f43r0',
+              category: Category.ERROR,
+              summary:
+                'Without eliminating all the TODOs your change will break!',
+            },
+          ]
+        : [],
+  };
+}
+
+export const fakeRun4Att = [
+  fakeRun4_1,
+  fakeRun4_2,
+  fakeRun4_3,
+  ...fakeRun4CreateAttempts(5, 40),
+  fakeRun4_4,
+];
+
 export const fakeActions: Action[] = [
   {
     name: 'Fake Action 1',
-    primary: false,
+    primary: true,
+    disabled: true,
     tooltip: 'Tooltip for Fake Action 1',
-    callback: () => {
-      console.warn('fake action 1 triggered');
-      return undefined;
-    },
+    callback: () => Promise.resolve({message: 'fake action 1 triggered'}),
   },
   {
     name: 'Fake Action 2',
     primary: false,
+    disabled: true,
     tooltip: 'Tooltip for Fake Action 2',
-    callback: () => {
-      console.warn('fake action 2 triggered');
-      return undefined;
-    },
+    callback: () => Promise.resolve({message: 'fake action 2 triggered'}),
   },
   {
     name: 'Fake Action 3',
+    summary: true,
     primary: false,
     tooltip: 'Tooltip for Fake Action 3',
-    callback: () => {
-      console.warn('fake action 3 triggered');
-      return undefined;
-    },
+    callback: () => Promise.resolve({message: 'fake action 3 triggered'}),
   },
 ];
 
 export const fakeLinks: Link[] = [
   {
     url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Tooltip for Bug Report Fake Link',
+    primary: true,
+    tooltip: 'Fake Bug Report 1',
     icon: LinkIcon.REPORT_BUG,
   },
   {
     url: 'https://www.google.com',
-    primary: false,
-    tooltip: 'Tooltip for External Fake Link',
+    primary: true,
+    tooltip: 'Fake Bug Report 2',
+    icon: LinkIcon.REPORT_BUG,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Link 1',
     icon: LinkIcon.EXTERNAL,
   },
+  {
+    url: 'https://www.google.com',
+    primary: false,
+    tooltip: 'Fake Link 2',
+    icon: LinkIcon.EXTERNAL,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Code Link',
+    icon: LinkIcon.CODE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Image Link',
+    icon: LinkIcon.IMAGE,
+  },
+  {
+    url: 'https://www.google.com',
+    primary: true,
+    tooltip: 'Fake Help Link',
+    icon: LinkIcon.HELP_PAGE,
+  },
 ];
 
-export function updateStateSetLoading(pluginName: string) {
+export function getPluginState(
+  state: ChecksState,
+  patchset: ChecksPatchset = ChecksPatchset.LATEST
+) {
+  if (patchset === ChecksPatchset.LATEST) {
+    state.pluginStateLatest = {...state.pluginStateLatest};
+    return state.pluginStateLatest;
+  } else {
+    state.pluginStateSelected = {...state.pluginStateSelected};
+    return state.pluginStateSelected;
+  }
+}
+
+export function updateStateSetLoading(
+  pluginName: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: true,
   };
   privateState$.next(nextState);
 }
 
-export function updateStateSetError(pluginName: string, errorMessage: string) {
+export function updateStateSetError(
+  pluginName: string,
+  errorMessage: string,
+  patchset: ChecksPatchset
+) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
+    firstTimeLoad: false,
     errorMessage,
     loginCallback: undefined,
     runs: [],
@@ -513,13 +786,15 @@
 
 export function updateStateSetNotLoggedIn(
   pluginName: string,
-  loginCallback: () => void
+  loginCallback: () => void,
+  patchset: ChecksPatchset
 ) {
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
+    firstTimeLoad: false,
     errorMessage: undefined,
     loginCallback,
     runs: [],
@@ -532,19 +807,21 @@
   pluginName: string,
   runs: CheckRunApi[],
   actions: Action[] = [],
-  links: Link[] = []
+  links: Link[] = [],
+  patchset: ChecksPatchset
 ) {
   const attemptMap = createAttemptMap(runs);
   for (const attemptInfo of attemptMap.values()) {
     // Per run only one attempt can be undefined, so the '?? -1' is not really
     // relevant for sorting.
-    attemptInfo.attempts.sort((a, b) => (b.attempt ?? -1) - (a.attempt ?? -1));
+    attemptInfo.attempts.sort((a, b) => (a.attempt ?? -1) - (b.attempt ?? -1));
   }
   const nextState = {...privateState$.getValue()};
-  nextState.providerNameToState = {...nextState.providerNameToState};
-  nextState.providerNameToState[pluginName] = {
-    ...nextState.providerNameToState[pluginName],
+  const pluginState = getPluginState(nextState, patchset);
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
     loading: false,
+    firstTimeLoad: false,
     errorMessage: undefined,
     loginCallback: undefined,
     runs: runs.map(run => {
@@ -553,6 +830,7 @@
       assertIsDefined(attemptInfo, 'attemptInfo');
       return {
         ...run,
+        pluginName,
         internalRunId: runId,
         isLatestAttempt: attemptInfo.latestAttempt === run.attempt,
         isSingleAttempt: attemptInfo.isSingleAttempt,
@@ -571,8 +849,44 @@
   privateState$.next(nextState);
 }
 
+export function updateStateUpdateResult(
+  pluginName: string,
+  updatedRun: CheckRunApi,
+  updatedResult: CheckResultApi,
+  patchset: ChecksPatchset
+) {
+  const nextState = {...privateState$.getValue()};
+  const pluginState = getPluginState(nextState, patchset);
+  let runUpdated = false;
+  const runs: CheckRun[] = pluginState[pluginName].runs.map(run => {
+    if (run.change !== updatedRun.change) return run;
+    if (run.patchset !== updatedRun.patchset) return run;
+    if (run.attempt !== updatedRun.attempt) return run;
+    if (run.checkName !== updatedRun.checkName) return run;
+    let resultUpdated = false;
+    const results: CheckResult[] = (run.results ?? []).map(result => {
+      if (result.externalId && result.externalId === updatedResult.externalId) {
+        runUpdated = true;
+        resultUpdated = true;
+        return {
+          ...updatedResult,
+          internalResultId: result.internalResultId,
+        };
+      }
+      return result;
+    });
+    return resultUpdated ? {...run, results} : run;
+  });
+  if (!runUpdated) return;
+  pluginState[pluginName] = {
+    ...pluginState[pluginName],
+    runs,
+  };
+  privateState$.next(nextState);
+}
+
 export function updateStateSetPatchset(patchsetNumber?: PatchSetNumber) {
   const nextState = {...privateState$.getValue()};
-  nextState.patchsetNumber = patchsetNumber;
+  nextState.patchsetNumberSelected = patchsetNumber;
   privateState$.next(nextState);
 }
diff --git a/polygerrit-ui/app/services/checks/checks-model_test.ts b/polygerrit-ui/app/services/checks/checks-model_test.ts
new file mode 100644
index 0000000..dbd3f86
--- /dev/null
+++ b/polygerrit-ui/app/services/checks/checks-model_test.ts
@@ -0,0 +1,112 @@
+/**
+ * @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';
+import './checks-model';
+import {
+  _testOnly_getState,
+  _testOnly_resetState,
+  ChecksPatchset,
+  updateStateSetLoading,
+  updateStateSetProvider,
+  updateStateSetResults,
+  updateStateUpdateResult,
+} from './checks-model';
+import {Category, CheckRun, RunStatus} from '../../api/checks';
+
+const PLUGIN_NAME = 'test-plugin';
+
+const RUNS: CheckRun[] = [
+  {
+    checkName: 'MacCheck',
+    change: 123,
+    patchset: 1,
+    attempt: 1,
+    status: RunStatus.COMPLETED,
+    results: [
+      {
+        externalId: 'id-314',
+        category: Category.WARNING,
+        summary: 'Meddle cheddle check and you are weg.',
+      },
+    ],
+  },
+];
+
+function current() {
+  return _testOnly_getState().pluginStateLatest[PLUGIN_NAME];
+}
+
+suite('checks-model tests', () => {
+  test('updateStateSetProvider', () => {
+    _testOnly_resetState();
+    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.deepEqual(current(), {
+      pluginName: PLUGIN_NAME,
+      loading: false,
+      firstTimeLoad: true,
+      runs: [],
+      actions: [],
+      links: [],
+    });
+  });
+
+  test('loading and first time load', () => {
+    _testOnly_resetState();
+    updateStateSetProvider(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isFalse(current().loading);
+    assert.isTrue(current().firstTimeLoad);
+    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current().loading);
+    assert.isTrue(current().firstTimeLoad);
+    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+    assert.isFalse(current().loading);
+    assert.isFalse(current().firstTimeLoad);
+    updateStateSetLoading(PLUGIN_NAME, ChecksPatchset.LATEST);
+    assert.isTrue(current().loading);
+    assert.isFalse(current().firstTimeLoad);
+    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+    assert.isFalse(current().loading);
+    assert.isFalse(current().firstTimeLoad);
+  });
+
+  test('updateStateSetResults', () => {
+    _testOnly_resetState();
+    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+    assert.lengthOf(current().runs, 1);
+    assert.lengthOf(current().runs[0].results!, 1);
+  });
+
+  test('updateStateUpdateResult', () => {
+    _testOnly_resetState();
+    updateStateSetResults(PLUGIN_NAME, RUNS, [], [], ChecksPatchset.LATEST);
+    assert.equal(
+      current().runs[0].results![0].summary,
+      RUNS[0]!.results![0].summary
+    );
+    const result = RUNS[0].results![0];
+    const updatedResult = {...result, summary: 'new'};
+    updateStateUpdateResult(
+      PLUGIN_NAME,
+      RUNS[0],
+      updatedResult,
+      ChecksPatchset.LATEST
+    );
+    assert.lengthOf(current().runs, 1);
+    assert.lengthOf(current().runs[0].results!, 1);
+    assert.equal(current().runs[0].results![0].summary, 'new');
+  });
+});
diff --git a/polygerrit-ui/app/services/checks/checks-service.ts b/polygerrit-ui/app/services/checks/checks-service.ts
index 250cea5..5ebc13c 100644
--- a/polygerrit-ui/app/services/checks/checks-service.ts
+++ b/polygerrit-ui/app/services/checks/checks-service.ts
@@ -14,17 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {
   catchError,
   filter,
   switchMap,
+  takeUntil,
   takeWhile,
   throttleTime,
   withLatestFrom,
 } from 'rxjs/operators';
 import {
+  Action,
   ChangeData,
+  CheckResult,
+  CheckRun,
   ChecksApiConfig,
   ChecksProvider,
   FetchResponse,
@@ -32,7 +35,8 @@
 } from '../../api/checks';
 import {change$, changeNum$, latestPatchNum$} from '../change/change-model';
 import {
-  checksPatchsetNumber$,
+  ChecksPatchset,
+  checksSelectedPatchsetNumber$,
   checkToPluginMap$,
   updateStateSetError,
   updateStateSetLoading,
@@ -40,6 +44,7 @@
   updateStateSetPatchset,
   updateStateSetProvider,
   updateStateSetResults,
+  updateStateUpdateResult,
 } from './checks-model';
 import {
   BehaviorSubject,
@@ -50,10 +55,14 @@
   Subject,
   timer,
 } from 'rxjs';
-import {PatchSetNumber} from '../../types/common';
+import {ChangeInfo, NumericChangeId, PatchSetNumber} from '../../types/common';
 import {getCurrentRevision} from '../../utils/change-util';
 import {getShaByPatchNum} from '../../utils/patch-set-util';
 import {assertIsDefined} from '../../utils/common-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+import {routerPatchNum$} from '../router/router-model';
+import {Execution} from '../../constants/reporting';
+import {fireAlert, fireEvent} from '../../utils/event-util';
 
 export class ChecksService {
   private readonly providers: {[name: string]: ChecksProvider} = {};
@@ -62,22 +71,39 @@
 
   private checkToPluginMap = new Map<string, string>();
 
+  private changeNum?: NumericChangeId;
+
+  private latestPatchNum?: PatchSetNumber;
+
   private readonly documentVisibilityChange$ = new BehaviorSubject(undefined);
 
-  constructor() {
+  constructor(readonly reporting: ReportingService) {
+    changeNum$.subscribe(x => (this.changeNum = x));
     checkToPluginMap$.subscribe(map => {
       this.checkToPluginMap = map;
     });
-    latestPatchNum$.subscribe(num => {
-      updateStateSetPatchset(num);
-    });
+    combineLatest([routerPatchNum$, latestPatchNum$]).subscribe(
+      ([routerPs, latestPs]) => {
+        this.latestPatchNum = latestPs;
+        if (latestPs === undefined) {
+          this.setPatchset(undefined);
+        } else if (typeof routerPs === 'number') {
+          this.setPatchset(routerPs);
+        } else {
+          this.setPatchset(latestPs);
+        }
+      }
+    );
     document.addEventListener('visibilitychange', () => {
       this.documentVisibilityChange$.next(undefined);
     });
+    document.addEventListener('reload', () => {
+      this.reloadAll();
+    });
   }
 
-  setPatchset(num: PatchSetNumber) {
-    updateStateSetPatchset(num);
+  setPatchset(num?: PatchSetNumber) {
+    updateStateSetPatchset(num === this.latestPatchNum ? undefined : num);
   }
 
   reload(pluginName: string) {
@@ -94,14 +120,68 @@
     if (plugin) this.reload(plugin);
   }
 
+  updateResult(pluginName: string, run: CheckRun, result: CheckResult) {
+    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.LATEST);
+    updateStateUpdateResult(pluginName, run, result, ChecksPatchset.SELECTED);
+  }
+
+  triggerAction(action?: Action, run?: CheckRun) {
+    if (!action?.callback) return;
+    if (!this.changeNum) return;
+    const patchSet = run?.patchset ?? this.latestPatchNum;
+    if (!patchSet) return;
+    const promise = action.callback(
+      this.changeNum,
+      patchSet,
+      run?.attempt,
+      run?.externalId,
+      run?.checkName,
+      action.name
+    );
+    // If plugins return undefined or not a promise, then show no toast.
+    if (!promise?.then) return;
+
+    fireAlert(document, `Triggering action '${action.name}' ...`);
+    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 || result.message) {
+          fireAlert(document, `${result.message ?? result.errorMessage}`);
+        } else {
+          fireEvent(document, 'hide-alert');
+        }
+        if (result.shouldReload) {
+          this.reloadForCheck(run?.checkName);
+        }
+      });
+  }
+
   register(
     pluginName: string,
     provider: ChecksProvider,
     config: ChecksApiConfig
   ) {
+    if (this.providers[pluginName]) {
+      console.warn(
+        `Plugin '${pluginName}' was trying to register twice as a Checks UI provider. Ignored.`
+      );
+      return;
+    }
     this.providers[pluginName] = provider;
     this.reloadSubjects[pluginName] = new BehaviorSubject<void>(undefined);
-    updateStateSetProvider(pluginName, config);
+    updateStateSetProvider(pluginName, ChecksPatchset.LATEST);
+    updateStateSetProvider(pluginName, ChecksPatchset.SELECTED);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.LATEST);
+    this.initFetchingOfData(pluginName, config, ChecksPatchset.SELECTED);
+  }
+
+  initFetchingOfData(
+    pluginName: string,
+    config: ChecksApiConfig,
+    patchset: ChecksPatchset
+  ) {
     const pollIntervalMs = (config?.fetchPollingIntervalSeconds ?? 60) * 1000;
     // Various events should trigger fetching checks from the provider:
     // 1. Change number and patchset number changes.
@@ -110,7 +190,9 @@
     // 4. A hidden Gerrit tab becoming visible.
     combineLatest([
       changeNum$,
-      checksPatchsetNumber$,
+      patchset === ChecksPatchset.LATEST
+        ? latestPatchNum$
+        : checksSelectedPatchsetNumber$,
       this.reloadSubjects[pluginName].pipe(throttleTime(1000)),
       timer(0, pollIntervalMs),
       this.documentVisibilityChange$,
@@ -121,66 +203,103 @@
         withLatestFrom(change$),
         switchMap(
           ([[changeNum, patchNum], change]): Observable<FetchResponse> => {
-            if (
-              !change ||
-              !changeNum ||
-              !patchNum ||
-              typeof patchNum !== 'number'
-            ) {
-              return of({
-                responseCode: ResponseCode.OK,
-                runs: [],
-              });
-            }
+            if (!change || !changeNum || !patchNum) return of(this.empty());
+            if (typeof patchNum !== 'number') return of(this.empty());
             assertIsDefined(change.revisions, 'change.revisions');
             const patchsetSha = getShaByPatchNum(change.revisions, patchNum);
             // Sometimes patchNum is updated earlier than change, so change
             // revisions don't have patchNum yet
-            if (!patchsetSha) {
-              return of({
-                responseCode: ResponseCode.OK,
-                runs: [],
-              });
-            }
+            if (!patchsetSha) return of(this.empty());
             const data: ChangeData = {
               changeNumber: changeNum,
               patchsetNumber: patchNum,
               patchsetSha,
               repo: change.project,
-              commmitMessage: getCurrentRevision(change)?.commit?.message,
-              changeInfo: change,
+              commitMessage: getCurrentRevision(change)?.commit?.message,
+              changeInfo: change as ChangeInfo,
             };
-            updateStateSetLoading(pluginName);
-            return from(this.providers[pluginName].fetch(data));
+            return this.fetchResults(pluginName, data, patchset);
           }
         ),
         catchError(e => {
-          const errorResponse: FetchResponse = {
-            responseCode: ResponseCode.ERROR,
-            errorMessage: `Error message from plugin '${pluginName}': ${e}`,
-          };
-          return of(errorResponse);
+          // This should not happen and is really severe, because it means that
+          // the Observable has terminated and we won't recover from that. No
+          // further attempts to fetch results for this plugin will be made.
+          this.reporting.error(e, `checks-service crash for ${pluginName}`);
+          return of(this.createErrorResponse(pluginName, e));
         })
       )
       .subscribe(response => {
         switch (response.responseCode) {
-          case ResponseCode.ERROR:
-            assertIsDefined(response.errorMessage, 'errorMessage');
-            updateStateSetError(pluginName, response.errorMessage);
+          case ResponseCode.ERROR: {
+            const message = response.errorMessage ?? '-';
+            this.reporting.reportExecution(Execution.CHECKS_API_ERROR, {
+              plugin: pluginName,
+              message,
+            });
+            updateStateSetError(pluginName, message, patchset);
             break;
-          case ResponseCode.NOT_LOGGED_IN:
+          }
+          case ResponseCode.NOT_LOGGED_IN: {
             assertIsDefined(response.loginCallback, 'loginCallback');
-            updateStateSetNotLoggedIn(pluginName, response.loginCallback);
+            this.reporting.reportExecution(Execution.CHECKS_API_NOT_LOGGED_IN, {
+              plugin: pluginName,
+            });
+            updateStateSetNotLoggedIn(
+              pluginName,
+              response.loginCallback,
+              patchset
+            );
             break;
-          case ResponseCode.OK:
+          }
+          case ResponseCode.OK: {
             updateStateSetResults(
               pluginName,
               response.runs ?? [],
               response.actions ?? [],
-              response.links ?? []
+              response.links ?? [],
+              patchset
             );
             break;
+          }
         }
       });
   }
+
+  private empty(): FetchResponse {
+    return {
+      responseCode: ResponseCode.OK,
+      runs: [],
+    };
+  }
+
+  private createErrorResponse(
+    pluginName: string,
+    message: object
+  ): FetchResponse {
+    return {
+      responseCode: ResponseCode.ERROR,
+      errorMessage:
+        `Error message from plugin '${pluginName}':` +
+        ` ${JSON.stringify(message)}`,
+    };
+  }
+
+  private fetchResults(
+    pluginName: string,
+    data: ChangeData,
+    patchset: ChecksPatchset
+  ): Observable<FetchResponse> {
+    updateStateSetLoading(pluginName, patchset);
+    const timer = this.reporting.getTimer('ChecksPluginFetch');
+    const fetchPromise = this.providers[pluginName]
+      .fetch(data)
+      .then(response => {
+        timer.end({pluginName});
+        return response;
+      });
+    return from(fetchPromise).pipe(
+      catchError(e => of(this.createErrorResponse(pluginName, e)))
+    );
+  }
 }
diff --git a/polygerrit-ui/app/services/checks/checks-util.ts b/polygerrit-ui/app/services/checks/checks-util.ts
index 15841f3..18cc076 100644
--- a/polygerrit-ui/app/services/checks/checks-util.ts
+++ b/polygerrit-ui/app/services/checks/checks-util.ts
@@ -17,8 +17,9 @@
 import {
   Action,
   Category,
-  CheckRun as CheckRunApi,
   CheckResult as CheckResultApi,
+  CheckRun as CheckRunApi,
+  Link,
   LinkIcon,
   RunStatus,
 } from '../../api/checks';
@@ -42,6 +43,10 @@
       return 'help-outline';
     case LinkIcon.REPORT_BUG:
       return 'bug';
+    case LinkIcon.CODE:
+      return 'code';
+    case LinkIcon.FILE_PRESENT:
+      return 'file-present';
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -67,6 +72,10 @@
       return 'Link to help page';
     case LinkIcon.REPORT_BUG:
       return 'Link for reporting a problem';
+    case LinkIcon.CODE:
+      return 'Link to code';
+    case LinkIcon.FILE_PRESENT:
+      return 'Link to file';
     default:
       // We don't throw an assertion error here, because plugins don't have to
       // be written in TypeScript, so we may encounter arbitrary strings for
@@ -79,28 +88,75 @@
   if (hasResultsOf(run, Category.ERROR)) return Category.ERROR;
   if (hasResultsOf(run, Category.WARNING)) return Category.WARNING;
   if (hasResultsOf(run, Category.INFO)) return Category.INFO;
+  if (hasResultsOf(run, Category.SUCCESS)) return Category.SUCCESS;
   return undefined;
 }
 
-export function iconForCategory(category: Category | 'SUCCESS') {
-  switch (category) {
+export function isCategory(
+  catStat?: Category | RunStatus
+): catStat is Category {
+  return (
+    catStat === Category.ERROR ||
+    catStat === Category.WARNING ||
+    catStat === Category.INFO ||
+    catStat === Category.SUCCESS
+  );
+}
+
+export function isStatus(catStat?: Category | RunStatus): catStat is RunStatus {
+  return (
+    catStat === RunStatus.COMPLETED ||
+    catStat === RunStatus.RUNNABLE ||
+    catStat === RunStatus.RUNNING
+  );
+}
+
+export function labelFor(catStat: Category | RunStatus) {
+  switch (catStat) {
+    case Category.ERROR:
+      return 'error';
+    case Category.INFO:
+      return 'info';
+    case Category.WARNING:
+      return 'warning';
+    case Category.SUCCESS:
+      return 'success';
+    case RunStatus.COMPLETED:
+      return 'completed';
+    case RunStatus.RUNNABLE:
+      return 'runnable';
+    case RunStatus.RUNNING:
+      return 'running';
+    default:
+      assertNever(catStat, `Unsupported category/status: ${catStat}`);
+  }
+}
+
+export function iconFor(catStat: Category | RunStatus) {
+  switch (catStat) {
     case Category.ERROR:
       return 'error';
     case Category.INFO:
       return 'info-outline';
     case Category.WARNING:
       return 'warning';
-    case 'SUCCESS':
+    case Category.SUCCESS:
       return 'check-circle-outline';
+    // 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(category, `Unsupported category: ${category}`);
+      assertNever(catStat, `Unsupported category/status: ${catStat}`);
   }
 }
 
-enum PRIMARY_STATUS_ACTIONS {
+export enum PRIMARY_STATUS_ACTIONS {
   RERUN = 'rerun',
   RUN = 'run',
-  CANCEL = 'cancel',
 }
 
 export function toCanonicalAction(action: Action, status: RunStatus) {
@@ -108,28 +164,39 @@
   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) {
+export function headerForStatus(status: RunStatus) {
+  switch (status) {
+    case RunStatus.COMPLETED:
+      return 'Completed';
+    case RunStatus.RUNNABLE:
+      return 'Not run';
+    case RunStatus.RUNNING:
+      return 'Running';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+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;
+      return undefined;
     default:
       assertNever(status, `Unsupported status: ${status}`);
   }
 }
 
-export function primaryRunAction(run: CheckRun): Action | undefined {
+export function primaryRunAction(run?: CheckRun): Action | undefined {
+  if (!run) return undefined;
   return runActions(run).filter(
-    action => action.name === primaryActionName(run.status)
+    action => !action.disabled && action.name === primaryActionName(run.status)
   )[0];
 }
 
@@ -140,24 +207,10 @@
 
 export function iconForRun(run: CheckRun) {
   if (run.status !== RunStatus.COMPLETED) {
-    return iconForStatus(run.status);
+    return iconFor(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}`);
+    return category ? iconFor(category) : iconFor(run.status);
   }
 }
 
@@ -210,12 +263,14 @@
 export function level(cat?: Category) {
   if (!cat) return -1;
   switch (cat) {
-    case Category.INFO:
+    case Category.SUCCESS:
       return 0;
-    case Category.WARNING:
+    case Category.INFO:
       return 1;
-    case Category.ERROR:
+    case Category.WARNING:
       return 2;
+    case Category.ERROR:
+      return 3;
   }
 }
 
@@ -232,20 +287,6 @@
   }
 }
 
-export function fireActionTriggered(
-  target: EventTarget,
-  action: Action,
-  run?: CheckRun
-) {
-  target.dispatchEvent(
-    new CustomEvent('action-triggered', {
-      detail: {action, run},
-      composed: true,
-      bubbles: true,
-    })
-  );
-}
-
 export interface AttemptDetail {
   attempt: number | undefined;
   icon: string;
@@ -291,6 +332,7 @@
 export function fromApiToInternalRun(run: CheckRunApi): CheckRun {
   return {
     ...run,
+    pluginName: 'fake',
     internalRunId: 'fake',
     isSingleAttempt: false,
     isLatestAttempt: false,
@@ -305,3 +347,20 @@
     internalResultId: 'fake',
   };
 }
+
+function allPrimaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => link.primary);
+}
+
+export function firstPrimaryLink(result?: CheckResultApi): Link | undefined {
+  return allPrimaryLinks(result).find(link => link.icon === LinkIcon.EXTERNAL);
+}
+
+export function otherPrimaryLinks(result?: CheckResultApi): Link[] {
+  const first = firstPrimaryLink(result);
+  return allPrimaryLinks(result).filter(link => link !== first);
+}
+
+export function secondaryLinks(result?: CheckResultApi): Link[] {
+  return (result?.links ?? []).filter(link => !link.primary);
+}
diff --git a/polygerrit-ui/app/services/comments/comments-model.ts b/polygerrit-ui/app/services/comments/comments-model.ts
new file mode 100644
index 0000000..850acbc
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-model.ts
@@ -0,0 +1,206 @@
+/**
+ * @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 {BehaviorSubject, Observable} from 'rxjs';
+import {distinctUntilChanged, map} from 'rxjs/operators';
+import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
+import {
+  CommentInfo,
+  PathToCommentsInfoMap,
+  RobotCommentInfo,
+} from '../../types/common';
+import {addPath, DraftInfo} from '../../utils/comment-util';
+
+interface CommentState {
+  comments: PathToCommentsInfoMap;
+  robotComments: {[path: string]: RobotCommentInfo[]};
+  drafts: {[path: string]: DraftInfo[]};
+  portedComments: PathToCommentsInfoMap;
+  portedDrafts: PathToCommentsInfoMap;
+  /**
+   * If a draft is discarded by the user, then we temporarily keep it in this
+   * array in case the user decides to Undo the discard operation and bring the
+   * draft back. Once restored, the draft is removed from this array.
+   */
+  discardedDrafts: DraftInfo[];
+}
+
+const initialState: CommentState = {
+  comments: {},
+  robotComments: {},
+  drafts: {},
+  portedComments: {},
+  portedDrafts: {},
+  discardedDrafts: [],
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+export function _testOnly_resetState() {
+  privateState$.next(initialState);
+}
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const commentState$: Observable<CommentState> = privateState$;
+
+export function _testOnly_getState() {
+  return privateState$.getValue();
+}
+
+export function _testOnly_setState(state: CommentState) {
+  privateState$.next(state);
+}
+
+export const drafts$ = commentState$.pipe(
+  map(commentState => commentState.drafts),
+  distinctUntilChanged()
+);
+
+export const discardedDrafts$ = commentState$.pipe(
+  map(commentState => commentState.discardedDrafts),
+  distinctUntilChanged()
+);
+
+// Emits a new value even if only a single draft is changed. Components should
+// aim to subsribe to something more specific.
+export const changeComments$ = commentState$.pipe(
+  map(
+    commentState =>
+      new ChangeComments(
+        commentState.comments,
+        commentState.robotComments,
+        commentState.drafts,
+        commentState.portedComments,
+        commentState.portedDrafts
+      )
+  ),
+  distinctUntilChanged()
+);
+
+function publishState(state: CommentState) {
+  privateState$.next(state);
+}
+
+export function updateStateComments(comments?: {
+  [path: string]: CommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.comments = addPath(comments) || {};
+  publishState(nextState);
+}
+
+export function updateStateRobotComments(robotComments?: {
+  [path: string]: RobotCommentInfo[];
+}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.robotComments = addPath(robotComments) || {};
+  publishState(nextState);
+}
+
+export function updateStateDrafts(drafts?: {[path: string]: DraftInfo[]}) {
+  const nextState = {...privateState$.getValue()};
+  nextState.drafts = addPath(drafts) || {};
+  publishState(nextState);
+}
+
+export function updateStatePortedComments(
+  portedComments?: PathToCommentsInfoMap
+) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedComments = portedComments || {};
+  publishState(nextState);
+}
+
+export function updateStatePortedDrafts(portedDrafts?: PathToCommentsInfoMap) {
+  const nextState = {...privateState$.getValue()};
+  nextState.portedDrafts = portedDrafts || {};
+  publishState(nextState);
+}
+
+export function updateStateAddDiscardedDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
+  publishState(nextState);
+}
+
+export function updateStateUndoDiscardedDraft(draftID?: string) {
+  const nextState = {...privateState$.getValue()};
+  const drafts = [...nextState.discardedDrafts];
+  const index = drafts.findIndex(d => d.id === draftID);
+  if (index === -1) {
+    throw new Error('discarded draft not found');
+  }
+  drafts.splice(index, 1);
+  nextState.discardedDrafts = drafts;
+  publishState(nextState);
+}
+
+export function updateStateAddDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
+  else drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(
+    d =>
+      (d.__draftID && d.__draftID === draft.__draftID) ||
+      (d.id && d.id === draft.id)
+  );
+  if (index !== -1) {
+    drafts[draft.path][index] = draft;
+  } else {
+    drafts[draft.path].push(draft);
+  }
+  publishState(nextState);
+}
+
+export function updateStateUpdateDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  if (!drafts[draft.path])
+    throw new Error('draft: trying to edit non-existent draft');
+  drafts[draft.path] = [...drafts[draft.path]];
+  const index = drafts[draft.path].findIndex(
+    d =>
+      (d.__draftID && d.__draftID === draft.__draftID) ||
+      (d.id && d.id === draft.id)
+  );
+  if (index === -1) return;
+  drafts[draft.path][index] = draft;
+  publishState(nextState);
+}
+
+export function updateStateDeleteDraft(draft: DraftInfo) {
+  const nextState = {...privateState$.getValue()};
+  if (!draft.path) throw new Error('draft path undefined');
+  nextState.drafts = {...nextState.drafts};
+  const drafts = nextState.drafts;
+  const index = (drafts[draft.path] || []).findIndex(
+    d =>
+      (d.__draftID && d.__draftID === draft.__draftID) ||
+      (d.id && d.id === draft.id)
+  );
+  if (index === -1) return;
+  const discardedDraft = drafts[draft.path][index];
+  drafts[draft.path] = [...drafts[draft.path]];
+  drafts[draft.path].splice(index, 1);
+  publishState(nextState);
+  updateStateAddDiscardedDraft(discardedDraft);
+}
diff --git a/polygerrit-ui/app/services/comments/comments-model_test.ts b/polygerrit-ui/app/services/comments/comments-model_test.ts
new file mode 100644
index 0000000..e389254
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-model_test.ts
@@ -0,0 +1,56 @@
+/**
+ * @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';
+import {createDraft} from '../../test/test-data-generators';
+import {UrlEncodedCommentId} from '../../types/common';
+import {DraftInfo} from '../../utils/comment-util';
+import './comments-model';
+import {
+  updateStateDeleteDraft,
+  _testOnly_getState,
+  _testOnly_resetState,
+  _testOnly_setState,
+} from './comments-model';
+
+suite('comments model tests', () => {
+  test('updateStateDeleteDraft', () => {
+    _testOnly_resetState();
+    const draft = createDraft();
+    draft.id = '1' as UrlEncodedCommentId;
+    _testOnly_setState({
+      comments: {},
+      robotComments: {},
+      drafts: {
+        [draft.path!]: [draft as DraftInfo],
+      },
+      portedComments: {},
+      portedDrafts: {},
+      discardedDrafts: [],
+    });
+    updateStateDeleteDraft(draft);
+    assert.deepEqual(_testOnly_getState(), {
+      comments: {},
+      robotComments: {},
+      drafts: {
+        'abc.txt': [],
+      },
+      portedComments: {},
+      portedDrafts: {},
+      discardedDrafts: [{...draft}],
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/comments/comments-service.ts b/polygerrit-ui/app/services/comments/comments-service.ts
new file mode 100644
index 0000000..16ee2f7
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service.ts
@@ -0,0 +1,111 @@
+/**
+ * @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 {NumericChangeId, PatchSetNum, RevisionId} from '../../types/common';
+import {DraftInfo, UIDraft} from '../../utils/comment-util';
+import {fireAlert} from '../../utils/event-util';
+import {CURRENT} from '../../utils/patch-set-util';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+import {
+  updateStateAddDraft,
+  updateStateDeleteDraft,
+  updateStateUpdateDraft,
+  updateStateComments,
+  updateStateRobotComments,
+  updateStateDrafts,
+  updateStatePortedComments,
+  updateStatePortedDrafts,
+  updateStateUndoDiscardedDraft,
+  discardedDrafts$,
+} from './comments-model';
+
+export class CommentsService {
+  private discardedDrafts?: UIDraft[] = [];
+
+  constructor(readonly restApiService: RestApiService) {
+    discardedDrafts$.subscribe(
+      discardedDrafts => (this.discardedDrafts = discardedDrafts)
+    );
+  }
+
+  /**
+   * Load all comments (with drafts and robot comments) for the given change
+   * number. The returned promise resolves when the comments have loaded, but
+   * does not yield the comment data.
+   */
+  // TODO(dhruvsri): listen to changeNum changes or reload event to update
+  // automatically
+  loadAll(changeNum: NumericChangeId, patchNum = CURRENT as RevisionId) {
+    const revision = patchNum;
+    this.restApiService
+      .getDiffComments(changeNum)
+      .then(comments => updateStateComments(comments));
+    this.restApiService
+      .getDiffRobotComments(changeNum)
+      .then(robotComments => updateStateRobotComments(robotComments));
+    this.restApiService
+      .getDiffDrafts(changeNum)
+      .then(drafts => updateStateDrafts(drafts));
+    this.restApiService
+      .getPortedComments(changeNum, revision)
+      .then(portedComments => updateStatePortedComments(portedComments));
+    this.restApiService
+      .getPortedDrafts(changeNum, revision)
+      .then(portedDrafts => updateStatePortedDrafts(portedDrafts));
+  }
+
+  restoreDraft(
+    changeNum: NumericChangeId,
+    patchNum: PatchSetNum,
+    draftID: string
+  ) {
+    const draft = {...this.discardedDrafts?.find(d => d.id === draftID)};
+    if (!draft) throw new Error('discarded draft not found');
+    // delete draft ID since we want to treat this as a new draft creation
+    delete draft.id;
+    this.restApiService
+      .saveDiffDraft(changeNum, patchNum, draft)
+      .then(result => {
+        if (!result.ok) {
+          fireAlert(document, 'Unable to restore draft');
+          return;
+        }
+        this.restApiService.getResponseObject(result).then(obj => {
+          const resComment = obj as unknown as DraftInfo;
+          resComment.patch_set = draft.patch_set;
+          updateStateAddDraft(resComment);
+          updateStateUndoDiscardedDraft(draftID);
+        });
+      });
+  }
+
+  addDraft(draft: DraftInfo) {
+    updateStateAddDraft(draft);
+  }
+
+  cancelDraft(draft: DraftInfo) {
+    updateStateUpdateDraft(draft);
+  }
+
+  editDraft(draft: DraftInfo) {
+    updateStateUpdateDraft(draft);
+  }
+
+  deleteDraft(draft: DraftInfo) {
+    updateStateDeleteDraft(draft);
+  }
+}
diff --git a/polygerrit-ui/app/services/comments/comments-service_test.ts b/polygerrit-ui/app/services/comments/comments-service_test.ts
new file mode 100644
index 0000000..604b5c4
--- /dev/null
+++ b/polygerrit-ui/app/services/comments/comments-service_test.ts
@@ -0,0 +1,126 @@
+/**
+ * @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';
+import {
+  createComment,
+  createFixSuggestionInfo,
+} from '../../test/test-data-generators';
+import {stubRestApi} from '../../test/test-utils';
+import {
+  NumericChangeId,
+  RobotId,
+  RobotRunId,
+  Timestamp,
+  UrlEncodedCommentId,
+} from '../../types/common';
+import {appContext} from '../app-context';
+import {CommentsService} from './comments-service';
+
+suite('change service tests', () => {
+  let commentsService: CommentsService;
+
+  test('loads logged-out', () => {
+    const changeNum = 1234 as NumericChangeId;
+    commentsService = new CommentsService(appContext.restApiService);
+    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+
+  test('loads logged-in', () => {
+    const changeNum = 1234 as NumericChangeId;
+
+    stubRestApi('getLoggedIn').returns(Promise.resolve(true));
+    const diffCommentsSpy = stubRestApi('getDiffComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '123' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+          },
+        ],
+      })
+    );
+    const diffRobotCommentsSpy = stubRestApi('getDiffRobotComments').returns(
+      Promise.resolve({
+        'foo.c': [
+          {
+            ...createComment(),
+            id: '321' as UrlEncodedCommentId,
+            message: 'Done',
+            updated: '2017-02-08 16:40:49' as Timestamp,
+            robot_id: 'robot_1' as RobotId,
+            robot_run_id: 'run_1' as RobotRunId,
+            properties: {},
+            fix_suggestions: [
+              createFixSuggestionInfo('fix_1'),
+              createFixSuggestionInfo('fix_2'),
+            ],
+          },
+        ],
+      })
+    );
+    const diffDraftsSpy = stubRestApi('getDiffDrafts').returns(
+      Promise.resolve({})
+    );
+
+    commentsService.loadAll(changeNum);
+    assert.isTrue(diffCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffRobotCommentsSpy.calledWithExactly(changeNum));
+    assert.isTrue(diffDraftsSpy.calledWithExactly(changeNum));
+  });
+});
diff --git a/polygerrit-ui/app/services/config/config-model.ts b/polygerrit-ui/app/services/config/config-model.ts
index 254760b..f5e10c5 100644
--- a/polygerrit-ui/app/services/config/config-model.ts
+++ b/polygerrit-ui/app/services/config/config-model.ts
@@ -14,12 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {ConfigInfo} from '../../types/common';
+import {ConfigInfo, ServerInfo} from '../../types/common';
 import {BehaviorSubject, Observable} from 'rxjs';
 import {map, distinctUntilChanged} from 'rxjs/operators';
 
 interface ConfigState {
   repoConfig?: ConfigInfo;
+  serverConfig?: ServerInfo;
 }
 
 // TODO: Figure out how to best enforce immutability of all states. Use Immer?
@@ -31,14 +32,22 @@
 // Re-exporting as Observable so that you can only subscribe, but not emit.
 export const configState$: Observable<ConfigState> = privateState$;
 
-// Must only be used by the change service or whatever is in control of this
-// model.
 export function updateRepoConfig(repoConfig?: ConfigInfo) {
   const current = privateState$.getValue();
   privateState$.next({...current, repoConfig});
 }
 
+export function updateServerConfig(serverConfig?: ServerInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, serverConfig});
+}
+
 export const repoConfig$ = configState$.pipe(
   map(configState => configState.repoConfig),
   distinctUntilChanged()
 );
+
+export const serverConfig$ = configState$.pipe(
+  map(configState => configState.serverConfig),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/config/config-service.ts b/polygerrit-ui/app/services/config/config-service.ts
index d6ff99c..7cd1538 100644
--- a/polygerrit-ui/app/services/config/config-service.ts
+++ b/polygerrit-ui/app/services/config/config-service.ts
@@ -14,17 +14,20 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {updateRepoConfig} from './config-model';
+import {updateRepoConfig, updateServerConfig} from './config-model';
 import {repo$} from '../change/change-model';
 import {appContext} from '../app-context';
 import {switchMap} from 'rxjs/operators';
-import {ConfigInfo, RepoName} from '../../types/common';
+import {ConfigInfo, RepoName, ServerInfo} from '../../types/common';
 import {from, of} from 'rxjs';
 
 export class ConfigService {
   private readonly restApiService = appContext.restApiService;
 
   constructor() {
+    from(this.restApiService.getConfig()).subscribe((config?: ServerInfo) => {
+      updateServerConfig(config);
+    });
     repo$
       .pipe(
         switchMap((repo?: RepoName) => {
diff --git a/polygerrit-ui/app/services/flags/flags.ts b/polygerrit-ui/app/services/flags/flags.ts
index 1a29055..863f95f 100644
--- a/polygerrit-ui/app/services/flags/flags.ts
+++ b/polygerrit-ui/app/services/flags/flags.ts
@@ -25,5 +25,6 @@
  */
 export enum KnownExperimentId {
   NEW_IMAGE_DIFF_UI = 'UiFeature__new_image_diff_ui',
-  TOKEN_HIGHLIGHTING = 'UiFeature__token_highlighting',
+  CHECKS_DEVELOPER = 'UiFeature__checks_developer',
+  SUBMIT_REQUIREMENTS_UI = 'UiFeature__submit_requirements_ui',
 }
diff --git a/polygerrit-ui/app/services/flags/flags_impl.ts b/polygerrit-ui/app/services/flags/flags_impl.ts
index fbfa833..18e225b 100644
--- a/polygerrit-ui/app/services/flags/flags_impl.ts
+++ b/polygerrit-ui/app/services/flags/flags_impl.ts
@@ -18,7 +18,7 @@
 
 declare global {
   interface Window {
-    ENABLED_EXPERIMENTS: string[];
+    ENABLED_EXPERIMENTS?: string[];
   }
 }
 
@@ -40,7 +40,7 @@
   }
 
   _loadExperiments(): Set<string> {
-    return new Set(window.ENABLED_EXPERIMENTS);
+    return new Set(window.ENABLED_EXPERIMENTS ?? []);
   }
 
   get enabledExperiments() {
diff --git a/polygerrit-ui/app/services/flags/flags_test.js b/polygerrit-ui/app/services/flags/flags_test.ts
similarity index 85%
rename from polygerrit-ui/app/services/flags/flags_test.js
rename to polygerrit-ui/app/services/flags/flags_test.ts
index 33508af..4ae11bf 100644
--- a/polygerrit-ui/app/services/flags/flags_test.js
+++ b/polygerrit-ui/app/services/flags/flags_test.ts
@@ -15,12 +15,12 @@
  * limitations under the License.
  */
 
-import '../../test/common-test-setup-karma.js';
-import {FlagsServiceImplementation} from './flags_impl.js';
+import '../../test/common-test-setup-karma';
+import {FlagsServiceImplementation} from './flags_impl';
 
 suite('flags tests', () => {
-  let originalEnabledExperiments;
-  let flags;
+  let originalEnabledExperiments: string[] | undefined;
+  let flags: FlagsServiceImplementation;
 
   suiteSetup(() => {
     originalEnabledExperiments = window.ENABLED_EXPERIMENTS;
@@ -41,4 +41,3 @@
     assert.deepEqual(flags.enabledExperiments, ['a']);
   });
 });
-
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 6fadfde..c254284 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_impl.ts
@@ -26,7 +26,7 @@
   Token,
 } from './gr-auth';
 
-const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
+export const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
 const MAX_GET_TOKEN_RETRIES = 2;
 
 interface ValidToken extends Token {
@@ -103,6 +103,15 @@
 
     return this.authCheckPromise
       .then(res => {
+        // Make a call that requires loading the body of the request. This makes it so that the browser
+        // can close the request even though callers of this method might only ever read headers.
+        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+        try {
+          res.clone().text();
+        } catch {
+          // Ignore error
+        }
+
         // auth-check will return 204 if authed
         // treat the rest as unauthed
         if (res.status === 204) {
@@ -220,7 +229,7 @@
       }
     }
     options.credentials = 'same-origin';
-    return fetch(url, options);
+    return this._ensureBodyLoaded(fetch(url, options));
   }
 
   private _getAccessToken(): Promise<string | null> {
@@ -286,6 +295,22 @@
     if (params.length) {
       url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
     }
-    return fetch(url, options);
+    return this._ensureBodyLoaded(fetch(url, options));
+  }
+
+  private _ensureBodyLoaded(response: Promise<Response>): Promise<Response> {
+    return response.then(response => {
+      if (!response.ok) {
+        // Make a call that requires loading the body of the request. This makes it so that the browser
+        // can close the request even though callers of this method might only ever read headers.
+        // See https://stackoverflow.com/questions/45816743/how-to-solve-this-caution-request-is-not-finished-yet-in-chrome
+        try {
+          response.clone().text();
+        } catch {
+          // Ignore error
+        }
+      }
+      return response;
+    });
   }
 }
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
new file mode 100644
index 0000000..3dbb4c3
--- /dev/null
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_mock.ts
@@ -0,0 +1,77 @@
+/**
+ * @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 {EventEmitterService} from '../gr-event-interface/gr-event-interface';
+import {
+  AuthRequestInit,
+  AuthService,
+  AuthStatus,
+  DefaultAuthOptions,
+  GetTokenCallback,
+} from './gr-auth';
+import {Auth} from './gr-auth_impl';
+
+export class GrAuthMock implements AuthService {
+  baseUrl = '';
+
+  private _status = AuthStatus.UNDETERMINED;
+
+  public eventEmitter: EventEmitterService;
+
+  constructor(eventEmitter: EventEmitterService) {
+    this.eventEmitter = eventEmitter;
+  }
+
+  get isAuthed() {
+    return this._status === Auth.STATUS.AUTHED;
+  }
+
+  private _setStatus(status: AuthStatus) {
+    if (this._status === status) return;
+    if (this._status === AuthStatus.AUTHED) {
+      this.eventEmitter.emit('auth-error', {
+        message: Auth.CREDS_EXPIRED_MSG,
+        action: 'Refresh credentials',
+      });
+    }
+    this._status = status;
+  }
+
+  get status() {
+    return this._status;
+  }
+
+  authCheck() {
+    return this.fetch(`${this.baseUrl}/auth-check`).then(res => {
+      if (res.status === 204) {
+        this._setStatus(Auth.STATUS.AUTHED);
+        return true;
+      } else {
+        this._setStatus(Auth.STATUS.NOT_AUTHED);
+        return false;
+      }
+    });
+  }
+
+  clearCache() {}
+
+  setup(_getToken: GetTokenCallback, _defaultOptions: DefaultAuthOptions) {}
+
+  fetch(_url: string, _opt_options?: AuthRequestInit): Promise<Response> {
+    return Promise.resolve(new Response());
+  }
+}
diff --git a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
index 80938ad..debba6d 100644
--- a/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
+++ b/polygerrit-ui/app/services/gr-auth/gr-auth_test.js
@@ -24,7 +24,7 @@
   let auth;
 
   setup(() => {
-    auth = appContext.authService;
+    auth = new Auth(appContext.eventEmitter);
   });
 
   suite('Auth class methods', () => {
@@ -34,40 +34,32 @@
       fakeFetch = sinon.stub(window, 'fetch');
     });
 
-    test('auth-check returns 403', done => {
+    test('auth-check returns 403', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        done();
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
     });
 
-    test('auth-check returns 204', done => {
+    test('auth-check returns 204', async () => {
       fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        done();
-      });
+      const authed = await auth.authCheck();
+      assert.isTrue(authed);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
     });
 
-    test('auth-check returns 502', done => {
+    test('auth-check returns 502', async () => {
       fakeFetch.returns(Promise.resolve({status: 502}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        done();
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
     });
 
-    test('auth-check failed', done => {
+    test('auth-check failed', async () => {
       fakeFetch.returns(Promise.reject(new Error('random error')));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.ERROR);
-        done();
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
     });
   });
 
@@ -80,129 +72,105 @@
       fakeFetch = sinon.stub(window, 'fetch');
     });
 
-    test('cache auth-check result', done => {
+    test('cache auth-check result', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
     });
 
-    test('clearCache should refetch auth-check result', done => {
+    test('clearCache should refetch auth-check result', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.clearCache();
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      auth.clearCache();
+      const authed2 = await auth.authCheck();
+      assert.isTrue(authed2);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
     });
 
-    test('cache expired on auth-check after certain time', done => {
+    test('cache expired on auth-check after certain time', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const authed2 = await auth.authCheck();
+      assert.isTrue(authed2);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
     });
 
-    test('no cache if auth-check failed', done => {
+    test('no cache if auth-check failed', async () => {
       fakeFetch.returns(Promise.reject(new Error('random error')));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.ERROR);
-        assert.equal(fakeFetch.callCount, 1);
-        auth.authCheck().then(() => {
-          assert.equal(fakeFetch.callCount, 2);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
+      assert.equal(fakeFetch.callCount, 1);
+      await auth.authCheck();
+      assert.equal(fakeFetch.callCount, 2);
     });
 
-    test('fire event when switch from authed to unauthed', done => {
+    test('fire event when switch from authed to unauthed', async () => {
       fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 403}));
-        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-          assert.isTrue(emitStub.called);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isTrue(authed);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.resolve({status: 403}));
+      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      assert.isTrue(emitStub.called);
     });
 
-    test('fire event when switch from authed to error', done => {
+    test('fire event when switch from authed to error', async () => {
       fakeFetch.returns(Promise.resolve({status: 204}));
-      auth.authCheck().then(authed => {
-        assert.isTrue(authed);
-        assert.equal(auth.status, Auth.STATUS.AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.reject(new Error('random error')));
-        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.isTrue(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.ERROR);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isTrue(authed);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.isTrue(emitStub.called);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
     });
 
-    test('no event from non-authed to other status', done => {
+    test('no event from non-authed to other status', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.resolve({status: 204}));
-        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-        auth.authCheck().then(authed2 => {
-          assert.isTrue(authed2);
-          assert.isFalse(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.AUTHED);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.resolve({status: 204}));
+      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const authed2 = await auth.authCheck();
+      assert.isTrue(authed2);
+      assert.isFalse(emitStub.called);
+      assert.equal(auth.status, Auth.STATUS.AUTHED);
     });
 
-    test('no event from non-authed to other status', done => {
+    test('no event from non-authed to other status', async () => {
       fakeFetch.returns(Promise.resolve({status: 403}));
-      auth.authCheck().then(authed => {
-        assert.isFalse(authed);
-        assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
-        clock.tick(1000 * 10000);
-        fakeFetch.returns(Promise.reject(new Error('random error')));
-        const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
-        auth.authCheck().then(authed2 => {
-          assert.isFalse(authed2);
-          assert.isFalse(emitStub.called);
-          assert.equal(auth.status, Auth.STATUS.ERROR);
-          done();
-        });
-      });
+      const authed = await auth.authCheck();
+      assert.isFalse(authed);
+      assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
+      clock.tick(1000 * 10000);
+      fakeFetch.returns(Promise.reject(new Error('random error')));
+      const emitStub = sinon.stub(appContext.eventEmitter, 'emit');
+      const authed2 = await auth.authCheck();
+      assert.isFalse(authed2);
+      assert.isFalse(emitStub.called);
+      assert.equal(auth.status, Auth.STATUS.ERROR);
     });
   });
 
@@ -211,26 +179,22 @@
       sinon.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
     });
 
-    test('GET', done => {
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.credentials, 'same-origin');
-        done();
-      });
+    test('GET', async () => {
+      await auth.fetch('/url', {bar: 'bar'});
+      const [url, options] = fetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.credentials, 'same-origin');
     });
 
-    test('POST', done => {
+    test('POST', async () => {
       sinon.stub(auth, '_getCookie')
           .withArgs('XSRF_TOKEN')
           .returns('foobar');
-      auth.fetch('/url', {method: 'POST'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.credentials, 'same-origin');
-        assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
-        done();
-      });
+      await auth.fetch('/url', {method: 'POST'});
+      const [url, options] = fetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.credentials, 'same-origin');
+      assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
     });
   });
 
@@ -254,45 +218,36 @@
       auth.setup(getToken);
     });
 
-    test('base url support', done => {
+    test('base url support', async () => {
       const baseUrl = 'http://foo';
       stubBaseUrl(baseUrl);
-      auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
-        const [url] = fetch.lastCall.args;
-        assert.equal(url, 'http://foo/a/url?access_token=zbaz');
-        done();
-      });
+      await auth.fetch(baseUrl + '/url', {bar: 'bar'});
+      const [url] = fetch.lastCall.args;
+      assert.equal(url, 'http://foo/a/url?access_token=zbaz');
     });
 
-    test('fetch not signed in', done => {
+    test('fetch not signed in', async () => {
       getToken.returns(Promise.resolve());
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.bar, 'bar');
-        assert.equal(Object.keys(options.headers).length, 0);
-        done();
-      });
+      await auth.fetch('/url', {bar: 'bar'});
+      const [url, options] = fetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.bar, 'bar');
+      assert.equal(Object.keys(options.headers).length, 0);
     });
 
-    test('fetch signed in', done => {
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/a/url?access_token=zbaz');
-        assert.equal(options.bar, 'bar');
-        done();
-      });
+    test('fetch signed in', async () => {
+      await auth.fetch('/url', {bar: 'bar'});
+      const [url, options] = fetch.lastCall.args;
+      assert.equal(url, '/a/url?access_token=zbaz');
+      assert.equal(options.bar, 'bar');
     });
 
-    test('getToken calls are cached', done => {
-      Promise.all([
-        auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
-        assert.equal(getToken.callCount, 1);
-        done();
-      });
+    test('getToken calls are cached', async () => {
+      await Promise.all([auth.fetch('/url-one'), auth.fetch('/url-two')]);
+      assert.equal(getToken.callCount, 1);
     });
 
-    test('getToken refreshes token', done => {
+    test('getToken refreshes token', async () => {
       sinon.stub(auth, '_isTokenValid');
       auth._isTokenValid
           .onFirstCall().returns(true)
@@ -300,27 +255,21 @@
           .returns(false)
           .onThirdCall()
           .returns(true);
-      auth.fetch('/url-one')
-          .then(() => {
-            getToken.returns(Promise.resolve(makeToken('bzzbb')));
-            return auth.fetch('/url-two');
-          })
-          .then(() => {
-            const [[firstUrl], [secondUrl]] = fetch.args;
-            assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
-            assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
-            done();
-          });
+      await auth.fetch('/url-one');
+      getToken.returns(Promise.resolve(makeToken('bzzbb')));
+      await auth.fetch('/url-two');
+
+      const [[firstUrl], [secondUrl]] = fetch.args;
+      assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+      assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
     });
 
-    test('signed in token error falls back to anonymous', done => {
+    test('signed in token error falls back to anonymous', async () => {
       getToken.returns(Promise.resolve('rubbish'));
-      auth.fetch('/url', {bar: 'bar'}).then(() => {
-        const [url, options] = fetch.lastCall.args;
-        assert.equal(url, '/url');
-        assert.equal(options.bar, 'bar');
-        done();
-      });
+      await auth.fetch('/url', {bar: 'bar'});
+      const [url, options] = fetch.lastCall.args;
+      assert.equal(url, '/url');
+      assert.equal(options.bar, 'bar');
     });
 
     test('_isTokenValid', () => {
@@ -337,37 +286,33 @@
       }));
     });
 
-    test('HTTP PUT with content type', done => {
+    test('HTTP PUT with content type', async () => {
       const originalOptions = {
         method: 'PUT',
         headers: new Headers({'Content-Type': 'mail/pigeon'}),
       };
-      auth.fetch('/url', originalOptions).then(() => {
-        assert.isTrue(getToken.called);
-        const [url, options] = fetch.lastCall.args;
-        assert.include(url, '$ct=mail%2Fpigeon');
-        assert.include(url, '$m=PUT');
-        assert.include(url, 'access_token=zbaz');
-        assert.equal(options.method, 'POST');
-        assert.equal(options.headers.get('Content-Type'), 'text/plain');
-        done();
-      });
+      await auth.fetch('/url', originalOptions);
+      assert.isTrue(getToken.called);
+      const [url, options] = fetch.lastCall.args;
+      assert.include(url, '$ct=mail%2Fpigeon');
+      assert.include(url, '$m=PUT');
+      assert.include(url, 'access_token=zbaz');
+      assert.equal(options.method, 'POST');
+      assert.equal(options.headers.get('Content-Type'), 'text/plain');
     });
 
-    test('HTTP PUT without content type', done => {
+    test('HTTP PUT without content type', async () => {
       const originalOptions = {
         method: 'PUT',
       };
-      auth.fetch('/url', originalOptions).then(() => {
-        assert.isTrue(getToken.called);
-        const [url, options] = fetch.lastCall.args;
-        assert.include(url, '$ct=text%2Fplain');
-        assert.include(url, '$m=PUT');
-        assert.include(url, 'access_token=zbaz');
-        assert.equal(options.method, 'POST');
-        assert.equal(options.headers.get('Content-Type'), 'text/plain');
-        done();
-      });
+      await auth.fetch('/url', originalOptions);
+      assert.isTrue(getToken.called);
+      const [url, options] = fetch.lastCall.args;
+      assert.include(url, '$ct=text%2Fplain');
+      assert.include(url, '$m=PUT');
+      assert.include(url, 'access_token=zbaz');
+      assert.equal(options.method, 'POST');
+      assert.equal(options.headers.get('Content-Type'), 'text/plain');
     });
   });
 });
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 6ce5eea..54a0f72e 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,6 +16,7 @@
  */
 
 import '../../test/common-test-setup-karma.js';
+import {mockPromise} from '../../test/test-utils.js';
 import {EventEmitter} from './gr-event-interface_impl.js';
 
 suite('gr-event-interface tests', () => {
@@ -29,37 +30,42 @@
       gerrit.removeAllListeners();
     });
 
-    test('communicate between plugin and Gerrit', done => {
+    test('communicate between plugin and Gerrit', async () => {
       const eventName = 'test-plugin-event';
       let p;
+      const promise = mockPromise();
       gerrit.on(eventName, e => {
         assert.equal(e.value, 'test');
         assert.equal(e.plugin, p);
-        done();
+        promise.resolve();
       });
       gerrit.install(plugin => {
         p = plugin;
         gerrit.emit(eventName, {value: 'test', plugin});
       }, '0.1',
       'http://test.com/plugins/testplugin/static/test.js');
+      await promise;
     });
 
-    test('listen on events from core', done => {
+    test('listen on events from core', async () => {
       const eventName = 'test-plugin-event';
+      const promise = mockPromise();
       gerrit.on(eventName, e => {
         assert.equal(e.value, 'test');
-        done();
+        promise.resolve();
       });
 
       gerrit.emit(eventName, {value: 'test'});
+      await promise;
     });
 
-    test('communicate across plugins', done => {
+    test('communicate across plugins', async () => {
       const eventName = 'test-plugin-event';
+      const promise = mockPromise();
       gerrit.install(plugin => {
         gerrit.on(eventName, e => {
           assert.equal(e.plugin.getPluginName(), 'testB');
-          done();
+          promise.resolve();
         });
       }, '0.1',
       'http://test.com/plugins/testA/static/testA.js');
@@ -68,6 +74,7 @@
         gerrit.emit(eventName, {plugin});
       }, '0.1',
       'http://test.com/plugins/testB/static/testB.js');
+      await promise;
     });
   });
 
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index d7081ce..06f1a0c 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -18,13 +18,18 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 export type EventValue = string | number | {error?: Error};
 
 export interface Timer {
   reset(): this;
-  end(): this;
+  end(eventDetails?: EventDetails): this;
   withMaximum(maximum: number): this;
 }
 
@@ -43,7 +48,7 @@
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
-  changeDisplayed(): void;
+  changeDisplayed(eventDetails?: EventDetails): void;
   changeFullyLoaded(): void;
   diffViewDisplayed(): void;
   diffViewFullyLoaded(): void;
@@ -52,7 +57,8 @@
   reportExtension(name: string): void;
   pluginLoaded(name: string): void;
   pluginsLoaded(pluginsList?: string[]): void;
-  error(err: unknown, reporter?: string, details?: EventDetails): void;
+  pluginsFailed(pluginsList?: string[]): void;
+  error(err: Error, reporter?: string, details?: EventDetails): void;
   /**
    * Reset named timer.
    */
@@ -89,7 +95,8 @@
    */
   reportRpcTiming(anonymizedUrl: string, elapsed: number): void;
   reportLifeCycle(eventName: LifeCycle, details?: EventDetails): void;
-
+  reportPluginLifeCycleLog(eventName: string, details?: EventDetails): void;
+  reportPluginInteractionLog(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
@@ -104,7 +111,10 @@
     object: string,
     method: string
   ): void;
-  reportInteraction(eventName: string, details?: EventDetails): void;
+  reportInteraction(
+    eventName: string | Interaction,
+    details?: EventDetails
+  ): void;
   /**
    * A draft interaction was started. Update the time-between-draft-actions
    * timer.
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 5fd4234..65a5784 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -21,7 +21,12 @@
 import {NumericChangeId} from '../../types/common';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution, LifeCycle, Timing} from '../../constants/reporting';
+import {
+  Execution,
+  Interaction,
+  LifeCycle,
+  Timing,
+} from '../../constants/reporting';
 
 // Latency reporting constants.
 
@@ -70,6 +75,14 @@
   },
 };
 
+const PLUGIN = {
+  TYPE: 'plugin-log',
+  CATEGORY: {
+    LIFECYCLE: 'lifecycle',
+    INTERACTION: 'interaction',
+  },
+};
+
 const STARTUP_TIMERS: {[name: string]: number} = {
   [Timing.PLUGINS_LOADED]: 0,
   [Timing.METRICS_PLUGIN_LOADED]: 0,
@@ -406,7 +419,10 @@
       eventInfo.inBackgroundTab = isInBackgroundTab;
     }
 
-    if (this._flagsService.enabledExperiments.length) {
+    if (
+      name === Timing.APP_STARTED &&
+      this._flagsService.enabledExperiments.length
+    ) {
       eventInfo.enabledExperiments = JSON.stringify(
         this._flagsService.enabledExperiments
       );
@@ -508,11 +524,12 @@
     }
   }
 
-  changeDisplayed() {
+  changeDisplayed(eventDetails?: EventDetails) {
+    eventDetails = {...eventDetails, ...this._pageLoadDetails()};
     if (hasOwnProperty(this._baselines, Timing.STARTUP_CHANGE_DISPLAYED)) {
-      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.STARTUP_CHANGE_DISPLAYED, eventDetails);
     } else {
-      this.timeEnd(Timing.CHANGE_DISPLAYED, this._pageLoadDetails());
+      this.timeEnd(Timing.CHANGE_DISPLAYED, eventDetails);
     }
   }
 
@@ -619,6 +636,18 @@
     );
   }
 
+  pluginsFailed(pluginsList?: string[]) {
+    if (!pluginsList || pluginsList.length === 0) return;
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.PLUGINS_INSTALLED,
+      LifeCycle.PLUGINS_FAILED,
+      undefined,
+      {pluginsList: pluginsList || []},
+      true
+    );
+  }
+
   /**
    * Reset named Timing.
    */
@@ -713,7 +742,7 @@
       },
 
       // Stop the timer and report the intervening time.
-      end: () => {
+      end: (eventDetails?: EventDetails) => {
         if (called) {
           throw new Error(`Timer for "${name}" already ended.`);
         }
@@ -725,7 +754,7 @@
           return timer;
         }
 
-        this._reportTiming(name, time);
+        this._reportTiming(name, time, eventDetails);
         return timer;
       },
 
@@ -772,7 +801,29 @@
     );
   }
 
-  reportInteraction(eventName: string, details: EventDetails) {
+  reportPluginLifeCycleLog(eventName: string, details: EventDetails) {
+    this.reporter(
+      PLUGIN.TYPE,
+      PLUGIN.CATEGORY.LIFECYCLE,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  reportPluginInteractionLog(eventName: string, details: EventDetails) {
+    this.reporter(
+      PLUGIN.TYPE,
+      PLUGIN.CATEGORY.INTERACTION,
+      eventName,
+      undefined,
+      details,
+      true
+    );
+  }
+
+  reportInteraction(eventName: string | Interaction, details: EventDetails) {
     this.reporter(
       INTERACTION.TYPE,
       INTERACTION.CATEGORY.DEFAULT,
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 c180439..337cf2f 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -17,7 +17,7 @@
 import {ReportingService, Timer} from './gr-reporting';
 import {EventDetails} from '../../api/reporting';
 import {PluginApi} from '../../api/plugin';
-import {Execution} from '../../constants/reporting';
+import {Execution, Interaction} from '../../constants/reporting';
 
 export class MockTimer implements Timer {
   end(): this {
@@ -56,6 +56,7 @@
   },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
+  pluginsFailed: () => {},
   recordDraftInteraction: () => {},
   reporter: () => {},
   reportErrorDialog: (message: string) => {
@@ -72,10 +73,15 @@
     log(`trackApi '${plugin}', ${object}, ${method}`);
   },
   reportExtension: () => {},
-  reportInteraction: (eventName: string, details?: EventDetails) => {
+  reportInteraction: (
+    eventName: string | Interaction,
+    details?: EventDetails
+  ) => {
     log(`reportInteraction '${eventName}': ${JSON.stringify(details)}`);
   },
   reportLifeCycle: () => {},
+  reportPluginLifeCycleLog: () => {},
+  reportPluginInteractionLog: () => {},
   reportRpcTiming: () => {},
   setRepoName: () => {},
   setChangeId: () => {},
diff --git a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
index 581f10b..445e932 100644
--- a/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
+++ b/polygerrit-ui/app/services/gr-rest-api/gr-rest-api.ts
@@ -207,10 +207,12 @@
 
   getChange(
     changeNum: ChangeId | NumericChangeId,
-    errFn: ErrorCallback
+    errFn?: ErrorCallback
   ): Promise<ChangeInfo | null>;
 
-  savePreferences(prefs: PreferencesInput): Promise<Response>;
+  savePreferences(
+    prefs: PreferencesInput
+  ): Promise<PreferencesInfo | undefined>;
 
   getDiffPreferences(): Promise<DiffPreferencesInfo | undefined>;
 
@@ -712,11 +714,6 @@
 
   addAccountEmail(email: string): Promise<Response>;
 
-  saveChangeReviewed(
-    changeNum: NumericChangeId,
-    reviewed: boolean
-  ): Promise<Response | undefined>;
-
   saveChangeStarred(
     changeNum: NumericChangeId,
     starred: boolean
diff --git a/polygerrit-ui/app/services/router/router-model.ts b/polygerrit-ui/app/services/router/router-model.ts
index 201cb3f..b3cdf9e 100644
--- a/polygerrit-ui/app/services/router/router-model.ts
+++ b/polygerrit-ui/app/services/router/router-model.ts
@@ -65,6 +65,11 @@
   });
 }
 
+export const routerView$ = routerState$.pipe(
+  map(state => state.view),
+  distinctUntilChanged()
+);
+
 export const routerChangeNum$ = routerState$.pipe(
   map(state => state.changeNum),
   distinctUntilChanged()
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
new file mode 100644
index 0000000..3c9e058
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-config.ts
@@ -0,0 +1,525 @@
+/**
+ * @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.
+ */
+
+/** Enum for all special shortcuts */
+import {ComboKey, Key, Modifier, Binding} from '../../utils/dom-util';
+
+export enum SPECIAL_SHORTCUT {
+  DOC_ONLY = 'DOC_ONLY',
+  GO_KEY = 'GO_KEY',
+  V_KEY = 'V_KEY',
+}
+
+/**
+ * Enum for all shortcut sections, where that shortcut should be applied to.
+ */
+export enum ShortcutSection {
+  ACTIONS = 'Actions',
+  DIFFS = 'Diffs',
+  EVERYWHERE = 'Global Shortcuts',
+  FILE_LIST = 'File list',
+  NAVIGATION = 'Navigation',
+  REPLY_DIALOG = 'Reply dialog',
+}
+
+/**
+ * Enum for all possible shortcut names.
+ */
+export enum Shortcut {
+  OPEN_SHORTCUT_HELP_DIALOG = 'OPEN_SHORTCUT_HELP_DIALOG',
+  GO_TO_USER_DASHBOARD = 'GO_TO_USER_DASHBOARD',
+  GO_TO_OPENED_CHANGES = 'GO_TO_OPENED_CHANGES',
+  GO_TO_MERGED_CHANGES = 'GO_TO_MERGED_CHANGES',
+  GO_TO_ABANDONED_CHANGES = 'GO_TO_ABANDONED_CHANGES',
+  GO_TO_WATCHED_CHANGES = 'GO_TO_WATCHED_CHANGES',
+
+  CURSOR_NEXT_CHANGE = 'CURSOR_NEXT_CHANGE',
+  CURSOR_PREV_CHANGE = 'CURSOR_PREV_CHANGE',
+  OPEN_CHANGE = 'OPEN_CHANGE',
+  NEXT_PAGE = 'NEXT_PAGE',
+  PREV_PAGE = 'PREV_PAGE',
+  TOGGLE_CHANGE_REVIEWED = 'TOGGLE_CHANGE_REVIEWED',
+  TOGGLE_CHANGE_STAR = 'TOGGLE_CHANGE_STAR',
+  REFRESH_CHANGE_LIST = 'REFRESH_CHANGE_LIST',
+  OPEN_SUBMIT_DIALOG = 'OPEN_SUBMIT_DIALOG',
+  TOGGLE_ATTENTION_SET = 'TOGGLE_ATTENTION_SET',
+
+  OPEN_REPLY_DIALOG = 'OPEN_REPLY_DIALOG',
+  OPEN_DOWNLOAD_DIALOG = 'OPEN_DOWNLOAD_DIALOG',
+  EXPAND_ALL_MESSAGES = 'EXPAND_ALL_MESSAGES',
+  COLLAPSE_ALL_MESSAGES = 'COLLAPSE_ALL_MESSAGES',
+  UP_TO_DASHBOARD = 'UP_TO_DASHBOARD',
+  UP_TO_CHANGE = 'UP_TO_CHANGE',
+  TOGGLE_DIFF_MODE = 'TOGGLE_DIFF_MODE',
+  REFRESH_CHANGE = 'REFRESH_CHANGE',
+  EDIT_TOPIC = 'EDIT_TOPIC',
+  DIFF_AGAINST_BASE = 'DIFF_AGAINST_BASE',
+  DIFF_AGAINST_LATEST = 'DIFF_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LEFT = 'DIFF_BASE_AGAINST_LEFT',
+  DIFF_RIGHT_AGAINST_LATEST = 'DIFF_RIGHT_AGAINST_LATEST',
+  DIFF_BASE_AGAINST_LATEST = 'DIFF_BASE_AGAINST_LATEST',
+
+  NEXT_LINE = 'NEXT_LINE',
+  PREV_LINE = 'PREV_LINE',
+  VISIBLE_LINE = 'VISIBLE_LINE',
+  NEXT_CHUNK = 'NEXT_CHUNK',
+  PREV_CHUNK = 'PREV_CHUNK',
+  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',
+  COLLAPSE_ALL_COMMENT_THREADS = 'COLLAPSE_ALL_COMMENT_THREADS',
+  LEFT_PANE = 'LEFT_PANE',
+  RIGHT_PANE = 'RIGHT_PANE',
+  TOGGLE_LEFT_PANE = 'TOGGLE_LEFT_PANE',
+  NEW_COMMENT = 'NEW_COMMENT',
+  SAVE_COMMENT = 'SAVE_COMMENT',
+  OPEN_DIFF_PREFS = 'OPEN_DIFF_PREFS',
+  TOGGLE_DIFF_REVIEWED = 'TOGGLE_DIFF_REVIEWED',
+
+  NEXT_FILE = 'NEXT_FILE',
+  PREV_FILE = 'PREV_FILE',
+  NEXT_FILE_WITH_COMMENTS = 'NEXT_FILE_WITH_COMMENTS',
+  PREV_FILE_WITH_COMMENTS = 'PREV_FILE_WITH_COMMENTS',
+  NEXT_UNREVIEWED_FILE = 'NEXT_UNREVIEWED_FILE',
+  CURSOR_NEXT_FILE = 'CURSOR_NEXT_FILE',
+  CURSOR_PREV_FILE = 'CURSOR_PREV_FILE',
+  OPEN_FILE = 'OPEN_FILE',
+  TOGGLE_FILE_REVIEWED = 'TOGGLE_FILE_REVIEWED',
+  TOGGLE_ALL_INLINE_DIFFS = 'TOGGLE_ALL_INLINE_DIFFS',
+  TOGGLE_INLINE_DIFF = 'TOGGLE_INLINE_DIFF',
+  TOGGLE_HIDE_ALL_COMMENT_THREADS = 'TOGGLE_HIDE_ALL_COMMENT_THREADS',
+  OPEN_FILE_LIST = 'OPEN_FILE_LIST',
+
+  OPEN_FIRST_FILE = 'OPEN_FIRST_FILE',
+  OPEN_LAST_FILE = 'OPEN_LAST_FILE',
+
+  SEARCH = 'SEARCH',
+  SEND_REPLY = 'SEND_REPLY',
+  EMOJI_DROPDOWN = 'EMOJI_DROPDOWN',
+  TOGGLE_BLAME = 'TOGGLE_BLAME',
+}
+
+export interface ShortcutHelpItem {
+  shortcut: Shortcut;
+  text: string;
+  bindings: Binding[];
+}
+
+export const config = new Map<ShortcutSection, ShortcutHelpItem[]>();
+
+function describe(
+  shortcut: Shortcut,
+  section: ShortcutSection,
+  text: string,
+  binding: Binding,
+  ...moreBindings: Binding[]
+) {
+  if (!config.has(section)) {
+    config.set(section, []);
+  }
+  const shortcuts = config.get(section);
+  if (shortcuts) {
+    shortcuts.push({shortcut, text, bindings: [binding, ...moreBindings]});
+  }
+}
+
+describe(Shortcut.SEARCH, ShortcutSection.EVERYWHERE, 'Search', {key: '/'});
+describe(
+  Shortcut.OPEN_SHORTCUT_HELP_DIALOG,
+  ShortcutSection.EVERYWHERE,
+  'Show this dialog',
+  {key: '?'}
+);
+describe(
+  Shortcut.GO_TO_USER_DASHBOARD,
+  ShortcutSection.EVERYWHERE,
+  'Go to User Dashboard',
+  {key: 'i', combo: ComboKey.G}
+);
+describe(
+  Shortcut.GO_TO_OPENED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Opened Changes',
+  {key: 'o', combo: ComboKey.G}
+);
+describe(
+  Shortcut.GO_TO_MERGED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Merged Changes',
+  {key: 'm', combo: ComboKey.G}
+);
+describe(
+  Shortcut.GO_TO_ABANDONED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Abandoned Changes',
+  {key: 'a', combo: ComboKey.G}
+);
+describe(
+  Shortcut.GO_TO_WATCHED_CHANGES,
+  ShortcutSection.EVERYWHERE,
+  'Go to Watched Changes',
+  {key: 'w', combo: ComboKey.G}
+);
+
+describe(
+  Shortcut.CURSOR_NEXT_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select next change',
+  {key: 'j'}
+);
+describe(
+  Shortcut.CURSOR_PREV_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Select previous change',
+  {key: 'k'}
+);
+describe(
+  Shortcut.OPEN_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Show selected change',
+  {key: 'o'}
+);
+describe(
+  Shortcut.NEXT_PAGE,
+  ShortcutSection.ACTIONS,
+  'Go to next page',
+  {key: 'n'},
+  {key: ']'}
+);
+describe(
+  Shortcut.PREV_PAGE,
+  ShortcutSection.ACTIONS,
+  'Go to previous page',
+  {key: 'p'},
+  {key: '['}
+);
+describe(
+  Shortcut.OPEN_REPLY_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open reply dialog to publish comments and add reviewers',
+  {key: 'a'}
+);
+describe(
+  Shortcut.OPEN_DOWNLOAD_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open download overlay',
+  {key: 'd'}
+);
+describe(
+  Shortcut.EXPAND_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Expand all messages',
+  {key: 'x'}
+);
+describe(
+  Shortcut.COLLAPSE_ALL_MESSAGES,
+  ShortcutSection.ACTIONS,
+  'Collapse all messages',
+  {key: 'z'}
+);
+describe(
+  Shortcut.REFRESH_CHANGE,
+  ShortcutSection.ACTIONS,
+  'Reload the change at the latest patch',
+  {key: 'R'}
+);
+describe(
+  Shortcut.TOGGLE_CHANGE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Mark/unmark change as reviewed',
+  {key: 'r'}
+);
+describe(
+  Shortcut.TOGGLE_FILE_REVIEWED,
+  ShortcutSection.ACTIONS,
+  'Toggle review flag on selected file',
+  {key: 'r'}
+);
+describe(
+  Shortcut.REFRESH_CHANGE_LIST,
+  ShortcutSection.ACTIONS,
+  'Refresh list of changes',
+  {key: 'R'}
+);
+describe(
+  Shortcut.TOGGLE_CHANGE_STAR,
+  ShortcutSection.ACTIONS,
+  'Star/unstar change',
+  {key: 's'}
+);
+describe(
+  Shortcut.OPEN_SUBMIT_DIALOG,
+  ShortcutSection.ACTIONS,
+  'Open submit dialog',
+  {key: 'S'}
+);
+describe(
+  Shortcut.TOGGLE_ATTENTION_SET,
+  ShortcutSection.ACTIONS,
+  'Toggle attention set status',
+  {key: 'T'}
+);
+describe(Shortcut.EDIT_TOPIC, ShortcutSection.ACTIONS, 'Add a change topic', {
+  key: 't',
+});
+describe(
+  Shortcut.DIFF_AGAINST_BASE,
+  ShortcutSection.DIFFS,
+  'Diff against base',
+  {key: Key.DOWN, combo: ComboKey.V},
+  {key: 's', combo: ComboKey.V}
+);
+describe(
+  Shortcut.DIFF_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff against latest patchset',
+  {key: Key.UP, combo: ComboKey.V},
+  {key: 'w', combo: ComboKey.V}
+);
+describe(
+  Shortcut.DIFF_BASE_AGAINST_LEFT,
+  ShortcutSection.DIFFS,
+  'Diff base against left',
+  {key: Key.LEFT, combo: ComboKey.V},
+  {key: 'a', combo: ComboKey.V}
+);
+describe(
+  Shortcut.DIFF_RIGHT_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff right against latest',
+  {key: Key.RIGHT, combo: ComboKey.V},
+  {key: 'd', combo: ComboKey.V}
+);
+describe(
+  Shortcut.DIFF_BASE_AGAINST_LATEST,
+  ShortcutSection.DIFFS,
+  'Diff base against latest',
+  {key: 'b', combo: ComboKey.V}
+);
+
+describe(
+  Shortcut.NEXT_LINE,
+  ShortcutSection.DIFFS,
+  'Go to next line',
+  {key: 'j'},
+  {key: Key.DOWN}
+);
+describe(
+  Shortcut.PREV_LINE,
+  ShortcutSection.DIFFS,
+  'Go to previous line',
+  {key: 'k'},
+  {key: Key.UP}
+);
+describe(
+  Shortcut.VISIBLE_LINE,
+  ShortcutSection.DIFFS,
+  'Move cursor to currently visible code',
+  {key: '.'}
+);
+describe(Shortcut.NEXT_CHUNK, ShortcutSection.DIFFS, 'Go to next diff chunk', {
+  key: 'n',
+});
+describe(
+  Shortcut.PREV_CHUNK,
+  ShortcutSection.DIFFS,
+  'Go to previous diff chunk',
+  {key: 'p'}
+);
+describe(
+  Shortcut.TOGGLE_ALL_DIFF_CONTEXT,
+  ShortcutSection.DIFFS,
+  'Toggle all diff context',
+  {key: 'X'}
+);
+describe(
+  Shortcut.NEXT_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to next comment thread',
+  {key: 'N'}
+);
+describe(
+  Shortcut.PREV_COMMENT_THREAD,
+  ShortcutSection.DIFFS,
+  'Go to previous comment thread',
+  {key: 'P'}
+);
+describe(
+  Shortcut.EXPAND_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Expand all comment threads',
+  {key: 'e', docOnly: true}
+);
+describe(
+  Shortcut.COLLAPSE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Collapse all comment threads',
+  {key: 'E', docOnly: true}
+);
+describe(
+  Shortcut.TOGGLE_HIDE_ALL_COMMENT_THREADS,
+  ShortcutSection.DIFFS,
+  'Hide/Display all comment threads',
+  {key: 'h'}
+);
+describe(Shortcut.LEFT_PANE, ShortcutSection.DIFFS, 'Select left pane', {
+  key: Key.LEFT,
+  modifiers: [Modifier.SHIFT_KEY],
+});
+describe(Shortcut.RIGHT_PANE, ShortcutSection.DIFFS, 'Select right pane', {
+  key: Key.RIGHT,
+  modifiers: [Modifier.SHIFT_KEY],
+});
+describe(
+  Shortcut.TOGGLE_LEFT_PANE,
+  ShortcutSection.DIFFS,
+  'Hide/show left diff',
+  {key: 'A'}
+);
+describe(Shortcut.NEW_COMMENT, ShortcutSection.DIFFS, 'Draft new comment', {
+  key: 'c',
+});
+describe(
+  Shortcut.SAVE_COMMENT,
+  ShortcutSection.DIFFS,
+  'Save comment',
+  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY]},
+  {key: Key.ENTER, modifiers: [Modifier.META_KEY]},
+  {key: 's', modifiers: [Modifier.CTRL_KEY]},
+  {key: 's', modifiers: [Modifier.META_KEY]}
+);
+describe(
+  Shortcut.OPEN_DIFF_PREFS,
+  ShortcutSection.DIFFS,
+  'Show diff preferences',
+  {key: ','}
+);
+describe(
+  Shortcut.TOGGLE_DIFF_REVIEWED,
+  ShortcutSection.DIFFS,
+  'Mark/unmark file as reviewed',
+  {key: 'r'}
+);
+describe(
+  Shortcut.TOGGLE_DIFF_MODE,
+  ShortcutSection.DIFFS,
+  'Toggle unified/side-by-side diff',
+  {key: 'm'}
+);
+describe(
+  Shortcut.NEXT_UNREVIEWED_FILE,
+  ShortcutSection.DIFFS,
+  'Mark file as reviewed and go to next unreviewed file',
+  {key: 'M'}
+);
+describe(Shortcut.TOGGLE_BLAME, ShortcutSection.DIFFS, 'Toggle blame', {
+  key: 'b',
+});
+describe(Shortcut.OPEN_FILE_LIST, ShortcutSection.DIFFS, 'Open file list', {
+  key: 'f',
+});
+describe(Shortcut.NEXT_FILE, ShortcutSection.NAVIGATION, 'Go to next file', {
+  key: ']',
+});
+describe(
+  Shortcut.PREV_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file',
+  {key: '['}
+);
+describe(
+  Shortcut.NEXT_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to next file that has comments',
+  {key: 'J'}
+);
+describe(
+  Shortcut.PREV_FILE_WITH_COMMENTS,
+  ShortcutSection.NAVIGATION,
+  'Go to previous file that has comments',
+  {key: 'K'}
+);
+describe(
+  Shortcut.OPEN_FIRST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to first file',
+  {key: ']'}
+);
+describe(
+  Shortcut.OPEN_LAST_FILE,
+  ShortcutSection.NAVIGATION,
+  'Go to last file',
+  {key: '['}
+);
+describe(
+  Shortcut.UP_TO_DASHBOARD,
+  ShortcutSection.NAVIGATION,
+  'Up to dashboard',
+  {key: 'u'}
+);
+describe(Shortcut.UP_TO_CHANGE, ShortcutSection.NAVIGATION, 'Up to change', {
+  key: 'u',
+});
+
+describe(
+  Shortcut.CURSOR_NEXT_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select next file',
+  {key: 'j'},
+  {key: Key.DOWN}
+);
+describe(
+  Shortcut.CURSOR_PREV_FILE,
+  ShortcutSection.FILE_LIST,
+  'Select previous file',
+  {key: 'k'},
+  {key: Key.UP}
+);
+describe(
+  Shortcut.OPEN_FILE,
+  ShortcutSection.FILE_LIST,
+  'Go to selected file',
+  {key: 'o'},
+  {key: Key.ENTER}
+);
+describe(
+  Shortcut.TOGGLE_ALL_INLINE_DIFFS,
+  ShortcutSection.FILE_LIST,
+  'Show/hide all inline diffs',
+  {key: 'I'}
+);
+describe(
+  Shortcut.TOGGLE_INLINE_DIFF,
+  ShortcutSection.FILE_LIST,
+  'Show/hide selected inline diff',
+  {key: 'i'}
+);
+
+describe(
+  Shortcut.SEND_REPLY,
+  ShortcutSection.REPLY_DIALOG,
+  'Send reply',
+  {key: Key.ENTER, modifiers: [Modifier.CTRL_KEY], docOnly: true},
+  {key: Key.ENTER, modifiers: [Modifier.META_KEY], docOnly: true}
+);
+describe(
+  Shortcut.EMOJI_DROPDOWN,
+  ShortcutSection.REPLY_DIALOG,
+  'Emoji dropdown',
+  {key: ':', docOnly: true}
+);
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
new file mode 100644
index 0000000..a26fa08
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service.ts
@@ -0,0 +1,363 @@
+/**
+ * @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 {
+  config,
+  Shortcut,
+  ShortcutHelpItem,
+  ShortcutSection,
+} from './shortcuts-config';
+import {disableShortcuts$} from '../user/user-model';
+import {
+  ComboKey,
+  eventMatchesShortcut,
+  isElementTarget,
+  Key,
+  Modifier,
+  Binding,
+  shouldSuppress,
+} from '../../utils/dom-util';
+import {ReportingService} from '../gr-reporting/gr-reporting';
+
+export type SectionView = Array<{binding: string[][]; text: string}>;
+
+export interface ShortcutListener {
+  shortcut: Shortcut;
+  listener: (e: KeyboardEvent) => void;
+}
+
+export function listen(
+  shortcut: Shortcut,
+  listener: (e: KeyboardEvent) => void
+): ShortcutListener {
+  return {shortcut, listener};
+}
+
+/**
+ * The interface for listener for shortcut events.
+ */
+export type ShortcutViewListener = (
+  viewMap?: Map<ShortcutSection, SectionView>
+) => void;
+
+function isComboKey(key: string): key is ComboKey {
+  return Object.values(ComboKey).includes(key as ComboKey);
+}
+
+export const COMBO_TIMEOUT_MS = 1000;
+
+/**
+ * Shortcuts service, holds all hosts, bindings and listeners.
+ */
+export class ShortcutsService {
+  /**
+   * Keeps track of the components that are currently active such that we can
+   * show a shortcut help dialog that only shows the shortcuts that are
+   * currently relevant.
+   */
+  private readonly activeShortcuts = new Map<HTMLElement, Shortcut[]>();
+
+  /**
+   * Keeps track of cleanup callbacks (which remove keyboard listeners) that
+   * have to be invoked when a component unregisters itself.
+   */
+  private readonly cleanupsPerHost = new Map<HTMLElement, (() => void)[]>();
+
+  /** Static map built in the constructor by iterating over the config. */
+  private readonly bindings = new Map<Shortcut, Binding[]>();
+
+  private readonly listeners = new Set<ShortcutViewListener>();
+
+  /**
+   * Stores the timestamp of the last combo key being pressed.
+   * This enabled key combinations like 'g+o' where we can check whether 'g' was
+   * pressed recently when 'o' is processed. Keys of this map must be items of
+   * COMBO_KEYS. Values are Date timestamps in milliseconds.
+   */
+  private comboKeyLastPressed: {key?: ComboKey; timestampMs?: number} = {};
+
+  /** Keeps track of the corresponding user preference. */
+  private shortcutsDisabled = false;
+
+  constructor(readonly reporting?: ReportingService) {
+    for (const section of config.keys()) {
+      const items = config.get(section) ?? [];
+      for (const item of items) {
+        this.bindings.set(item.shortcut, item.bindings);
+      }
+    }
+    disableShortcuts$.subscribe(x => (this.shortcutsDisabled = x));
+    document.addEventListener('keydown', (e: KeyboardEvent) => {
+      if (!isComboKey(e.key)) return;
+      if (this.shouldSuppress(e)) return;
+      this.comboKeyLastPressed = {key: e.key, timestampMs: Date.now()};
+    });
+  }
+
+  public _testOnly_isEmpty() {
+    return this.activeShortcuts.size === 0 && this.listeners.size === 0;
+  }
+
+  isInComboKeyMode() {
+    return Object.values(ComboKey).some(key =>
+      this.isInSpecificComboKeyMode(key)
+    );
+  }
+
+  isInSpecificComboKeyMode(comboKey: ComboKey) {
+    const {key, timestampMs} = this.comboKeyLastPressed;
+    return (
+      key === comboKey &&
+      timestampMs &&
+      Date.now() - timestampMs < COMBO_TIMEOUT_MS
+    );
+  }
+
+  /**
+   * TODO(brohlfs): Reconcile with the addShortcut() function in dom-util.
+   * Most likely we will just keep this one here, but that is something for a
+   * follow-up change.
+   */
+  addShortcut(
+    element: HTMLElement,
+    shortcut: Binding,
+    listener: (e: KeyboardEvent) => void
+  ) {
+    const wrappedListener = (e: KeyboardEvent) => {
+      if (e.repeat) return;
+      if (!eventMatchesShortcut(e, shortcut)) return;
+      if (shortcut.combo) {
+        if (!this.isInSpecificComboKeyMode(shortcut.combo)) return;
+      } else {
+        if (this.isInComboKeyMode()) return;
+      }
+      if (this.shouldSuppress(e)) return;
+      e.preventDefault();
+      e.stopPropagation();
+      listener(e);
+    };
+    element.addEventListener('keydown', wrappedListener);
+    return () => element.removeEventListener('keydown', wrappedListener);
+  }
+
+  shouldSuppress(e: KeyboardEvent) {
+    if (this.shortcutsDisabled) return true;
+    if (shouldSuppress(e)) return true;
+
+    // eg: {key: "k:keydown", ..., from: "gr-diff-view"}
+    let key = `${e.key}:${e.type}`;
+    if (this.isInSpecificComboKeyMode(ComboKey.G)) key = 'g+' + key;
+    if (this.isInSpecificComboKeyMode(ComboKey.V)) key = 'v+' + key;
+    if (e.shiftKey) key = 'shift+' + key;
+    if (e.ctrlKey) key = 'ctrl+' + key;
+    if (e.metaKey) key = 'meta+' + key;
+    if (e.altKey) key = 'alt+' + key;
+    let from = 'unknown';
+    if (isElementTarget(e.currentTarget)) {
+      from = e.currentTarget.tagName;
+    }
+    this.reporting?.reportInteraction('shortcut-triggered', {key, from});
+    return false;
+  }
+
+  createTitle(shortcutName: Shortcut, section: ShortcutSection) {
+    const desc = this.getDescription(section, shortcutName);
+    const shortcut = this.getShortcut(shortcutName);
+    return desc && shortcut ? `${desc} (shortcut: ${shortcut})` : '';
+  }
+
+  getBindingsForShortcut(shortcut: Shortcut) {
+    return this.bindings.get(shortcut);
+  }
+
+  attachHost(host: HTMLElement, shortcuts: ShortcutListener[]) {
+    this.activeShortcuts.set(
+      host,
+      shortcuts.map(s => s.shortcut)
+    );
+    const cleanups: (() => void)[] = [];
+    for (const s of shortcuts) {
+      const bindings = this.getBindingsForShortcut(s.shortcut);
+      for (const binding of bindings ?? []) {
+        if (binding.docOnly) continue;
+        cleanups.push(this.addShortcut(document.body, binding, s.listener));
+      }
+    }
+    this.cleanupsPerHost.set(host, cleanups);
+    this.notifyViewListeners();
+  }
+
+  detachHost(host: HTMLElement) {
+    this.activeShortcuts.delete(host);
+    const cleanups = this.cleanupsPerHost.get(host);
+    for (const cleanup of cleanups ?? []) cleanup();
+    this.notifyViewListeners();
+    return true;
+  }
+
+  addListener(listener: ShortcutViewListener) {
+    this.listeners.add(listener);
+    listener(this.directoryView());
+  }
+
+  removeListener(listener: ShortcutViewListener) {
+    return this.listeners.delete(listener);
+  }
+
+  getDescription(section: ShortcutSection, shortcutName: Shortcut) {
+    const bindings = config.get(section);
+    if (!bindings) return '';
+    const binding = bindings.find(binding => binding.shortcut === shortcutName);
+    return binding?.text ?? '';
+  }
+
+  getShortcut(shortcutName: Shortcut) {
+    const bindings = this.bindings.get(shortcutName);
+    if (!bindings) return '';
+    return bindings
+      .map(binding => describeBinding(binding).join('+'))
+      .join(',');
+  }
+
+  activeShortcutsBySection() {
+    const activeShortcuts = new Set<Shortcut>();
+    for (const shortcuts of this.activeShortcuts.values()) {
+      for (const shortcut of shortcuts) {
+        activeShortcuts.add(shortcut);
+      }
+    }
+
+    const activeShortcutsBySection = new Map<
+      ShortcutSection,
+      ShortcutHelpItem[]
+    >();
+    config.forEach((shortcutList, section) => {
+      shortcutList.forEach(shortcutHelp => {
+        if (activeShortcuts.has(shortcutHelp.shortcut)) {
+          if (!activeShortcutsBySection.has(section)) {
+            activeShortcutsBySection.set(section, []);
+          }
+          activeShortcutsBySection.get(section)!.push(shortcutHelp);
+        }
+      });
+    });
+    return activeShortcutsBySection;
+  }
+
+  directoryView() {
+    const view = new Map<ShortcutSection, SectionView>();
+    this.activeShortcutsBySection().forEach((shortcutHelps, section) => {
+      const sectionView: SectionView = [];
+      shortcutHelps.forEach(shortcutHelp => {
+        const bindingDesc = this.describeBindings(shortcutHelp.shortcut);
+        if (!bindingDesc) {
+          return;
+        }
+        this.distributeBindingDesc(bindingDesc).forEach(bindingDesc => {
+          sectionView.push({
+            binding: bindingDesc,
+            text: shortcutHelp.text,
+          });
+        });
+      });
+      view.set(section, sectionView);
+    });
+    return view;
+  }
+
+  distributeBindingDesc(bindingDesc: string[][]): string[][][] {
+    if (
+      bindingDesc.length === 1 ||
+      this.comboSetDisplayWidth(bindingDesc) < 21
+    ) {
+      return [bindingDesc];
+    }
+    // Find the largest prefix of bindings that is under the
+    // size threshold.
+    const head = [bindingDesc[0]];
+    for (let i = 1; i < bindingDesc.length; i++) {
+      head.push(bindingDesc[i]);
+      if (this.comboSetDisplayWidth(head) >= 21) {
+        head.pop();
+        return [head].concat(this.distributeBindingDesc(bindingDesc.slice(i)));
+      }
+    }
+    return [];
+  }
+
+  comboSetDisplayWidth(bindingDesc: string[][]) {
+    const bindingSizer = (binding: string[]) =>
+      binding.reduce((acc, key) => acc + key.length, 0);
+    // Width is the sum of strings + (n-1) * 2 to account for the word
+    // "or" joining them.
+    return (
+      bindingDesc.reduce((acc, binding) => acc + bindingSizer(binding), 0) +
+      2 * (bindingDesc.length - 1)
+    );
+  }
+
+  describeBindings(shortcut: Shortcut): string[][] | null {
+    const bindings = this.bindings.get(shortcut);
+    if (!bindings) return null;
+    return bindings
+      .filter(binding => !binding.docOnly)
+      .map(binding => describeBinding(binding));
+  }
+
+  notifyViewListeners() {
+    const view = this.directoryView();
+    this.listeners.forEach(listener => listener(view));
+  }
+}
+
+function describeKey(key: string | Key) {
+  switch (key) {
+    case Key.UP:
+      return '\u2191'; // ↑
+    case Key.DOWN:
+      return '\u2193'; // ↓
+    case Key.LEFT:
+      return '\u2190'; // ←
+    case Key.RIGHT:
+      return '\u2192'; // →
+    default:
+      return key;
+  }
+}
+
+export function describeBinding(binding: Binding): string[] {
+  const description: string[] = [];
+  if (binding.combo === ComboKey.G) {
+    description.push('g');
+  }
+  if (binding.combo === ComboKey.V) {
+    description.push('v');
+  }
+  if (binding.modifiers?.includes(Modifier.SHIFT_KEY)) {
+    description.push('Shift');
+  }
+  if (binding.modifiers?.includes(Modifier.ALT_KEY)) {
+    description.push('Alt');
+  }
+  if (binding.modifiers?.includes(Modifier.CTRL_KEY)) {
+    description.push('Ctrl');
+  }
+  if (binding.modifiers?.includes(Modifier.META_KEY)) {
+    description.push('Meta/Cmd');
+  }
+  description.push(describeKey(binding.key));
+  return description;
+}
diff --git a/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
new file mode 100644
index 0000000..05c4f53
--- /dev/null
+++ b/polygerrit-ui/app/services/shortcuts/shortcuts-service_test.ts
@@ -0,0 +1,345 @@
+/**
+ * @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';
+import {
+  COMBO_TIMEOUT_MS,
+  describeBinding,
+  ShortcutsService,
+} from '../../services/shortcuts/shortcuts-service';
+import {Shortcut, ShortcutSection} from './shortcuts-config';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {SinonFakeTimers} from 'sinon';
+import {Key, Modifier} from '../../utils/dom-util';
+
+async function keyEventOn(
+  el: HTMLElement,
+  callback: (e: KeyboardEvent) => void,
+  keyCode = 75,
+  key = 'k'
+): Promise<KeyboardEvent> {
+  let resolve: (e: KeyboardEvent) => void;
+  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
+  el.addEventListener('keydown', (e: KeyboardEvent) => {
+    callback(e);
+    resolve(e);
+  });
+  MockInteractions.keyDownOn(el, keyCode, null, key);
+  return await promise;
+}
+
+suite('shortcuts-service tests', () => {
+  let service: ShortcutsService;
+
+  setup(() => {
+    service = new ShortcutsService();
+  });
+
+  suite('shouldSuppress', () => {
+    test('do not suppress shortcut event from <div>', async () => {
+      await keyEventOn(document.createElement('div'), e => {
+        assert.isFalse(service.shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from <input>', async () => {
+      await keyEventOn(document.createElement('input'), e => {
+        assert.isTrue(service.shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from <textarea>', async () => {
+      await keyEventOn(document.createElement('textarea'), e => {
+        assert.isTrue(service.shouldSuppress(e));
+      });
+    });
+
+    test('do not suppress shortcut event from checkbox <input>', async () => {
+      const inputEl = document.createElement('input');
+      inputEl.setAttribute('type', 'checkbox');
+      await keyEventOn(inputEl, e => {
+        assert.isFalse(service.shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from children of <gr-overlay>', async () => {
+      const overlay = document.createElement('gr-overlay');
+      const div = document.createElement('div');
+      overlay.appendChild(div);
+      await keyEventOn(div, e => {
+        assert.isTrue(service.shouldSuppress(e));
+      });
+    });
+
+    test('suppress "enter" shortcut event from <a>', async () => {
+      await keyEventOn(document.createElement('a'), e => {
+        assert.isFalse(service.shouldSuppress(e));
+      });
+      await keyEventOn(
+        document.createElement('a'),
+        e => assert.isTrue(service.shouldSuppress(e)),
+        13,
+        'enter'
+      );
+    });
+  });
+
+  test('getShortcut', () => {
+    assert.equal(service.getShortcut(Shortcut.NEXT_FILE), ']');
+    assert.equal(service.getShortcut(Shortcut.TOGGLE_LEFT_PANE), 'A');
+    assert.equal(
+      service.getShortcut(Shortcut.SEND_REPLY),
+      'Ctrl+Enter,Meta/Cmd+Enter'
+    );
+  });
+
+  suite('binding descriptions', () => {
+    function mapToObject<K, V>(m: Map<K, V>) {
+      const o: any = {};
+      m.forEach((v: V, k: K) => (o[k] = v));
+      return o;
+    }
+
+    test('single combo description', () => {
+      assert.deepEqual(describeBinding({key: 'a'}), ['a']);
+      assert.deepEqual(
+        describeBinding({key: 'a', modifiers: [Modifier.CTRL_KEY]}),
+        ['Ctrl', 'a']
+      );
+      assert.deepEqual(
+        describeBinding({
+          key: Key.UP,
+          modifiers: [Modifier.CTRL_KEY, Modifier.SHIFT_KEY],
+        }),
+        ['Shift', 'Ctrl', '↑']
+      );
+    });
+
+    test('combo set description', () => {
+      assert.deepEqual(
+        service.describeBindings(Shortcut.GO_TO_OPENED_CHANGES),
+        [['g', 'o']]
+      );
+      assert.deepEqual(service.describeBindings(Shortcut.SAVE_COMMENT), [
+        ['Ctrl', 'Enter'],
+        ['Meta/Cmd', 'Enter'],
+        ['Ctrl', 's'],
+        ['Meta/Cmd', 's'],
+      ]);
+      assert.deepEqual(service.describeBindings(Shortcut.PREV_FILE), [['[']]);
+    });
+
+    test('combo set description width', () => {
+      assert.strictEqual(service.comboSetDisplayWidth([['u']]), 1);
+      assert.strictEqual(service.comboSetDisplayWidth([['g', 'o']]), 2);
+      assert.strictEqual(service.comboSetDisplayWidth([['Shift', 'r']]), 6);
+      assert.strictEqual(service.comboSetDisplayWidth([['x'], ['y']]), 4);
+      assert.strictEqual(
+        service.comboSetDisplayWidth([['x'], ['y'], ['Shift', 'z']]),
+        12
+      );
+    });
+
+    test('distribute shortcut help', () => {
+      assert.deepEqual(service.distributeBindingDesc([['o']]), [[['o']]]);
+      assert.deepEqual(service.distributeBindingDesc([['g', 'o']]), [
+        [['g', 'o']],
+      ]);
+      assert.deepEqual(
+        service.distributeBindingDesc([['ctrl', 'shift', 'meta', 'enter']]),
+        [[['ctrl', 'shift', 'meta', 'enter']]]
+      );
+      assert.deepEqual(
+        service.distributeBindingDesc([
+          ['ctrl', 'shift', 'meta', 'enter'],
+          ['o'],
+        ]),
+        [[['ctrl', 'shift', 'meta', 'enter']], [['o']]]
+      );
+      assert.deepEqual(
+        service.distributeBindingDesc([
+          ['ctrl', 'enter'],
+          ['meta', 'enter'],
+          ['ctrl', 's'],
+          ['meta', 's'],
+        ]),
+        [
+          [
+            ['ctrl', 'enter'],
+            ['meta', 'enter'],
+          ],
+          [
+            ['ctrl', 's'],
+            ['meta', 's'],
+          ],
+        ]
+      );
+    });
+
+    test('active shortcuts by section', () => {
+      assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {});
+
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
+      ]);
+      assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [{key: ']'}],
+          },
+        ],
+      });
+
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
+      ]);
+      assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
+        [ShortcutSection.DIFFS]: [
+          {
+            shortcut: Shortcut.NEXT_LINE,
+            text: 'Go to next line',
+            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
+          },
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [{key: ']'}],
+          },
+        ],
+      });
+
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.SEARCH, listener: _ => {}},
+        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
+      ]);
+      assert.deepEqual(mapToObject(service.activeShortcutsBySection()), {
+        [ShortcutSection.DIFFS]: [
+          {
+            shortcut: Shortcut.NEXT_LINE,
+            text: 'Go to next line',
+            bindings: [{key: 'j'}, {key: 'ArrowDown'}],
+          },
+        ],
+        [ShortcutSection.EVERYWHERE]: [
+          {
+            shortcut: Shortcut.SEARCH,
+            text: 'Search',
+            bindings: [{key: '/'}],
+          },
+          {
+            shortcut: Shortcut.GO_TO_OPENED_CHANGES,
+            text: 'Go to Opened Changes',
+            bindings: [{key: 'o', combo: 'g'}],
+          },
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {
+            shortcut: Shortcut.NEXT_FILE,
+            text: 'Go to next file',
+            bindings: [{key: ']'}],
+          },
+        ],
+      });
+    });
+
+    test('directory view', () => {
+      assert.deepEqual(mapToObject(service.directoryView()), {});
+
+      service.attachHost(document.createElement('div'), [
+        {shortcut: Shortcut.GO_TO_OPENED_CHANGES, listener: _ => {}},
+        {shortcut: Shortcut.NEXT_FILE, listener: _ => {}},
+        {shortcut: Shortcut.NEXT_LINE, listener: _ => {}},
+        {shortcut: Shortcut.SAVE_COMMENT, listener: _ => {}},
+        {shortcut: Shortcut.SEARCH, listener: _ => {}},
+      ]);
+      assert.deepEqual(mapToObject(service.directoryView()), {
+        [ShortcutSection.DIFFS]: [
+          {binding: [['j'], ['↓']], text: 'Go to next line'},
+          {
+            binding: [['Ctrl', 'Enter']],
+            text: 'Save comment',
+          },
+          {
+            binding: [
+              ['Meta/Cmd', 'Enter'],
+              ['Ctrl', 's'],
+            ],
+            text: 'Save comment',
+          },
+          {
+            binding: [['Meta/Cmd', 's']],
+            text: 'Save comment',
+          },
+        ],
+        [ShortcutSection.EVERYWHERE]: [
+          {binding: [['/']], text: 'Search'},
+          {binding: [['g', 'o']], text: 'Go to Opened Changes'},
+        ],
+        [ShortcutSection.NAVIGATION]: [
+          {binding: [[']']], text: 'Go to next file'},
+        ],
+      });
+    });
+  });
+
+  suite('combo keys', () => {
+    let clock: SinonFakeTimers;
+    setup(() => {
+      clock = sinon.useFakeTimers();
+      clock.tick(1000);
+    });
+
+    teardown(() => {
+      clock.restore();
+    });
+
+    test('not in combo key mode initially', () => {
+      assert.isFalse(service.isInComboKeyMode());
+    });
+
+    test('pressing f does not switch into combo key mode', () => {
+      const event = new KeyboardEvent('keydown', {key: 'f'});
+      document.dispatchEvent(event);
+      assert.isFalse(service.isInComboKeyMode());
+    });
+
+    test('pressing g switches into combo key mode', () => {
+      const event = new KeyboardEvent('keydown', {key: 'g'});
+      document.dispatchEvent(event);
+      assert.isTrue(service.isInComboKeyMode());
+    });
+
+    test('pressing v switches into combo key mode', () => {
+      const event = new KeyboardEvent('keydown', {key: 'v'});
+      document.dispatchEvent(event);
+      assert.isTrue(service.isInComboKeyMode());
+    });
+
+    test('combo key mode timeout', () => {
+      const event = new KeyboardEvent('keydown', {key: 'g'});
+      document.dispatchEvent(event);
+      assert.isTrue(service.isInComboKeyMode());
+      clock.tick(COMBO_TIMEOUT_MS / 2);
+      assert.isTrue(service.isInComboKeyMode());
+      clock.tick(COMBO_TIMEOUT_MS);
+      assert.isFalse(service.isInComboKeyMode());
+    });
+  });
+});
diff --git a/polygerrit-ui/app/services/user/user-model.ts b/polygerrit-ui/app/services/user/user-model.ts
new file mode 100644
index 0000000..72ce3e1
--- /dev/null
+++ b/polygerrit-ui/app/services/user/user-model.ts
@@ -0,0 +1,67 @@
+/**
+ * @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 {AccountDetailInfo, PreferencesInfo} from '../../types/common';
+import {BehaviorSubject, Observable} from 'rxjs';
+import {map, distinctUntilChanged} from 'rxjs/operators';
+import {createDefaultPreferences} from '../../constants/constants';
+
+interface UserState {
+  /**
+   * Keeps being defined even when credentials have expired.
+   */
+  account?: AccountDetailInfo;
+  preferences: PreferencesInfo;
+}
+
+const initialState: UserState = {
+  preferences: createDefaultPreferences(),
+};
+
+const privateState$ = new BehaviorSubject(initialState);
+
+// Re-exporting as Observable so that you can only subscribe, but not emit.
+export const userState$: Observable<UserState> = privateState$;
+
+export function updateAccount(account?: AccountDetailInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, account});
+}
+
+export function updatePreferences(preferences: PreferencesInfo) {
+  const current = privateState$.getValue();
+  privateState$.next({...current, preferences});
+}
+
+export const account$ = userState$.pipe(
+  map(userState => userState.account),
+  distinctUntilChanged()
+);
+
+export const preferences$ = userState$.pipe(
+  map(userState => userState.preferences),
+  distinctUntilChanged()
+);
+
+export const myTopMenuItems$ = preferences$.pipe(
+  map(preferences => preferences?.my ?? []),
+  distinctUntilChanged()
+);
+
+export const disableShortcuts$ = preferences$.pipe(
+  map(preferences => preferences?.disable_keyboard_shortcuts ?? false),
+  distinctUntilChanged()
+);
diff --git a/polygerrit-ui/app/services/user/user-service.ts b/polygerrit-ui/app/services/user/user-service.ts
new file mode 100644
index 0000000..125d20c
--- /dev/null
+++ b/polygerrit-ui/app/services/user/user-service.ts
@@ -0,0 +1,55 @@
+/**
+ * @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 {
+  AccountDetailInfo,
+  PreferencesInfo,
+  PreferencesInput,
+} from '../../types/common';
+import {from, of} from 'rxjs';
+import {account$, updateAccount, updatePreferences} from './user-model';
+import {switchMap} from 'rxjs/operators';
+import {createDefaultPreferences} from '../../constants/constants';
+import {RestApiService} from '../gr-rest-api/gr-rest-api';
+
+export class UserService {
+  constructor(readonly restApiService: RestApiService) {
+    from(this.restApiService.getAccount()).subscribe(
+      (account?: AccountDetailInfo) => {
+        updateAccount(account);
+      }
+    );
+    account$
+      .pipe(
+        switchMap(account => {
+          if (!account) return of(createDefaultPreferences());
+          return from(this.restApiService.getPreferences());
+        })
+      )
+      .subscribe((preferences?: PreferencesInfo) => {
+        updatePreferences(preferences ?? createDefaultPreferences());
+      });
+  }
+
+  updatePreferences(prefs: PreferencesInput) {
+    this.restApiService
+      .savePreferences(prefs)
+      .then((newPrefs: PreferencesInfo | undefined) => {
+        if (!newPrefs) return;
+        updatePreferences(newPrefs);
+      });
+  }
+}
diff --git a/polygerrit-ui/app/styles/dashboard-header-styles.ts b/polygerrit-ui/app/styles/dashboard-header-styles.ts
index 2354f65..643a76a 100644
--- a/polygerrit-ui/app/styles/dashboard-header-styles.ts
+++ b/polygerrit-ui/app/styles/dashboard-header-styles.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {css} from 'lit';
+
 // 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,42 +24,40 @@
 
 const $_documentContainer = document.createElement('template');
 
+export const dashboardHeaderStyles = css`
+  :host {
+    background-color: var(--view-background-color);
+    display: block;
+    min-height: 9em;
+    width: 100%;
+  }
+  gr-avatar {
+    display: inline-block;
+    height: 7em;
+    left: 1em;
+    margin: 1em;
+    top: 1em;
+    width: 7em;
+  }
+  .info {
+    display: inline-block;
+    padding: var(--spacing-l);
+    vertical-align: top;
+  }
+  .info > div > span {
+    display: inline-block;
+    font-weight: var(--font-weight-bold);
+    text-align: right;
+    width: 4em;
+  }
+`;
+
 $_documentContainer.innerHTML = `<dom-module id="dashboard-header-styles">
   <template>
     <style>
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-        min-height: 9em;
-        width: 100%;
-      }
-      gr-avatar {
-        display: inline-block;
-        height: 7em;
-        left: 1em;
-        margin: 1em;
-        top: 1em;
-        width: 7em;
-      }
-      .info {
-        display: inline-block;
-        padding: var(--spacing-l);
-        vertical-align: top;
-      }
-      .info > div > span {
-        display: inline-block;
-        font-weight: var(--font-weight-bold);
-        text-align: right;
-        width: 4em;
-      }
+    ${dashboardHeaderStyles.cssText}
     </style>
   </template>
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-a11y-styles.ts b/polygerrit-ui/app/styles/gr-a11y-styles.ts
new file mode 100644
index 0000000..a1fa62b
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-a11y-styles.ts
@@ -0,0 +1,42 @@
+/**
+ * @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} from 'lit';
+
+export const a11yStyles = css`
+  .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;
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-a11y-styles">
+  <template>
+    <style>
+    ${a11yStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.ts b/polygerrit-ui/app/styles/gr-change-list-styles.ts
index d1fcdc9..e0a7a28 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.ts
@@ -202,9 +202,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
index 3d07d2e..67a6963 100644
--- a/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-metadata-shared-styles.ts
@@ -55,9 +55,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
index 57c8d78..145f0d5 100644
--- a/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
+++ b/polygerrit-ui/app/styles/gr-change-view-integration-shared-styles.ts
@@ -22,6 +22,15 @@
 
 const $_documentContainer = document.createElement('template');
 
+/*
+  These are shared styles for change-view-integration endpoints.
+  All plugins that registered that endpoint should include this in
+  the component to have a consistent UX:
+
+  <style include="gr-change-view-integration-shared-styles"></style>
+
+  And use those defined class to apply these styles.
+*/
 $_documentContainer.innerHTML = `<dom-module id="gr-change-view-integration-shared-styles">
   <template>
     <style include="shared-styles">
@@ -61,18 +70,3 @@
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  This is shared styles for change-view-integration endpoints.
-  All plugins that registered that endpoint should include this in
-  the component to have a consistent UX:
-
-  <style include="gr-change-view-integration-shared-styles"></style>
-
-  And use those defined class to apply these styles.
-*/
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-font-styles.ts b/polygerrit-ui/app/styles/gr-font-styles.ts
new file mode 100644
index 0000000..422a7c5
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-font-styles.ts
@@ -0,0 +1,61 @@
+/**
+ * @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 {css} from 'lit';
+
+export const fontStyles = css`
+  .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);
+  }
+  strong {
+    font-weight: var(--font-weight-bold);
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-font-styles">
+  <template>
+    <style>
+    ${fontStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-form-styles.ts b/polygerrit-ui/app/styles/gr-form-styles.ts
index 3284ad5..34a6936 100644
--- a/polygerrit-ui/app/styles/gr-form-styles.ts
+++ b/polygerrit-ui/app/styles/gr-form-styles.ts
@@ -14,119 +14,111 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+export const formStyles = css`
+  .gr-form-styles input {
+    background-color: var(--view-background-color);
+    color: var(--primary-text-color);
+  }
+  .gr-form-styles select {
+    background-color: var(--select-background-color);
+    color: var(--primary-text-color);
+  }
+  .gr-form-styles h1,
+  .gr-form-styles h2 {
+    margin-bottom: var(--spacing-s);
+  }
+  .gr-form-styles h4 {
+    font-weight: var(--font-weight-bold);
+  }
+  .gr-form-styles fieldset {
+    border: none;
+    margin-bottom: var(--spacing-xxl);
+  }
+  .gr-form-styles section {
+    display: flex;
+    margin: var(--spacing-s) 0;
+    min-height: 2em;
+  }
+  .gr-form-styles section * {
+    vertical-align: middle;
+  }
+  .gr-form-styles .title,
+  .gr-form-styles .value {
+    display: inline-block;
+  }
+  .gr-form-styles .title {
+    color: var(--deemphasized-text-color);
+    font-weight: var(--font-weight-bold);
+    padding-right: var(--spacing-m);
+    width: 15em;
+  }
+  .gr-form-styles th {
+    color: var(--deemphasized-text-color);
+    text-align: left;
+    vertical-align: bottom;
+  }
+  .gr-form-styles td,
+  .gr-form-styles tfoot th {
+    padding: var(--spacing-s) 0;
+    vertical-align: middle;
+  }
+  .gr-form-styles .emptyHeader {
+    text-align: right;
+  }
+  .gr-form-styles table {
+    width: 50em;
+  }
+  .gr-form-styles th:first-child,
+  .gr-form-styles td:first-child {
+    width: 15em;
+  }
+  .gr-form-styles th:first-child input,
+  .gr-form-styles td:first-child input {
+    width: 14em;
+  }
+  .gr-form-styles input:not([type='checkbox']),
+  .gr-form-styles select,
+  .gr-form-styles textarea {
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    padding: var(--spacing-s);
+  }
+  .gr-form-styles td:last-child {
+    width: 5em;
+  }
+  .gr-form-styles th:last-child gr-button,
+  .gr-form-styles td:last-child gr-button {
+    width: 100%;
+  }
+  .gr-form-styles iron-autogrow-textarea {
+    height: auto;
+    min-height: 4em;
+  }
+  .gr-form-styles gr-autocomplete {
+    width: 14em;
+  }
+  @media only screen and (max-width: 40em) {
+    .gr-form-styles section {
+      margin-bottom: var(--spacing-l);
+    }
+    .gr-form-styles .title,
+    .gr-form-styles .value {
+      display: block;
+    }
+    .gr-form-styles table {
+      width: 100%;
+    }
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-form-styles">
   <template>
     <style>
-      .gr-form-styles input {
-        background-color: var(--view-background-color);
-        color: var(--primary-text-color);
-      }
-      .gr-form-styles select {
-        background-color: var(--select-background-color);
-        color: var(--primary-text-color);
-      }
-      .gr-form-styles h1,
-      .gr-form-styles h2 {
-        margin-bottom: var(--spacing-s);
-      }
-      .gr-form-styles h4 {
-        font-weight: var(--font-weight-bold);
-      }
-      .gr-form-styles fieldset {
-        border: none;
-        margin-bottom: var(--spacing-xxl);
-      }
-      .gr-form-styles section {
-        display: flex;
-        margin: var(--spacing-s) 0;
-        min-height: 2em;
-      }
-      .gr-form-styles section * {
-        vertical-align: middle;
-      }
-      .gr-form-styles .title,
-      .gr-form-styles .value {
-        display: inline-block;
-      }
-      .gr-form-styles .title {
-        color: var(--deemphasized-text-color);
-        font-weight: var(--font-weight-bold);
-        padding-right: var(--spacing-m);
-        width: 15em;
-      }
-      .gr-form-styles th {
-        color: var(--deemphasized-text-color);
-        text-align: left;
-        vertical-align: bottom;
-      }
-      .gr-form-styles td,
-      .gr-form-styles tfoot th {
-        padding: var(--spacing-s) 0;
-        vertical-align: middle;
-      }
-      .gr-form-styles .emptyHeader {
-        text-align: right;
-      }
-      .gr-form-styles table {
-        width: 50em;
-      }
-      .gr-form-styles th:first-child,
-      .gr-form-styles td:first-child {
-        width: 15em;
-      }
-      .gr-form-styles th:first-child input,
-      .gr-form-styles td:first-child input {
-        width: 14em;
-      }
-      .gr-form-styles input:not([type="checkbox"]),
-      .gr-form-styles select,
-      .gr-form-styles textarea {
-        border: 1px solid var(--border-color);
-        border-radius: var(--border-radius);
-        padding: var(--spacing-s);
-      }
-      .gr-form-styles td:last-child {
-        width: 5em;
-      }
-      .gr-form-styles th:last-child gr-button,
-      .gr-form-styles td:last-child gr-button {
-        width: 100%;
-      }
-      .gr-form-styles iron-autogrow-textarea {
-        height: auto;
-        min-height: 4em;
-      }
-      .gr-form-styles gr-autocomplete {
-        width: 14em;
-      }
-      @media only screen and (max-width: 40em) {
-        .gr-form-styles section {
-          margin-bottom: var(--spacing-l);
-        }
-        .gr-form-styles .title,
-        .gr-form-styles .value {
-          display: block;
-        }
-        .gr-form-styles table {
-          width: 100%;
-        }
-      }
+    ${formStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-hovercard-styles.ts b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
new file mode 100644
index 0000000..f214a9c
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-hovercard-styles.ts
@@ -0,0 +1,51 @@
+/**
+ * @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 {css} from 'lit';
+
+export const hovercardStyles = css`
+  :host {
+    position: absolute;
+    display: none;
+    z-index: 200;
+    max-width: 600px;
+    outline: none;
+  }
+  :host(.hovered) {
+    display: block;
+  }
+  :host(.hide) {
+    visibility: hidden;
+  }
+  /* You have to use a <div class="container"> in your hovercard in order
+      to pick up this consistent styling. */
+  #container {
+    background: var(--dialog-background-color);
+    border: 1px solid var(--border-color);
+    border-radius: var(--border-radius);
+    box-shadow: var(--elevation-level-5);
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-hovercard-styles">
+  <template>
+    <style>
+    ${hovercardStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.ts b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
index e9a79c1f..5f58571 100644
--- a/polygerrit-ui/app/styles/gr-menu-page-styles.ts
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.ts
@@ -14,74 +14,68 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+export const menuPageStyles = css`
+  :host {
+    display: block;
+  }
+  .main {
+    margin: var(--spacing-xxl) auto;
+    max-width: 50em;
+  }
+  .mainHeader {
+    margin-left: 14em;
+    padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
+  }
+  .main.table,
+  .mainHeader {
+    margin-top: 0;
+    margin-right: 0;
+    margin-left: 14em;
+    max-width: none;
+  }
+  h2.edited:after {
+    color: var(--deemphasized-text-color);
+    content: ' *';
+  }
+  .loading {
+    color: var(--deemphasized-text-color);
+    padding: var(--spacing-l);
+  }
+  @media only screen and (max-width: 67em) {
+    .main {
+      margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
+    }
+    .main.table {
+      margin-left: 14em;
+    }
+  }
+  @media only screen and (max-width: 53em) {
+    .loading {
+      padding: 0 var(--spacing-l);
+    }
+    .main {
+      margin: var(--spacing-xxl) var(--spacing-l);
+    }
+    .main.table {
+      margin: 0;
+    }
+    .mainHeader {
+      margin-left: 0;
+      padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
+    }
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-menu-page-styles">
-  <template>
-    <style>
-      :host {
-        display: block;
-      }
-      .main {
-        margin: var(--spacing-xxl) auto;
-        max-width: 50em;
-      }
-      .mainHeader {
-        margin-left: 14em;
-        padding: var(--spacing-l) 0 var(--spacing-l) var(--spacing-xxl);
-      }
-      .main.table,
-      .mainHeader {
-        margin-top: 0;
-        margin-right: 0;
-        margin-left: 14em;
-        max-width: none;
-      }
-      h2.edited:after {
-        color: var(--deemphasized-text-color);
-        content: ' *';
-      }
-      .loading {
-        color: var(--deemphasized-text-color);
-        padding: var(--spacing-l);
-      }
-      @media only screen and (max-width: 67em) {
-        .main {
-          margin: var(--spacing-xxl) 0 var(--spacing-xxl) 15em;
-        }
-        .main.table {
-          margin-left: 14em;
-        }
-      }
-      @media only screen and (max-width: 53em) {
-        .loading {
-          padding: 0 var(--spacing-l);
-        }
-        .main {
-          margin: var(--spacing-xxl) var(--spacing-l);
-        }
-        .main.table {
-          margin: 0;
-        }
-        .mainHeader {
-          margin-left: 0;
-          padding: var(--spacing-m) 0 var(--spacing-m) var(--spacing-l);
-        }
-      }
-    </style>
-  </template>
-</dom-module>`;
-
+$_documentContainer.innerHTML = `
+  <dom-module id="gr-menu-page-styles">
+    <template>
+      <style>
+      ${menuPageStyles.cssText}
+      </style>
+    </template>
+  </dom-module>
+`;
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.ts b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
index 9010b2d..f928848 100644
--- a/polygerrit-ui/app/styles/gr-page-nav-styles.ts
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.ts
@@ -15,6 +15,8 @@
  * limitations under the License.
  */
 
+import {css} from 'lit';
+
 // 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,59 +24,57 @@
 
 const $_documentContainer = document.createElement('template');
 
+export const pageNavStyles = css`
+  .navStyles ul {
+    padding: var(--spacing-l) 0;
+  }
+  .navStyles li {
+    border-bottom: 1px solid transparent;
+    border-top: 1px solid transparent;
+    display: block;
+    padding: 0 var(--spacing-xl);
+  }
+  .navStyles li a {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .navStyles .subsectionItem {
+    padding-left: var(--spacing-xxl);
+  }
+  .navStyles .hideSubsection {
+    display: none;
+  }
+  .navStyles li.sectionTitle {
+    padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
+  }
+  .navStyles li.sectionTitle:not(:first-child) {
+    margin-top: var(--spacing-l);
+  }
+  .navStyles .title {
+    font-weight: var(--font-weight-bold);
+    margin: var(--spacing-s) 0;
+  }
+  .navStyles .selected {
+    background-color: var(--view-background-color);
+    border-bottom: 1px solid var(--border-color);
+    border-top: 1px solid var(--border-color);
+    font-weight: var(--font-weight-bold);
+  }
+  .navStyles a {
+    color: var(--primary-text-color);
+    display: inline-block;
+    margin: var(--spacing-s) 0;
+  }
+`;
+
 $_documentContainer.innerHTML = `<dom-module id="gr-page-nav-styles">
   <template>
     <style>
-      .navStyles ul {
-        padding: var(--spacing-l) 0;
-      }
-      .navStyles li {
-        border-bottom: 1px solid transparent;
-        border-top: 1px solid transparent;
-        display: block;
-        padding: 0 var(--spacing-xl);
-      }
-      .navStyles li a {
-        display: block;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        white-space: nowrap;
-      }
-      .navStyles .subsectionItem {
-        padding-left: var(--spacing-xxl);
-      }
-      .navStyles .hideSubsection {
-        display: none;
-      }
-      .navStyles li.sectionTitle {
-        padding: 0 var(--spacing-xxl) 0 var(--spacing-l);
-      }
-      .navStyles li.sectionTitle:not(:first-child) {
-        margin-top: var(--spacing-l);
-      }
-      .navStyles .title {
-        font-weight: var(--font-weight-bold);
-        margin: var(--spacing-s) 0;
-      }
-      .navStyles .selected {
-        background-color: var(--view-background-color);
-        border-bottom: 1px solid var(--border-color);
-        border-top: 1px solid var(--border-color);
-        font-weight: var(--font-weight-bold);
-      }
-      .navStyles a {
-        color: var(--primary-text-color);
-        display: inline-block;
-        margin: var(--spacing-s) 0;
-      }
+    ${pageNavStyles.cssText}
     </style>
   </template>
 </dom-module>`;
 
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-spinner-styles.ts b/polygerrit-ui/app/styles/gr-spinner-styles.ts
new file mode 100644
index 0000000..6015be4
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-spinner-styles.ts
@@ -0,0 +1,48 @@
+/**
+ * @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} from 'lit';
+
+export const spinnerStyles = css`
+  .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);
+    }
+  }
+`;
+
+const $_documentContainer = document.createElement('template');
+$_documentContainer.innerHTML = `<dom-module id="gr-spinner-styles">
+  <template>
+    <style>
+      ${spinnerStyles.cssText}
+    </style>
+  </template>
+</dom-module>`;
+
+document.head.appendChild($_documentContainer.content);
diff --git a/polygerrit-ui/app/styles/gr-subpage-styles.ts b/polygerrit-ui/app/styles/gr-subpage-styles.ts
index 41ee952..e426a7d 100644
--- a/polygerrit-ui/app/styles/gr-subpage-styles.ts
+++ b/polygerrit-ui/app/styles/gr-subpage-styles.ts
@@ -14,37 +14,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+export const subpageStyles = css`
+  .main {
+    margin: var(--spacing-l);
+  }
+  .loading {
+    display: none;
+  }
+  #loading.loading {
+    display: block;
+  }
+  #loading:not(.loading) {
+    display: none;
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-subpage-styles">
-  <template>
-    <style>
-      .main {
-        margin: var(--spacing-l);
-      }
-      .loading {
-        display: none;
-      }
-      #loading.loading {
-        display: block;
-      }
-      #loading:not(.loading) {
-        display: none;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
+$_documentContainer.innerHTML = `
+  <dom-module id="gr-subpage-styles">
+    <template>
+      <style>
+      ${subpageStyles.cssText}
+      </style>
+    </template>
+  </dom-module>
+`;
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-table-styles.ts b/polygerrit-ui/app/styles/gr-table-styles.ts
index 52fdc67..6871499 100644
--- a/polygerrit-ui/app/styles/gr-table-styles.ts
+++ b/polygerrit-ui/app/styles/gr-table-styles.ts
@@ -14,111 +14,105 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {css} from 'lit';
 
-// Mark the file as a module. Otherwise typescript assumes this is a script
-// and $_documentContainer is a global variable.
-// See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+export const tableStyles = css`
+  .genericList {
+    background-color: var(--background-color-primary);
+    border-collapse: collapse;
+    width: 100%;
+  }
+  .genericList th,
+  .genericList td {
+    padding: var(--spacing-m) 0;
+    vertical-align: middle;
+  }
+  .genericList tr {
+    border-bottom: 1px solid var(--border-color);
+  }
+  .genericList tr:hover {
+    background-color: var(--hover-background-color);
+  }
+  .genericList th {
+    white-space: nowrap;
+  }
+  .genericList th,
+  .genericList td {
+    padding-right: var(--spacing-l);
+  }
+  .genericList tr th:first-of-type,
+  .genericList tr td:first-of-type {
+    padding-left: var(--spacing-l);
+  }
+  .genericList tr:first-of-type {
+    border-top: 1px solid var(--border-color);
+  }
+  .genericList tr th:last-of-type,
+  .genericList tr td:last-of-type {
+    border-left: 1px solid var(--border-color);
+    text-align: center;
+    padding-left: var(--spacing-l);
+  }
+  .genericList tr th.delete,
+  .genericList tr td.delete {
+    padding-top: 0;
+    padding-bottom: 0;
+  }
+  .genericList tr th.delete,
+  .genericList tr td.delete,
+  .genericList tr.loadingMsg td,
+  .genericList tr.groupHeader td {
+    border-left: none;
+  }
+  .genericList .loading {
+    border: none;
+    display: none;
+  }
+  .genericList td {
+    flex-shrink: 0;
+  }
+  .genericList .topHeader,
+  .genericList .groupHeader {
+    color: var(--primary-text-color);
+    font-weight: var(--font-weight-bold);
+    text-align: left;
+    vertical-align: middle;
+  }
+  .genericList .groupHeader {
+    background-color: var(--background-color-secondary);
+    font-family: var(--header-font-family);
+    font-size: var(--font-size-h3);
+    font-weight: var(--font-weight-h3);
+    line-height: var(--line-height-h3);
+  }
+  .genericList a {
+    color: var(--primary-text-color);
+    text-decoration: none;
+  }
+  .genericList a:hover {
+    text-decoration: underline;
+  }
+  .genericList .description {
+    width: 99%;
+  }
+  .genericList .loadingMsg {
+    color: var(--deemphasized-text-color);
+    display: block;
+    padding: var(--spacing-s) var(--spacing-l);
+  }
+  .genericList .loadingMsg:not(.loading) {
+    display: none;
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
-$_documentContainer.innerHTML = `<dom-module id="gr-table-styles">
-  <template>
-    <style>
-      .genericList {
-        background-color: var(--background-color-primary);
-        border-collapse: collapse;
-        width: 100%;
-      }
-      .genericList th,
-      .genericList td {
-        padding: var(--spacing-m) 0;
-        vertical-align: middle;
-      }
-      .genericList tr {
-        border-bottom: 1px solid var(--border-color);
-      }
-      .genericList tr:hover {
-        background-color: var(--hover-background-color);
-      }
-      .genericList th {
-        white-space: nowrap;
-      }
-      .genericList th,
-      .genericList td {
-        padding-right: var(--spacing-l);
-      }
-      .genericList tr th:first-of-type,
-      .genericList tr td:first-of-type {
-        padding-left: var(--spacing-l);
-      }
-      .genericList tr:first-of-type {
-        border-top: 1px solid var(--border-color);
-      }
-      .genericList tr th:last-of-type,
-      .genericList tr td:last-of-type {
-        border-left: 1px solid var(--border-color);
-        text-align: center;
-        padding-left: var(--spacing-l);
-      }
-      .genericList tr th.delete,
-      .genericList tr td.delete {
-        padding-top: 0;
-        padding-bottom: 0;
-      }
-      .genericList tr th.delete,
-      .genericList tr td.delete,
-      .genericList tr.loadingMsg td,
-      .genericList tr.groupHeader td {
-        border-left: none;
-      }
-      .genericList .loading {
-        border: none;
-        display: none;
-      }
-      .genericList td {
-        flex-shrink: 0;
-      }
-      .genericList .topHeader,
-      .genericList .groupHeader {
-        color: var(--primary-text-color);
-        font-weight: var(--font-weight-bold);
-        text-align: left;
-        vertical-align: middle
-      }
-      .genericList .groupHeader {
-        background-color: var(--background-color-secondary);
-        font-family: var(--header-font-family);
-        font-size: var(--font-size-h3);
-        font-weight: var(--font-weight-h3);
-        line-height: var(--line-height-h3);
-      }
-      .genericList a {
-        color: var(--primary-text-color);
-        text-decoration: none;
-      }
-      .genericList a:hover {
-        text-decoration: underline;
-      }
-      .genericList .description {
-        width: 99%;
-      }
-      .genericList .loadingMsg {
-        color: var(--deemphasized-text-color);
-        display: block;
-        padding: var(--spacing-s) var(--spacing-l);
-      }
-      .genericList .loadingMsg:not(.loading) {
-        display: none;
-      }
-    </style>
-  </template>
-</dom-module>`;
-
+$_documentContainer.innerHTML = `
+  <dom-module id="gr-table-styles">
+    <template>
+      <style>
+      ${tableStyles.cssText}
+      </style>
+    </template>
+  </dom-module>
+`;
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/gr-voting-styles.ts b/polygerrit-ui/app/styles/gr-voting-styles.ts
index b50aee6..a623d99 100644
--- a/polygerrit-ui/app/styles/gr-voting-styles.ts
+++ b/polygerrit-ui/app/styles/gr-voting-styles.ts
@@ -18,39 +18,26 @@
 // Mark the file as a module. Otherwise typescript assumes this is a script
 // and $_documentContainer is a global variable.
 // See: https://www.typescriptlang.org/docs/handbook/modules.html
-export {};
+import {css} from 'lit';
+
+export const votingStyles = css`
+  .voteChip {
+    border: 1px solid var(--border-color);
+    /* max rounded */
+    border-radius: 1em;
+    box-shadow: none;
+    box-sizing: border-box;
+    min-width: 3em;
+    color: var(--vote-text-color);
+  }
+`;
 
 const $_documentContainer = document.createElement('template');
-
 $_documentContainer.innerHTML = `<dom-module id="gr-voting-styles">
   <template>
     <style>
-      :host {
-        --vote-chip-styles: {
-          border-style: solid;
-          border-color: var(--border-color);
-          border-top-left-radius: 1em;
-          border-top-right-radius: 1em;
-          border-bottom-right-radius: 1em;
-          border-bottom-left-radius: 1em;
-          border-top-width: 1px;
-          border-right-width: 1px;
-          border-bottom-width: 1px;
-          border-left-width: 1px;
-          box-shadow: none;
-          box-sizing: border-box;
-          min-width: 3em;
-          color: var(--vote-text-color);
-        }
-      }
+    ${votingStyles.cssText}
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/shared-styles.ts b/polygerrit-ui/app/styles/shared-styles.ts
index 6d128b3..98f6eb2 100644
--- a/polygerrit-ui/app/styles/shared-styles.ts
+++ b/polygerrit-ui/app/styles/shared-styles.ts
@@ -14,15 +14,7 @@
  * See the License for the specific language governing permissions and
  * 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
-export {};
-
-const $_documentContainer = document.createElement('template');
+import {css} from 'lit';
 
 export const sharedStyles = css`
   /* CSS reset */
@@ -176,36 +168,6 @@
     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;
@@ -239,9 +201,13 @@
       font-family: var(--header-font-family);
       -webkit-font-smoothing: initial;
     }
+    --paper-tab-content: {
+      margin-bottom: var(--spacing-s);
+    }
     --paper-tab-content-focused: {
       /* paper-tabs uses 700 here, which can look awkward */
       font-weight: var(--font-weight-h3);
+      background: var(--gray-background-focus);
     }
     --paper-tab-content-unselected: {
       /* paper-tabs uses 0.8 here, but we want to control the color directly */
@@ -249,27 +215,19 @@
       color: var(--deemphasized-text-color);
     }
   }
+  paper-tab:focus {
+    padding-left: 0px;
+    padding-right: 0px;
+  }
   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;
-  }
-
+  /**
+   * TODO: Remove these rules and change (plugin) users to rely on
+   * gr-spinner-styles directly.
+   */
   /** BEGIN: loading spiner */
   .loadingSpin {
     border: 2px solid var(--disabled-button-background-color);
@@ -291,6 +249,7 @@
   /** END: loading spiner */
 `;
 
+const $_documentContainer = document.createElement('template');
 $_documentContainer.innerHTML = `<dom-module id="shared-styles">
   <template>
     <style>
@@ -298,11 +257,4 @@
     </style>
   </template>
 </dom-module>`;
-
 document.head.appendChild($_documentContainer.content);
-
-/*
-  FIXME(polymer-modulizer): the above comments were extracted
-  from HTML and may be out of place here. Review them and
-  then delete this comment!
-*/
diff --git a/polygerrit-ui/app/styles/themes/app-theme.ts b/polygerrit-ui/app/styles/themes/app-theme.ts
index 83ff552..8b00a79 100644
--- a/polygerrit-ui/app/styles/themes/app-theme.ts
+++ b/polygerrit-ui/app/styles/themes/app-theme.ts
@@ -185,6 +185,7 @@
     --vote-text-color: black;
     --status-text-color: white;
     --tooltip-text-color: white;
+    --tooltip-button-text-color: var(--gerrit-blue-dark);
     --negative-red-text-color: var(--red-600);
     --positive-green-text-color: var(--green-700);
     --indirect-ancestor-text-color: var(--green-700);
@@ -253,6 +254,7 @@
     --status-wip: #795548;
     --status-private: var(--purple-500);
     --status-conflict: var(--red-600);
+    --status-revert-created: #e64a19;
     --status-active: var(--blue-700);
     --status-ready: var(--pink-800);
     --status-custom: var(--purple-900);
@@ -283,7 +285,7 @@
     --font-weight-bold: 500;
     --font-weight-h1: 400;
     --font-weight-h2: 400;
-    --font-weight-h3: 400;
+    --font-weight-h3: var(--font-weight-bold, 500);
     --context-control-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
     --code-hint-font-weight: 500;
     --image-diff-button-font: var(--font-weight-normal) var(--font-size-normal) var(--font-family);
@@ -336,6 +338,7 @@
     --coverage-covered: #e0f2f1;
     --coverage-not-covered: #ffd1a4;
     --ranged-comment-hint-text-color: var(--orange-900);
+    --token-highlighting-color: #fffd54;
 
     /* syntax colors */
     --syntax-attr-color: #219;
@@ -376,8 +379,7 @@
     /* misc */
     --border-radius: 4px;
     --reply-overlay-z-index: 1000;
-    /* Base 64 encoded 1x1px of #681da8 */
-    --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2PIlF2xAgAD+AHXfBDdKAAAAABJRU5ErkJggg==');
+    --line-length-indicator-color: #681da8;
 
     /* paper and iron component overrides */
     --iron-overlay-backdrop-background-color: black;
diff --git a/polygerrit-ui/app/styles/themes/dark-theme.ts b/polygerrit-ui/app/styles/themes/dark-theme.ts
index 77ceb30..05c6b7d 100644
--- a/polygerrit-ui/app/styles/themes/dark-theme.ts
+++ b/polygerrit-ui/app/styles/themes/dark-theme.ts
@@ -94,7 +94,8 @@
       --reviewed-text-color: var(--gray-300);
       --vote-text-color: black;
       --status-text-color: black;
-      --tooltip-text-color: var(--gray-200);
+      --tooltip-text-color: var(--gray-900);
+      --tooltip-button-text-color: var(--gerrit-blue-light);
       --negative-red-text-color: var(--red-200);
       --positive-green-text-color: var(--green-200);
       --indirect-ancestor-text-color: var(--green-200);
@@ -115,7 +116,7 @@
       --hover-background-color: rgba(161, 194, 250, 0.2);
       --disabled-button-background-color: #484a4d;
       --selection-background-color: rgba(161, 194, 250, 0.1);
-      --tooltip-background-color: var(--gray-800);
+      --tooltip-background-color: var(--gray-200);
 
       /* comment background colors */
       --comment-background-color: #3c3f43;
@@ -151,6 +152,7 @@
       --status-wip: #bcaaa4;
       --status-private: var(--purple-200);
       --status-conflict: var(--red-300);
+      --status-revert-created: #ff8a65;
       --status-active: var(--blue-400);
       --status-ready: var(--pink-500);
       --status-custom: var(--purple-400);
@@ -174,7 +176,7 @@
       --header-text-color: var(--primary-text-color);
 
       /* diff colors */
-      --dark-add-highlight-color: #133820;
+      --dark-add-highlight-color: var(--green-tonal); 
       --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
       --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
       --dark-remove-highlight-color: #62110f;
@@ -187,7 +189,7 @@
       --diff-selection-background-color: #3a71d8;
       --diff-tab-indicator-color: var(--deemphasized-text-color);
       --diff-trailing-whitespace-indicator: #ff9ad2;
-      --light-add-highlight-color: #0f401f;
+      --light-add-highlight-color: #182b1f;
       --light-rebased-add-highlight-color: #487165;
       --diff-moved-in-background: #1d4042;
       --diff-moved-out-background: #230e34;
@@ -198,6 +200,7 @@
       --coverage-covered: #112826;
       --coverage-not-covered: #6b3600;
       --ranged-comment-hint-text-color: var(--blue-50);
+      --token-highlighting-color: var(--yellow-tonal);
 
       /* syntax colors */
       --syntax-attr-color: #80cbbf;
@@ -229,8 +232,7 @@
       --syntax-variable-color: #f77669;
 
       /* misc */
-      /* Base 64 encoded 1x1px of #d7aefb; */
-      --line-length-indicator: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQYV2O4vu73fwAIYAOAtqAXCQAAAABJRU5ErkJggg==');
+      --line-length-indicator-color: #d7aefb;
 
       /* paper and iron component overrides */
       --iron-overlay-backdrop-background-color: white;
@@ -247,10 +249,3 @@
 export function applyTheme() {
   document.head.appendChild(getStyleEl());
 }
-
-export function removeTheme() {
-  const darkThemeEls = document.head.querySelectorAll('#dark-theme');
-  if (darkThemeEls.length) {
-    darkThemeEls.forEach(darkThemeEl => darkThemeEl.remove());
-  }
-}
diff --git a/polygerrit-ui/app/styles/themes/dark-theme_test.ts b/polygerrit-ui/app/styles/themes/dark-theme_test.ts
deleted file mode 100644
index 16e609e..0000000
--- a/polygerrit-ui/app/styles/themes/dark-theme_test.ts
+++ /dev/null
@@ -1,28 +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';
-import {applyTheme, removeTheme} from './dark-theme';
-
-suite('dark-theme test', () => {
-  test('apply and remove theme', () => {
-    applyTheme();
-    assert.equal(document.head.querySelectorAll('#dark-theme').length, 1);
-    removeTheme();
-    assert.isEmpty(document.head.querySelectorAll('#dark-theme'));
-  });
-});
diff --git a/polygerrit-ui/app/test/@types/sinon-esm.d.ts b/polygerrit-ui/app/test/@types/sinon-esm.d.ts
deleted file mode 100644
index 9074a7a..0000000
--- a/polygerrit-ui/app/test/@types/sinon-esm.d.ts
+++ /dev/null
@@ -1,27 +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.
- */
-
-declare module 'sinon/pkg/sinon-esm' {
-  // sinon-esm doesn't have it's own d.ts, reexport all types from sinon
-  // This is a trick - @types/sinon adds interfaces and sinon instance
-  // to a global variables/namespace. We reexport it here, so we
-  // can use in our code when importing sinon-esm
-  // eslint-disable-next-line import/no-default-export
-  export default sinon;
-  const sinon: Sinon.SinonStatic;
-  export {SinonSpy, SinonFakeTimers, SinonStubbedMember};
-}
diff --git a/polygerrit-ui/app/test/common-test-setup-karma.ts b/polygerrit-ui/app/test/common-test-setup-karma.ts
index 3d07d8a..39c79d1 100644
--- a/polygerrit-ui/app/test/common-test-setup-karma.ts
+++ b/polygerrit-ui/app/test/common-test-setup-karma.ts
@@ -50,7 +50,7 @@
   originalOnBeforeUnload = window.onbeforeunload;
   window.onbeforeunload = function (e: BeforeUnloadEvent) {
     // If a test reloads a page, we can't prevent it.
-    // However we can print earror and the stack trace with assert.fail
+    // However we can print an error and the stack trace with assert.fail
     try {
       throw new Error();
     } catch (e) {
@@ -58,7 +58,7 @@
       console.error(e.stack.toString());
     }
     if (originalOnBeforeUnload) {
-      originalOnBeforeUnload.call(this, e);
+      originalOnBeforeUnload.call(window, e);
     }
   };
 });
@@ -102,7 +102,8 @@
 self.flush = flushImpl;
 
 class TestFixtureIdProvider {
-  public static readonly instance: TestFixtureIdProvider = new TestFixtureIdProvider();
+  public static readonly instance: TestFixtureIdProvider =
+    new TestFixtureIdProvider();
 
   private fixturesCount = 1;
 
@@ -117,7 +118,7 @@
 }
 
 class TestFixture {
-  constructor(private readonly fixtureId: string) {}
+  constructor(readonly fixtureId: string) {}
 
   /**
    * Create an instance of a fixture's template.
@@ -198,7 +199,7 @@
 ): TagTestFixture<HTMLElementTagNameMap[T]> {
   const template = document.createElement('template');
   template.innerHTML = `<${tagName}></${tagName}>`;
-  return (fixtureFromTemplate(template) as unknown) as TagTestFixture<
+  return fixtureFromTemplate(template) as unknown as TagTestFixture<
     HTMLElementTagNameMap[T]
   >;
 }
diff --git a/polygerrit-ui/app/test/common-test-setup.ts b/polygerrit-ui/app/test/common-test-setup.ts
index 641e4b3..bd5504a 100644
--- a/polygerrit-ui/app/test/common-test-setup.ts
+++ b/polygerrit-ui/app/test/common-test-setup.ts
@@ -30,10 +30,8 @@
   registerTestCleanup,
   addIronOverlayBackdropStyleEl,
   removeIronOverlayBackdropStyleEl,
-  TestKeyboardShortcutBinder,
+  removeThemeStyles,
 } from './test-utils';
-import {_testOnly_getShortcutManagerInstance} from '../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
-import sinon from 'sinon/pkg/sinon-esm';
 import {safeTypesBridge} from '../utils/safe-types-util';
 import {_testOnly_initGerritPluginApi} from '../elements/shared/gr-js-api-interface/gr-gerrit';
 import {initGlobalVariables} from '../elements/gr-app-global-var-init';
@@ -44,6 +42,9 @@
 } from '../scripts/polymer-resin-install';
 import {_testOnly_allTasks} from '../utils/async-util';
 import {cleanUpStorage} from '../services/storage/gr-storage_mock';
+import {updatePreferences} from '../services/user/user-model';
+import {createDefaultPreferences} from '../constants/constants';
+import {appContext} from '../services/app-context';
 
 declare global {
   interface Window {
@@ -95,20 +96,18 @@
 
 setup(() => {
   testSetupTimestampMs = new Date().getTime();
-  window.Gerrit = {};
-  initGlobalVariables();
   addIronOverlayBackdropStyleEl();
 
   // If the following asserts fails - then window.stub is
   // overwritten by some other code.
   assert.equal(getCleanupsCount(), 0);
+  _testOnlyInitAppContext();
   // The following calls is nessecary to avoid influence of previously executed
   // tests.
-  TestKeyboardShortcutBinder.push();
-  _testOnlyInitAppContext();
+  initGlobalVariables();
   _testOnly_initGerritPluginApi();
-  const mgr = _testOnly_getShortcutManagerInstance();
-  assert.isTrue(mgr._testOnly_isEmpty());
+  const shortcuts = appContext.shortcutsService;
+  assert.isTrue(shortcuts._testOnly_isEmpty());
   const selection = document.getSelection();
   if (selection) {
     selection.removeAllRanges();
@@ -197,11 +196,13 @@
 teardown(() => {
   sinon.restore();
   cleanupTestUtils();
-  TestKeyboardShortcutBinder.pop();
   checkGlobalSpace();
   removeIronOverlayBackdropStyleEl();
+  removeThemeStyles();
   cancelAllTasks();
   cleanUpStorage();
+  // Reset state
+  updatePreferences(createDefaultPreferences());
   const testTeardownTimestampMs = new Date().getTime();
   const elapsedMs = testTeardownTimestampMs - testSetupTimestampMs;
   if (elapsedMs > 1000) {
diff --git a/polygerrit-ui/app/test/mocks/comment-api.js b/polygerrit-ui/app/test/mocks/comment-api.js
index 526aabe..fc4599d 100644
--- a/polygerrit-ui/app/test/mocks/comment-api.js
+++ b/polygerrit-ui/app/test/mocks/comment-api.js
@@ -28,27 +28,6 @@
       _changeComments: Object,
     };
   }
-
-  loadComments() {
-    return this._reloadComments();
-  }
-
-  /**
-   * For the purposes of the mock, _reloadDrafts is not included because its
-   * response is the same type as reloadComments, just makes less API
-   * requests. Since this is for test purposes/mocked data anyway, keep this
-   * file simpler by just using _reloadComments here instead.
-   */
-  _reloadDraftsWithCallback(e) {
-    return this._reloadComments().then(() => e.detail.resolve());
-  }
-
-  _reloadComments() {
-    return this.$.commentAPI.loadAll(this._changeNum)
-        .then(comments => {
-          this._changeComments = this.$.commentAPI._changeComments;
-        });
-  }
 }
 
 /**
diff --git a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
index 90b6125..3bb0c34 100644
--- a/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
+++ b/polygerrit-ui/app/test/mocks/gr-rest-api_mock.ts
@@ -83,6 +83,7 @@
 import {
   createDefaultDiffPrefs,
   createDefaultEditPrefs,
+  createDefaultPreferences,
 } from '../../constants/constants';
 import {ParsedChangeInfo} from '../../types/types';
 
@@ -266,7 +267,7 @@
     return Promise.resolve(createServerInfo());
   },
   getDashboard(): Promise<DashboardInfo | undefined> {
-    throw new Error('getDashboard() not implemented by RestApiMock.');
+    return Promise.resolve(undefined);
   },
   getDefaultPreferences(): Promise<PreferencesInfo | undefined> {
     throw new Error('getDefaultPreferences() not implemented by RestApiMock.');
@@ -356,7 +357,7 @@
   },
   getRepo(repo: RepoName): Promise<ProjectInfo | undefined> {
     return Promise.resolve({
-      id: (repo as string) as UrlEncodedRepoName,
+      id: repo as string as UrlEncodedRepoName,
       name: repo,
     });
   },
@@ -445,9 +446,6 @@
   saveChangeReview() {
     return Promise.resolve(new Response());
   },
-  saveChangeReviewed(): Promise<Response | undefined> {
-    return Promise.resolve(new Response());
-  },
   saveChangeStarred(): Promise<Response> {
     return Promise.resolve(new Response());
   },
@@ -484,8 +482,8 @@
   saveIncludedGroup(): Promise<GroupInfo | undefined> {
     throw new Error('saveIncludedGroup() not implemented by RestApiMock.');
   },
-  savePreferences(): Promise<Response> {
-    return Promise.resolve(new Response());
+  savePreferences(): Promise<PreferencesInfo> {
+    return Promise.resolve(createDefaultPreferences());
   },
   saveRepoConfig(): 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 d74a9c1..483baa6 100644
--- a/polygerrit-ui/app/test/test-app-context-init.ts
+++ b/polygerrit-ui/app/test/test-app-context-init.ts
@@ -21,6 +21,7 @@
 import {AppContext, appContext} from '../services/app-context';
 import {grRestApiMock} from './mocks/gr-rest-api_mock';
 import {grStorageMock} from '../services/storage/gr-storage_mock';
+import {GrAuthMock} from '../services/gr-auth/gr-auth_mock';
 
 export function _testOnlyInitAppContext() {
   initAppContext();
@@ -38,4 +39,5 @@
   setMock('reportingService', grReportingMock);
   setMock('restApiService', grRestApiMock);
   setMock('storageService', grStorageMock);
+  setMock('authService', new GrAuthMock(appContext.eventEmitter));
 }
diff --git a/polygerrit-ui/app/test/test-data-generators.ts b/polygerrit-ui/app/test/test-data-generators.ts
index 778e58d..351cc13 100644
--- a/polygerrit-ui/app/test/test-data-generators.ts
+++ b/polygerrit-ui/app/test/test-data-generators.ts
@@ -16,11 +16,13 @@
  */
 
 import {
+  AccountDetailInfo,
   AccountId,
   AccountInfo,
   AccountsConfigInfo,
   ApprovalInfo,
   AuthInfo,
+  BasePatchSetNum,
   BranchName,
   ChangeConfigInfo,
   ChangeId,
@@ -36,10 +38,16 @@
   ConfigInfo,
   DownloadInfo,
   EditPatchSetNum,
-  GerritInfo,
   EmailAddress,
+  FixId,
+  FixSuggestionInfo,
+  GerritInfo,
   GitPersonInfo,
   GitRef,
+  GroupAuditEventInfo,
+  GroupAuditEventType,
+  GroupId,
+  GroupInfo,
   InheritedBooleanInfo,
   MaxObjectSizeLimitInfo,
   MergeableInfo,
@@ -47,32 +55,29 @@
   PatchSetNum,
   PluginConfigInfo,
   PreferencesInfo,
+  RelatedChangeAndCommitInfo,
+  RelatedChangesInfo,
   RepoName,
+  Requirement,
+  RequirementType,
   Reviewers,
   RevisionInfo,
   SchemesInfoMap,
   ServerInfo,
+  SubmittedTogetherInfo,
   SubmitTypeInfo,
   SuggestInfo,
   Timestamp,
   TimezoneOffset,
-  UserConfigInfo,
-  AccountDetailInfo,
-  Requirement,
-  RequirementType,
   UrlEncodedCommentId,
-  BasePatchSetNum,
-  RelatedChangeAndCommitInfo,
-  SubmittedTogetherInfo,
-  RelatedChangesInfo,
-  FixSuggestionInfo,
-  FixId,
+  UserConfigInfo,
 } from '../types/common';
 import {
   AccountsVisibility,
   AppTheme,
   AuthType,
   ChangeStatus,
+  CommentSide,
   DateFormat,
   DefaultBase,
   DefaultDisplayNameConfig,
@@ -80,22 +85,30 @@
   EmailStrategy,
   InheritedBooleanInfoConfiguredValue,
   MergeabilityComputationBehavior,
+  RequirementStatus,
   RevisionKind,
   SubmitType,
   TimeFormat,
-  RequirementStatus,
-  CommentSide,
 } from '../constants/constants';
 import {formatDate} from '../utils/date-util';
 import {GetDiffCommentsOutput} from '../services/gr-rest-api/gr-rest-api';
 import {AppElementChangeViewParams} from '../elements/gr-app-types';
 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 {createCommentThreads, UIComment, UIDraft} 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';
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
+import {GenerateUrlEditViewParameters} from '../elements/core/gr-navigation/gr-navigation';
+import {
+  DetailedLabelInfo,
+  SubmitRequirementExpressionInfo,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../api/rest-api';
+import {RunResult} from '../services/checks/checks-model';
+import {Category, RunStatus} from '../api/checks';
 
 export function dateToTimestamp(date: Date): Timestamp {
   const nanosecondSuffix = '.000000000';
@@ -181,7 +194,8 @@
 export const TEST_PROJECT_NAME: RepoName = 'test-project' as RepoName;
 export const TEST_BRANCH_ID: BranchName = 'test-branch' as BranchName;
 export const TEST_CHANGE_ID: ChangeId = 'TestChangeId' as ChangeId;
-export const TEST_CHANGE_INFO_ID: ChangeInfoId = `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
+export const TEST_CHANGE_INFO_ID: ChangeInfoId =
+  `${TEST_PROJECT_NAME}~${TEST_BRANCH_ID}~${TEST_CHANGE_ID}` as ChangeInfoId;
 export const TEST_SUBJECT = 'Test subject';
 export const TEST_NUMERIC_CHANGE_ID = 42 as NumericChangeId;
 
@@ -191,7 +205,7 @@
 export function createGitPerson(name = 'Test name'): GitPersonInfo {
   return {
     name,
-    email: `${name}@`,
+    email: `${name}@` as EmailAddress,
     date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
     tz: 0 as TimezoneOffset,
   };
@@ -252,9 +266,9 @@
   };
 }
 
-export function createRevisions(
-  count: number
-): {[revisionId: string]: RevisionInfo} {
+export function createRevisions(count: number): {
+  [revisionId: string]: RevisionInfo;
+} {
   const revisions: {[revisionId: string]: RevisionInfo} = {};
   const revisionDate = TEST_CHANGE_CREATED;
   const revisionIdStart = 1; // The same as getCurrentRevision
@@ -341,14 +355,11 @@
 export function createChangeConfig(): ChangeConfigInfo {
   return {
     large_change: 500,
-    reply_label: 'Reply',
-    reply_tooltip: 'Reply and score',
     // The default update_delay is 5 minutes, but we don't want to accidentally
     // start polling in tests
     update_delay: 0,
     mergeability_computation_behavior:
       MergeabilityComputationBehavior.REF_UPDATED_AND_CHANGE_REINDEX,
-    enable_attention_set: false,
     enable_assignee: false,
   };
 }
@@ -445,6 +456,16 @@
   };
 }
 
+export function createGenerateUrlEditViewParameters(): GenerateUrlEditViewParameters {
+  return {
+    view: GerritView.EDIT,
+    changeNum: TEST_NUMERIC_CHANGE_ID,
+    patchNum: EditPatchSetNum as PatchSetNum,
+    path: 'foo/bar.baz',
+    project: TEST_PROJECT_NAME,
+  };
+}
+
 export function createRequirement(): Requirement {
   return {
     status: RequirementStatus.OK,
@@ -624,3 +645,75 @@
     replacements: [],
   };
 }
+
+export function createGroupInfo(id = 'id'): GroupInfo {
+  return {
+    id: id as GroupId,
+  };
+}
+
+export function createGroupAuditEventInfo(
+  type: GroupAuditEventType
+): GroupAuditEventInfo {
+  if (
+    type === GroupAuditEventType.ADD_USER ||
+    type === GroupAuditEventType.REMOVE_USER
+  ) {
+    return {
+      type,
+      member: createAccountWithId(10),
+      user: createAccountWithId(),
+      date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+    };
+  } else {
+    return {
+      type,
+      member: createGroupInfo(),
+      user: createAccountWithId(),
+      date: dateToTimestamp(new Date(2019, 11, 6, 14, 5, 8)),
+    };
+  }
+}
+
+export function createSubmitRequirementExpressionInfo(): SubmitRequirementExpressionInfo {
+  return {
+    expression: 'label:Verified=MAX -label:Verified=MIN',
+    fulfilled: true,
+    passing_atoms: ['label2:verified=MAX'],
+    failing_atoms: ['label2:verified=MIN'],
+  };
+}
+
+export function createSubmitRequirementResultInfo(): SubmitRequirementResultInfo {
+  return {
+    name: 'Verified',
+    status: SubmitRequirementStatus.SATISFIED,
+    submittability_expression_result: createSubmitRequirementExpressionInfo(),
+  };
+}
+
+export function createRunResult(): RunResult {
+  return {
+    attemptDetails: [],
+    category: Category.INFO,
+    checkName: 'test-name',
+    internalResultId: 'test-internal-result-id',
+    internalRunId: 'test-internal-run-id',
+    isLatestAttempt: true,
+    isSingleAttempt: true,
+    pluginName: 'test-plugin-name',
+    status: RunStatus.COMPLETED,
+    summary: 'This is the test summary.',
+    message: 'This is the test message.',
+  };
+}
+
+export function createDetailedLabelInfo(): DetailedLabelInfo {
+  return {
+    values: {
+      ' 0': 'No score',
+      '+1': 'Style Verified',
+      '-1': 'Wrong Style or Formatting',
+    },
+  };
+}
diff --git a/polygerrit-ui/app/test/test-utils.ts b/polygerrit-ui/app/test/test-utils.ts
index 26b99ac..557fe71 100644
--- a/polygerrit-ui/app/test/test-utils.ts
+++ b/polygerrit-ui/app/test/test-utils.ts
@@ -17,14 +17,15 @@
 import '../types/globals';
 import {_testOnly_resetPluginLoader} from '../elements/shared/gr-js-api-interface/gr-plugin-loader';
 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';
-import {SinonSpy} from 'sinon/pkg/sinon-esm';
+import {SinonSpy} from 'sinon';
 import {StorageService} from '../services/storage/gr-storage';
+import {AuthService} from '../services/gr-auth/gr-auth';
+import {ReportingService} from '../services/gr-reporting/gr-reporting';
+import {CommentsService} from '../services/comments/comments-service';
+import {UserService} from '../services/user/user-service';
+export {query, queryAll, queryAndAssert} from '../utils/common-util';
 
 export interface MockPromise extends Promise<unknown> {
   resolve: (value?: unknown) => void;
@@ -44,68 +45,9 @@
   return getComputedStyle(el).display === 'none';
 }
 
-export function queryAll<E extends Element = Element>(
-  el: Element | undefined,
-  selector: string
-): NodeListOf<E> {
-  if (!el) assert.fail('element not defined');
-  const root = el.shadowRoot ?? el;
-  return root.querySelectorAll<E>(selector);
-}
-
-export function query<E extends Element = Element>(
-  el: Element | undefined,
-  selector: string
-): E | undefined {
-  if (!el) return undefined;
-  const root = el.shadowRoot ?? el;
-  return root.querySelector<E>(selector) ?? undefined;
-}
-
-export function queryAndAssert<E extends Element = Element>(
-  el: Element | undefined,
-  selector: string
-): E {
-  const found = query<E>(el, selector);
-  if (!found) assert.fail(`selector '${selector}' did not match anything'`);
-  return found;
-}
-
-// Some tests/elements can define its own binding. We want to restore bindings
-// at the end of the test. The TestKeyboardShortcutBinder store bindings in
-// stack, so it is possible to override bindings in nested suites.
-export class TestKeyboardShortcutBinder {
-  private static stack: TestKeyboardShortcutBinder[] = [];
-
-  static push() {
-    const testBinder = new TestKeyboardShortcutBinder();
-    this.stack.push(testBinder);
-    return _testOnly_getShortcutManagerInstance();
-  }
-
-  static pop() {
-    const item = this.stack.pop();
-    if (!item) {
-      throw new Error('stack is empty');
-    }
-    item._restoreShortcuts();
-  }
-
-  private readonly originalBinding: Map<Shortcut, string[]>;
-
-  constructor() {
-    this.originalBinding = new Map(
-      _testOnly_getShortcutManagerInstance()._testOnly_getBindings()
-    );
-  }
-
-  _restoreShortcuts() {
-    const bindings = _testOnly_getShortcutManagerInstance()._testOnly_getBindings();
-    bindings.clear();
-    this.originalBinding.forEach((value, key) => {
-      bindings.set(key, value);
-    });
-  }
+export function isVisible(el: Element) {
+  assert.ok(el);
+  return getComputedStyle(el).getPropertyValue('display') !== 'none';
 }
 
 // Provide reset plugins function to clear installed plugins between tests.
@@ -166,10 +108,30 @@
   return sinon.spy(appContext.restApiService, method);
 }
 
+export function stubComments<K extends keyof CommentsService>(method: K) {
+  return sinon.stub(appContext.commentsService, method);
+}
+
+export function stubUsers<K extends keyof UserService>(method: K) {
+  return sinon.stub(appContext.userService, method);
+}
+
 export function stubStorage<K extends keyof StorageService>(method: K) {
   return sinon.stub(appContext.storageService, method);
 }
 
+export function spyStorage<K extends keyof StorageService>(method: K) {
+  return sinon.spy(appContext.storageService, method);
+}
+
+export function stubAuth<K extends keyof AuthService>(method: K) {
+  return sinon.stub(appContext.authService, method);
+}
+
+export function stubReporting<K extends keyof ReportingService>(method: K) {
+  return sinon.stub(appContext.reportingService, method);
+}
+
 export type SinonSpyMember<F extends (...args: any) => any> = SinonSpy<
   Parameters<F>,
   ReturnType<F>
@@ -193,6 +155,34 @@
   el.parentNode?.removeChild(el);
 }
 
+export function removeThemeStyles() {
+  // Do not remove the light theme, because it is only added once statically,
+  // not once per gr-app instantiation.
+  // document.head.querySelector('#light-theme')?.remove();
+  document.head.querySelector('#dark-theme')?.remove();
+}
+
+export function waitUntil(
+  predicate: () => boolean,
+  maxMillis = 100
+): Promise<void> {
+  const start = Date.now();
+  let sleep = 1;
+  return new Promise((resolve, reject) => {
+    const waiter = () => {
+      if (predicate()) {
+        return resolve();
+      }
+      if (Date.now() - start >= maxMillis) {
+        return reject(new Error('Took to long to waitUntil'));
+      }
+      setTimeout(waiter, sleep);
+      sleep *= 2;
+    };
+    waiter();
+  });
+}
+
 /**
  * Promisify an event callback to simplify async...await tests.
  *
diff --git a/polygerrit-ui/app/tsconfig.json b/polygerrit-ui/app/tsconfig.json
index 46d0173d..5040496 100644
--- a/polygerrit-ui/app/tsconfig.json
+++ b/polygerrit-ui/app/tsconfig.json
@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
     /* Basic Options */
-    "target": "es2018", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
     "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
     "allowJs": true, /* Allow javascript files to be compiled. */
     "checkJs": false, /* Report errors in .js files. */
@@ -25,6 +25,7 @@
     "noUnusedLocals": true, /* Report errors on unused locals. */
     "noUnusedParameters": true, /* Report errors on unused parameters. */
     "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+    "noImplicitOverride": true,
     "noFallthroughCasesInSwitch": true,/* Report errors for fallthrough cases in switch statement. */
 
     "skipLibCheck": true, /* Do not check node_modules */
@@ -39,7 +40,38 @@
     "incremental": true,
     "experimentalDecorators": true,
 
-    "allowUmdGlobalAccess": true
+    "allowUmdGlobalAccess": true,
+
+    /* typeRoots for IDE (see tsconfig_bazel.json for Bazel) */
+    "typeRoots": [
+      "node_modules/@types",
+      "../node_modules/@types"
+    ],
+
+    "plugins": [
+      {
+        "name": "ts-lit-plugin",
+        "strict": true,
+        "rules": {
+          "no-unknown-tag-name": "error",
+          "no-unclosed-tag": "error",
+          "no-unknown-property": "error",
+          "no-unintended-mixed-binding": "error",
+          "no-invalid-boolean-binding": "error",
+          "no-expressionless-property-binding": "error",
+          "no-noncallable-event-binding": "error",
+          "no-boolean-in-attribute-binding": "error",
+          "no-complex-attribute-binding": "error",
+          "no-nullable-attribute-binding": "error",
+          "no-incompatible-type-binding": "error",
+          "no-invalid-directive-binding": "error",
+          "no-incompatible-property-type": "error",
+          "no-unknown-property-converter": "error",
+          "no-invalid-attribute-name": "error",
+          "no-invalid-tag-name": "error"
+        }
+      }
+    ]
   },
   // With the * pattern (without an extension), only supported files
   // are included. The supported files are .ts, .tsx, .d.ts.
@@ -61,6 +93,7 @@
     "styles/**/*",
     "types/**/*",
     "utils/**/*",
-    "test/**/*"
+    "test/**/*",
+    "tmpl_out/**/*" //Created by template checker in dev-mode
   ]
 }
diff --git a/polygerrit-ui/app/tsconfig_bazel_test.json b/polygerrit-ui/app/tsconfig_bazel_test.json
index 9c2ff93..7137e23 100644
--- a/polygerrit-ui/app/tsconfig_bazel_test.json
+++ b/polygerrit-ui/app/tsconfig_bazel_test.json
@@ -2,7 +2,6 @@
   "extends": "./tsconfig_bazel.json",
   "compilerOptions": {
     "typeRoots": [
-      "./test/@types",
       "../../external/ui_dev_npm/node_modules/@polymer/iron-test-helpers",
       "../../external/ui_npm/node_modules/@types",
       "../../external/ui_dev_npm/node_modules/@types"
diff --git a/polygerrit-ui/wct.conf.js b/polygerrit-ui/app/types/aria-mixin.ts
similarity index 63%
rename from polygerrit-ui/wct.conf.js
rename to polygerrit-ui/app/types/aria-mixin.ts
index 2096e60..6ae8c2a 100644
--- a/polygerrit-ui/wct.conf.js
+++ b/polygerrit-ui/app/types/aria-mixin.ts
@@ -14,21 +14,17 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+export {};
 
-var path = require('path');
-
-var ret = {
-  suites: ['app/test'],
-  webserver: {
-    pathMappings: []
+declare global {
+  // The current version of lib.dom.d.ts doesn't contains AriaMixin definition,
+  // so we have to add some of them ourself.
+  // https://developer.mozilla.org/en-US/docs/Web/API/Element#properties_included_from_aria
+  interface AriaMixin {
+    ariaLabel?: string;
   }
-};
 
-var mapping = {};
-var rootPath = (__dirname).split(path.sep).slice(-1)[0];
-
-mapping['/components/' + rootPath  + '/app/bower_components'] = 'bower_components';
-
-ret.webserver.pathMappings.push(mapping);
-
-module.exports = ret;
+  interface Element extends AriaMixin {
+    ariaLabel: string;
+  }
+}
diff --git a/polygerrit-ui/app/types/common.ts b/polygerrit-ui/app/types/common.ts
index 3183510..1617aa3 100644
--- a/polygerrit-ui/app/types/common.ts
+++ b/polygerrit-ui/app/types/common.ts
@@ -14,24 +14,13 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {CommentRange} from '../api/core';
 import {
   ChangeStatus,
-  DefaultDisplayNameConfig,
-  FileInfoStatus,
-  GpgKeyInfoStatus,
-  ProblemInfoStatus,
   ProjectState,
-  RequirementStatus,
-  ReviewerState,
-  RevisionKind,
   SubmitType,
   InheritedBooleanInfoConfiguredValue,
-  ConfigParameterInfoType,
-  AccountTag,
   PermissionAction,
-  HttpMethod,
   CommentSide,
   AppTheme,
   DateFormat,
@@ -43,19 +32,183 @@
   DraftsAction,
   NotifyType,
   EmailFormat,
-  AuthType,
   MergeStrategy,
-  EditableAccountField,
-  MergeabilityComputationBehavior,
 } from '../constants/constants';
 import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
+import {
+  AccountId,
+  AccountDetailInfo,
+  AccountInfo,
+  AccountsConfigInfo,
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  ApprovalInfo,
+  AuthInfo,
+  AvatarInfo,
+  BasePatchSetNum,
+  BranchName,
+  BrandType,
+  ChangeConfigInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeSubmissionId,
+  CommentLinkInfo,
+  CommentLinks,
+  CommitId,
+  CommitInfo,
+  ConfigArrayParameterInfo,
+  ConfigInfo,
+  ConfigListParameterInfo,
+  ConfigParameterInfo,
+  ConfigParameterInfoBase,
+  ContributorAgreementInfo,
+  DetailedLabelInfo,
+  DownloadInfo,
+  DownloadSchemeInfo,
+  EmailAddress,
+  FetchInfo,
+  FileInfo,
+  GerritInfo,
+  GitPersonInfo,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  GroupOptionsInfo,
+  Hashtag,
+  InheritedBooleanInfo,
+  LabelInfo,
+  LabelNameToInfoMap,
+  LabelNameToLabelTypeInfoMap,
+  LabelNameToValueMap,
+  LabelTypeInfo,
+  LabelTypeInfoValues,
+  LabelValueToDescriptionMap,
+  MaxObjectSizeLimitInfo,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  PluginConfigInfo,
+  PluginNameToPluginParametersMap,
+  PluginParameterToConfigParameterInfoMap,
+  ProjectInfo,
+  ProjectInfoWithName,
+  QuickLabelInfo,
+  ReceiveInfo,
+  RepoName,
+  Requirement,
+  RequirementType,
+  ReviewInputTag,
+  ReviewerState,
+  ReviewerUpdateInfo,
+  Reviewers,
+  RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
+  SubmitTypeInfo,
+  SuggestInfo,
+  Timestamp,
+  TimezoneOffset,
+  TopicName,
+  UrlEncodedRepoName,
+  UserConfigInfo,
+  VotingRangeInfo,
+  WebLinkInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+} from '../api/rest-api';
+import {DiffInfo, IgnoreWhitespaceType} from './diff';
 
-import {DiffInfo, IgnoreWhitespaceType, WebLinkInfo} from './diff';
-
-export {CommentRange};
-
-export type BrandType<T, BrandName extends string> = T &
-  {[__brand in BrandName]: never};
+export {
+  AccountId,
+  AccountDetailInfo,
+  AccountInfo,
+  AccountsConfigInfo,
+  ActionInfo,
+  ActionNameToActionInfoMap,
+  ApprovalInfo,
+  AuthInfo,
+  AvatarInfo,
+  BasePatchSetNum,
+  BranchName,
+  BrandType,
+  ChangeConfigInfo,
+  ChangeId,
+  ChangeInfo,
+  ChangeInfoId,
+  ChangeMessageId,
+  ChangeMessageInfo,
+  ChangeSubmissionId,
+  CommentLinkInfo,
+  CommentLinks,
+  CommentRange,
+  CommitId,
+  CommitInfo,
+  ConfigArrayParameterInfo,
+  ConfigInfo,
+  ConfigListParameterInfo,
+  ConfigParameterInfo,
+  ConfigParameterInfoBase,
+  ContributorAgreementInfo,
+  DetailedLabelInfo,
+  DownloadInfo,
+  DownloadSchemeInfo,
+  EmailAddress,
+  FileInfo,
+  GerritInfo,
+  GitPersonInfo,
+  GitRef,
+  GpgKeyId,
+  GpgKeyInfo,
+  GroupId,
+  GroupInfo,
+  GroupName,
+  GroupOptionsInfo,
+  Hashtag,
+  InheritedBooleanInfo,
+  LabelInfo,
+  LabelNameToInfoMap,
+  LabelNameToLabelTypeInfoMap,
+  LabelNameToValueMap,
+  LabelTypeInfo,
+  LabelTypeInfoValues,
+  LabelValueToDescriptionMap,
+  MaxObjectSizeLimitInfo,
+  NumericChangeId,
+  ParentCommitInfo,
+  PatchSetNum,
+  PluginConfigInfo,
+  PluginNameToPluginParametersMap,
+  PluginParameterToConfigParameterInfoMap,
+  ProjectInfo,
+  ProjectInfoWithName,
+  QuickLabelInfo,
+  ReceiveInfo,
+  RepoName,
+  Requirement,
+  RequirementType,
+  ReviewInputTag,
+  ReviewerUpdateInfo,
+  Reviewers,
+  RevisionInfo,
+  SchemesInfoMap,
+  ServerInfo,
+  SubmitTypeInfo,
+  SuggestInfo,
+  Timestamp,
+  TimezoneOffset,
+  TopicName,
+  UrlEncodedRepoName,
+  UserConfigInfo,
+  VotingRangeInfo,
+  isDetailedLabelInfo,
+  isQuickLabelInfo,
+};
 
 /*
  * In T, make a set of properties whose keys are in the union K required
@@ -75,29 +228,18 @@
  */
 export type ParsedJSON = BrandType<unknown, '_parsedJSON'>;
 
-export type PatchSetNum = BrandType<'PARENT' | 'edit' | number, '_patchSet'>;
-export type BasePatchSetNum = BrandType<'PARENT' | number, '_patchSet'>;
 export type RevisionPatchSetNum = BrandType<'edit' | number, '_patchSet'>;
+
 export type PatchSetNumber = BrandType<number, '_patchSet'>;
 
 export const EditPatchSetNum = 'edit' as RevisionPatchSetNum;
+
 // TODO(TS): This is not correct, it is better to have a separate ApiPatchSetNum
 // without 'parent'.
 export const ParentPatchSetNum = 'PARENT' as BasePatchSetNum;
 
-export type ChangeId = BrandType<string, '_changeId'>;
-export type ChangeMessageId = BrandType<string, '_changeMessageId'>;
-export type NumericChangeId = BrandType<number, '_numericChangeId'>;
-export type RepoName = BrandType<string, '_repoName'>;
-export type UrlEncodedRepoName = BrandType<string, '_urlEncodedRepoName'>;
-export type TopicName = BrandType<string, '_topicName'>;
-// TODO(TS): Probably, we should separate AccountId and EncodedAccountId
-export type AccountId = BrandType<number, '_accountId'>;
-export type GitRef = BrandType<string, '_gitRef'>;
-export type RequirementType = BrandType<string, '_requirementType'>;
-export type TrackingId = BrandType<string, '_trackingId'>;
-export type ReviewInputTag = BrandType<string, '_reviewInputTag'>;
 export type RobotId = BrandType<string, '_robotId'>;
+
 export type RobotRunId = BrandType<string, '_robotRunId'>;
 
 // RevisionId '0' is the same as 'current'. However, we want to avoid '0'
@@ -106,7 +248,6 @@
 
 // The UUID of the suggested fix.
 export type FixId = BrandType<string, '_fixId'>;
-export type EmailAddress = BrandType<string, '_emailAddress'>;
 
 // The URL encoded UUID of the comment
 export type UrlEncodedCommentId = BrandType<string, '_urlEncodedCommentId'>;
@@ -114,197 +255,19 @@
 // The ID of the dashboard, in the form of '<ref>:<path>'
 export type DashboardId = BrandType<string, '_dahsboardId'>;
 
-// The 8-char hex GPG key ID.
-export type GpgKeyId = BrandType<string, '_gpgKeyId'>;
-
-// The 40-char (plus spaces) hex GPG key fingerprint
-export type GpgKeyFingerprint = BrandType<string, '_gpgKeyFingerprint'>;
-
-// OpenPGP User IDs (https://tools.ietf.org/html/rfc4880#section-5.11).
-export type OpenPgpUserIds = BrandType<string, '_openPgpUserIds'>;
-
-// This ID is equal to the numeric ID of the change that triggered the
-// submission. If the change that triggered the submission also has a topic, it
-// will be "<id>-<topic>" of the change that triggered the submission
-// The callers must not rely on the format of the submission ID.
-export type ChangeSubmissionId = BrandType<
-  string | number,
-  '_changeSubmissionId'
->;
-
-// The refs/heads/ prefix is omitted in Branch name
-export type BranchName = BrandType<string, '_branchName'>;
-
 // The refs/tags/ prefix is omitted in Tag name
 export type TagName = BrandType<string, '_tagName'>;
 
-// The ID of the change in the format "'<project>~<branch>~<Change-Id>'"
-export type ChangeInfoId = BrandType<string, '_changeInfoId'>;
-export type Hashtag = BrandType<string, '_hashtag'>;
-export type StarLabel = BrandType<string, '_startLabel'>;
-export type CommitId = BrandType<string, '_commitId'>;
 export type LabelName = BrandType<string, '_labelName'>;
-export type GroupName = BrandType<string, '_groupName'>;
-
-// The UUID of the group
-export type GroupId = BrandType<string, '_groupId'>;
 
 // The Encoded UUID of the group
 export type EncodedGroupId = BrandType<string, '_encodedGroupId'>;
 
-// The timezone offset from UTC in minutes
-export type TimezoneOffset = BrandType<number, '_timezoneOffset'>;
-
-// Timestamps are given in UTC and have the format
-// "'yyyy-mm-dd hh:mm:ss.fffffffff'"
-// where "'ffffffffff'" represents nanoseconds.
-export type Timestamp = BrandType<string, '_timestamp'>;
-
-export type IdToAttentionSetMap = {[accountId: string]: AttentionSetInfo};
-export type LabelNameToInfoMap = {[labelName: string]: LabelInfo};
-
-// {Verified: ["-1", " 0", "+1"]}
-export type LabelNameToValueMap = {[labelName: string]: string[]};
-
-// The map maps the values (“-2”, “-1”, " `0`", “+1”, “+2”) to the value descriptions.
-export type LabelValueToDescriptionMap = {[labelValue: string]: string};
-
-/**
- * The LabelInfo entity contains information about a label on a change, always
- * corresponding to the current patch set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#label-info
- */
-export type LabelInfo =
-  | QuickLabelInfo
-  | DetailedLabelInfo
-  | (QuickLabelInfo & DetailedLabelInfo);
-
-interface LabelCommonInfo {
-  optional?: boolean; // not set if false
-}
-
-export interface QuickLabelInfo extends LabelCommonInfo {
-  approved?: AccountInfo;
-  rejected?: AccountInfo;
-  recommended?: AccountInfo;
-  disliked?: AccountInfo;
-  blocking?: boolean; // not set if false
-  value?: number; // The voting value of the user who recommended/disliked this label on the change if it is not “+1”/“-1”.
-  default_value?: number;
-}
-
-/**
- * LabelInfo when DETAILED_LABELS are requested.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#_fields_set_by_code_detailed_labels_code
- */
-export interface DetailedLabelInfo extends LabelCommonInfo {
-  // This is not set when the change has no reviewers.
-  all?: ApprovalInfo[];
-  // Docs claim that 'values' is optional, but it is actually always set.
-  values?: LabelValueToDescriptionMap; // A map of all values that are allowed for this label
-  default_value?: number;
-}
-
-export function isQuickLabelInfo(
-  l: LabelInfo
-): l is QuickLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
-  const quickLabelInfo = l as QuickLabelInfo;
-  return (
-    quickLabelInfo.approved !== undefined ||
-    quickLabelInfo.rejected !== undefined ||
-    quickLabelInfo.recommended !== undefined ||
-    quickLabelInfo.disliked !== undefined ||
-    quickLabelInfo.blocking !== undefined ||
-    quickLabelInfo.blocking !== undefined ||
-    quickLabelInfo.value !== undefined
-  );
-}
-
-export function isDetailedLabelInfo(
-  label: LabelInfo
-): label is DetailedLabelInfo | (QuickLabelInfo & DetailedLabelInfo) {
-  return !!(label as DetailedLabelInfo).values;
-}
-
 // https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-input
 export interface ContributorAgreementInput {
   name?: string;
 }
 
-// https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#contributor-agreement-info
-export interface ContributorAgreementInfo {
-  name: string;
-  description: string;
-  url: string;
-  auto_verify_group?: GroupInfo;
-}
-
-/**
- * The ChangeInfo entity contains information about a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-info
- */
-export interface ChangeInfo {
-  id: ChangeInfoId;
-  project: RepoName;
-  branch: BranchName;
-  topic?: TopicName;
-  attention_set?: IdToAttentionSetMap;
-  assignee?: AccountInfo;
-  hashtags?: Hashtag[];
-  change_id: ChangeId;
-  subject: string;
-  status: ChangeStatus;
-  created: Timestamp;
-  updated: Timestamp;
-  submitted?: Timestamp;
-  submitter?: AccountInfo;
-  starred?: boolean; // not set if false
-  stars?: StarLabel[];
-  reviewed?: boolean; // not set if false
-  submit_type?: SubmitType;
-  mergeable?: boolean;
-  submittable?: boolean;
-  insertions: number; // Number of inserted lines
-  deletions: number; // Number of deleted lines
-  total_comment_count?: number;
-  unresolved_comment_count?: number;
-  _number: NumericChangeId;
-  owner: AccountInfo;
-  actions?: ActionNameToActionInfoMap;
-  requirements?: Requirement[];
-  labels?: LabelNameToInfoMap;
-  permitted_labels?: LabelNameToValueMap;
-  removable_reviewers?: AccountInfo[];
-  // This is documented as optional, but actually always set.
-  reviewers: Reviewers;
-  pending_reviewers?: AccountInfo[];
-  reviewer_updates?: ReviewerUpdateInfo[];
-  messages?: ChangeMessageInfo[];
-  current_revision?: CommitId;
-  revisions?: {[revisionId: string]: RevisionInfo};
-  tracking_ids?: TrackingIdInfo[];
-  _more_changes?: boolean; // not set if false
-  problems?: ProblemInfo[];
-  is_private?: boolean; // not set if false
-  work_in_progress?: boolean; // not set if false
-  has_review_started?: boolean; // not set if false
-  revert_of?: NumericChangeId;
-  submission_id?: ChangeSubmissionId;
-  cherry_pick_of_change?: NumericChangeId;
-  cherry_pick_of_patch_set?: PatchSetNum;
-  contains_git_conflicts?: boolean;
-  internalHost?: string; // TODO(TS): provide an explanation what is its
-}
-
-/**
- * The reviewers as a map that maps a reviewer state to a list of AccountInfo
- * entities. Possible reviewer states are REVIEWER, CC and REMOVED.
- * REVIEWER: Users with at least one non-zero vote on the change.
- * CC: Users that were added to the change, but have not voted.
- * REMOVED: Users that were previously reviewers on the change, but have been removed.
- */
-export type Reviewers = Partial<Record<ReviewerState, AccountInfo[]>>;
-
 /**
  * ChangeView request change detail with ALL_REVISIONS option set.
  * The response always contains current_revision and revisions.
@@ -313,27 +276,6 @@
   ChangeInfo,
   'current_revision' | 'revisions'
 >;
-/**
- * The AccountInfo entity contains information about an account.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-info
- */
-export interface AccountInfo {
-  // Normally _account_id is defined (for known Gerrit users), but users can
-  // also be CCed just with their email address. So you have to be prepared that
-  // _account_id is undefined, but then email must be set.
-  _account_id?: AccountId;
-  name?: string;
-  display_name?: string;
-  // Must be set, if _account_id is undefined.
-  email?: EmailAddress;
-  secondary_emails?: string[];
-  username?: string;
-  avatars?: AvatarInfo[];
-  _more_accounts?: boolean; // not set if false
-  status?: string; // status message of the account
-  inactive?: boolean; // not set if false
-  tags?: AccountTag[];
-}
 
 export function isAccount(x: AccountInfo | GroupInfo): x is AccountInfo {
   const account = x as AccountInfo;
@@ -345,34 +287,63 @@
 }
 
 /**
- * The AccountDetailInfo entity contains detailed information about an account.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-detail-info
- */
-export interface AccountDetailInfo extends AccountInfo {
-  registered_on: Timestamp;
-}
-
-/**
  * The AccountExternalIdInfo entity contains information for an external id of
  * an account.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#account-external-id-info
  */
 export interface AccountExternalIdInfo {
   identity: string;
-  email?: string;
+  email_address?: string;
   trusted?: boolean;
   can_delete?: boolean;
 }
 
 /**
  * The GroupAuditEventInfo entity contains information about an auditevent of a group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-audit-event-info
  */
-export interface GroupAuditEventInfo {
-  member: string;
-  type: string;
-  user: string;
-  date: string;
+export type GroupAuditEventInfo =
+  | GroupAuditAccountEventInfo
+  | GroupAuditGroupEventInfo;
+
+export enum GroupAuditEventType {
+  ADD_USER = 'ADD_USER',
+  REMOVE_USER = 'REMOVE_USER',
+  ADD_GROUP = 'ADD_GROUP',
+  REMOVE_GROUP = 'REMOVE_GROUP',
+}
+
+export interface GroupAuditEventInfoBase {
+  user: AccountInfo;
+  date: Timestamp;
+}
+
+export interface GroupAuditAccountEventInfo extends GroupAuditEventInfoBase {
+  type: GroupAuditEventType.ADD_USER | GroupAuditEventType.REMOVE_USER;
+  member: AccountInfo;
+}
+
+export interface GroupAuditGroupEventInfo extends GroupAuditEventInfoBase {
+  type: GroupAuditEventType.ADD_GROUP | GroupAuditEventType.REMOVE_GROUP;
+  member: GroupInfo;
+}
+
+export function isGroupAuditAccountEventInfo(
+  x: GroupAuditEventInfo
+): x is GroupAuditAccountEventInfo {
+  return (
+    x.type === GroupAuditEventType.ADD_USER ||
+    x.type === GroupAuditEventType.REMOVE_USER
+  );
+}
+
+export function isGroupAuditGroupEventInfo(
+  x: GroupAuditEventInfo
+): x is GroupAuditGroupEventInfo {
+  return (
+    x.type === GroupAuditEventType.ADD_GROUP ||
+    x.type === GroupAuditEventType.REMOVE_GROUP
+  );
 }
 
 /**
@@ -384,26 +355,6 @@
   name: GroupName;
 }
 
-/**
- * The GroupInfo entity contains information about a group. This can be a
- * Gerrit internal group, or an external group that is known to Gerrit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html#group-info
- */
-export interface GroupInfo {
-  id: GroupId;
-  name?: GroupName;
-  url?: string;
-  options?: GroupOptionsInfo;
-  description?: string;
-  group_id?: string;
-  owner?: string;
-  owner_id?: string;
-  created_on?: string;
-  _more_groups?: boolean;
-  members?: AccountInfo[];
-  includes?: GroupInfo[];
-}
-
 export type GroupNameToGroupInfoMap = {[groupName: string]: GroupInfo};
 
 /**
@@ -421,14 +372,6 @@
 }
 
 /**
- * Options of the group.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
- */
-export interface GroupOptionsInfo {
-  visible_to_all: boolean;
-}
-
-/**
  * New options for a group.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-groups.html
  */
@@ -456,188 +399,6 @@
   members?: string[];
 }
 
-/**
- * The ActionInfo entity describes a REST API call the client canmake to
- * manipulate a resource. These are frequently implemented by plugins and may
- * be discovered at runtime.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#action-info
- */
-export interface ActionInfo {
-  method?: HttpMethod; // Most actions use POST, PUT or DELETE to cause state changes.
-  label?: string; // Short title to display to a user describing the action
-  title?: string; // Longer text to display describing the action
-  enabled?: boolean; // not set if false
-}
-
-export interface ActionNameToActionInfoMap {
-  [actionType: string]: ActionInfo | undefined;
-  // List of actions explicitly used in code:
-  wip?: ActionInfo;
-  publishEdit?: ActionInfo;
-  rebaseEdit?: ActionInfo;
-  deleteEdit?: ActionInfo;
-  edit?: ActionInfo;
-  stopEdit?: ActionInfo;
-  download?: ActionInfo;
-  rebase?: ActionInfo;
-  cherrypick?: ActionInfo;
-  move?: ActionInfo;
-  revert?: ActionInfo;
-  revert_submission?: ActionInfo;
-  abandon?: ActionInfo;
-  submit?: ActionInfo;
-  topic?: ActionInfo;
-  hashtags?: ActionInfo;
-  assignee?: ActionInfo;
-  ready?: ActionInfo;
-}
-
-/**
- * The Requirement entity contains information about a requirement relative to
- * a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#requirement
- */
-export interface Requirement {
-  status: RequirementStatus;
-  fallbackText: string; // A human readable reason
-  type: RequirementType;
-}
-
-/**
- * The ReviewerUpdateInfo entity contains information about updates to change’s
- * reviewers set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#review-update-info
- */
-export interface ReviewerUpdateInfo {
-  updated: Timestamp;
-  updated_by: AccountInfo;
-  reviewer: AccountInfo;
-  state: ReviewerState;
-}
-
-/**
- * The ChangeMessageInfo entity contains information about a messageattached
- * to a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-message-info
- */
-export interface ChangeMessageInfo {
-  id: ChangeMessageId;
-  author?: AccountInfo;
-  reviewer?: AccountInfo;
-  updated_by?: AccountInfo;
-  real_author?: AccountInfo;
-  date: Timestamp;
-  message: string;
-  tag?: ReviewInputTag;
-  _revision_number?: PatchSetNum;
-}
-
-/**
- * The RevisionInfo entity contains information about a patch set.Not all
- * fields are returned by default.  Additional fields can be obtained by
- * adding o parameters as described in Query Changes.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#revision-info
- * basePatchNum is present in case RevisionInfo is of type 'edit'
- */
-export interface RevisionInfo {
-  kind: RevisionKind;
-  _number: PatchSetNum;
-  created: Timestamp;
-  uploader: AccountInfo;
-  ref: GitRef;
-  fetch?: {[protocol: string]: FetchInfo};
-  commit?: CommitInfo;
-  files?: {[filename: string]: FileInfo};
-  actions?: ActionNameToActionInfoMap;
-  reviewed?: boolean;
-  commit_with_footers?: boolean;
-  push_certificate?: PushCertificateInfo;
-  description?: string;
-  basePatchNum?: BasePatchSetNum;
-}
-
-/**
- * The TrackingIdInfo entity describes a reference to an external tracking
- * system.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#tracking-id-info
- */
-export interface TrackingIdInfo {
-  system: string;
-  id: TrackingId;
-}
-
-/**
- * The ProblemInfo entity contains a description of a potential consistency
- * problem with a change. These are not related to the code review process,
- * but rather indicate some inconsistency in Gerrit’s database or repository
- * metadata related to the enclosing change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#problem-info
- */
-export interface ProblemInfo {
-  message: string;
-  status?: ProblemInfoStatus; // Only set if a fix was attempted
-  outcome?: string;
-}
-
-/**
- * The AttentionSetInfo entity contains details of users that are in the attention set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#attention-set-info
- */
-export interface AttentionSetInfo {
-  account: AccountInfo;
-  last_update?: Timestamp;
-  reason?: string;
-}
-
-/**
- * The ApprovalInfo entity contains information about an approval from auser
- * for a label on a change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#approval-info
- */
-export interface ApprovalInfo extends AccountInfo {
-  value?: number;
-  permitted_voting_range?: VotingRangeInfo;
-  date?: Timestamp;
-  tag?: ReviewInputTag;
-  post_submit?: boolean; // not set if false
-}
-
-/**
- * The AvartarInfo entity contains information about an avatar image ofan
- * account.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#avatar-info
- */
-export interface AvatarInfo {
-  url: string;
-  height: number;
-  width: number;
-}
-
-/**
- * The FetchInfo entity contains information about how to fetch a patchset via
- * a certain protocol.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#fetch-info
- */
-export interface FetchInfo {
-  url: string;
-  ref: string;
-  commands?: {[commandName: string]: string};
-}
-
-/**
- * The CommitInfo entity contains information about a commit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
- */
-export interface CommitInfo {
-  commit?: CommitId;
-  parents: ParentCommitInfo[];
-  author: GitPersonInfo;
-  committer: GitPersonInfo;
-  subject: string;
-  message: string;
-  web_links?: WebLinkInfo[];
-}
-
 export interface CommitInfoWithRequiredCommit extends CommitInfo {
   commit: CommitId;
 }
@@ -652,55 +413,6 @@
 }
 
 /**
- * The parent commits of this commit as a list of CommitInfo entities.
- * In each parent only the commit and subject fields are populated.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#commit-info
- */
-export interface ParentCommitInfo {
-  commit: CommitId;
-  subject: string;
-}
-
-/**
- * The FileInfo entity contains information about a file in a patch set.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#file-info
- */
-export interface FileInfo {
-  status?: FileInfoStatus;
-  binary?: boolean; // not set if false
-  old_path?: string;
-  lines_inserted?: number;
-  lines_deleted?: number;
-  size_delta: number; // in bytes
-  size: number; // in bytes
-}
-
-/**
- * The PushCertificateInfo entity contains information about a pushcertificate
- * provided when the user pushed for review with git push
- * --signed HEAD:refs/for/<branch>. Only used when signed push is
- * enabled on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#push-certificate-info
- */
-export interface PushCertificateInfo {
-  certificate: string;
-  key: GpgKeyInfo;
-}
-
-/**
- * The GpgKeyInfo entity contains information about a GPG public key.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-key-info
- */
-export interface GpgKeyInfo {
-  id?: GpgKeyId;
-  fingerprint?: GpgKeyFingerprint;
-  user_ids?: OpenPgpUserIds[];
-  key?: string; // ASCII armored public key material
-  status?: GpgKeyInfoStatus;
-  problems?: string[];
-}
-
-/**
  * The GpgKeysInput entity contains information for adding/deleting GPG keys.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-accounts.html#gpg-keys-input
  */
@@ -710,58 +422,6 @@
 }
 
 /**
- * The GitPersonInfo entity contains information about theauthor/committer of
- * a commit.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#git-person-info
- */
-export interface GitPersonInfo {
-  name: string;
-  email: string;
-  date: Timestamp;
-  tz: TimezoneOffset;
-}
-
-/**
- * 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
- */
-export interface VotingRangeInfo {
-  min: number;
-  max: number;
-}
-
-/**
- * The AccountsConfigInfo entity contains information about Gerrit configuration
- * from the accounts section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#accounts-config-info
- */
-export interface AccountsConfigInfo {
-  visibility: string;
-  default_display_name: DefaultDisplayNameConfig;
-}
-
-/**
- * The AuthInfo entity contains information about the authentication
- * configuration of the Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#auth-info
- */
-export interface AuthInfo {
-  auth_type: AuthType; // docs incorrectly names it 'type'
-  use_contributor_agreements?: boolean;
-  contributor_agreements?: ContributorAgreementInfo[];
-  editable_account_fields: EditableAccountField[];
-  login_url?: string;
-  login_text?: string;
-  switch_account_url?: string;
-  register_url?: string;
-  register_text?: string;
-  edit_full_name_url?: string;
-  http_password_url?: string;
-  git_basic_auth_policy?: string;
-}
-
-/**
  * The CacheInfo entity contains information about a cache.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
  */
@@ -795,24 +455,6 @@
 export type CapabilityInfoMap = {[id: string]: CapabilityInfo};
 
 /**
- * The ChangeConfigInfo entity contains information about Gerrit configuration
- * from the change section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-config-info
- */
-export interface ChangeConfigInfo {
-  allow_blame?: boolean;
-  large_change: number;
-  reply_label: string;
-  reply_tooltip: string;
-  update_delay: number;
-  submit_whole_topic?: boolean;
-  disable_private_changes?: boolean;
-  mergeability_computation_behavior: MergeabilityComputationBehavior;
-  enable_attention_set: boolean;
-  enable_assignee: boolean;
-}
-
-/**
  * The ChangeIndexConfigInfo entity contains information about Gerrit
  * configuration from the index.change section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#change-index-config-info
@@ -899,32 +541,6 @@
   new_value: string;
 }
 
-export type SchemesInfoMap = {[name: string]: DownloadSchemeInfo};
-
-/**
- * The DownloadInfo entity contains information about supported download
- * options.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#download-info
- */
-export interface DownloadInfo {
-  schemes: SchemesInfoMap;
-  archives: string[];
-}
-
-export type CloneCommandMap = {[name: string]: string};
-/**
- * The DownloadSchemeInfo entity contains information about a supported download
- * scheme and its commands.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
- */
-export interface DownloadSchemeInfo {
-  url: string;
-  is_auth_required: boolean;
-  is_auth_supported: boolean;
-  commands: string;
-  clone_commands: CloneCommandMap;
-}
-
 /**
  * The EmailConfirmationInput entity contains information for confirming an
  * email address.
@@ -945,22 +561,6 @@
 }
 
 /**
- * The GerritInfo entity contains information about Gerrit configuration from
- * the gerrit section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#gerrit-info
- */
-export interface GerritInfo {
-  all_projects: string; // Doc contains incorrect name
-  all_users: string; // Doc contains incorrect name
-  doc_search: boolean;
-  doc_url?: string;
-  edit_gpg_keys?: boolean;
-  report_bug_url?: string;
-  // The following property is missed in doc
-  primary_weblink_name?: string;
-}
-
-/**
  * The IndexConfigInfo entity contains information about Gerrit configuration
  * from the index section.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#index-config-info
@@ -1018,65 +618,6 @@
 }
 
 /**
- * The PluginConfigInfo entity contains information about Gerrit extensions by
- * plugins.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#plugin-config-info
- */
-export interface PluginConfigInfo {
-  has_avatars: boolean;
-  // Exists in Java class, but not mentioned in docs.
-  js_resource_paths: string[];
-}
-
-/**
- * The ReceiveInfo entity contains information about the configuration of
- * git-receive-pack behavior on the server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#receive-info
- */
-export interface ReceiveInfo {
-  enable_signed_push?: string;
-}
-
-/**
- * The ServerInfo entity contains information about the configuration of the
- * Gerrit server.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#server-info
- */
-export interface ServerInfo {
-  accounts: AccountsConfigInfo;
-  auth: AuthInfo;
-  change: ChangeConfigInfo;
-  download: DownloadInfo;
-  gerrit: GerritInfo;
-  // docs mentions index property, but it doesn't exists in Java class
-  // index: IndexConfigInfo;
-  note_db_enabled?: boolean;
-  plugin: PluginConfigInfo;
-  receive?: ReceiveInfo;
-  sshd?: SshdInfo;
-  suggest: SuggestInfo;
-  user: UserConfigInfo;
-  default_theme?: string;
-}
-
-/**
- * The SshdInfo entity contains information about Gerrit configuration from the sshd section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#sshd-info
- * This entity doesn’t contain any data, but the presence of this (empty) entity
- * in the ServerInfo entity means that SSHD is enabled on the server.
- */
-export type SshdInfo = {};
-
-/**
- * The SuggestInfo entity contains information about Gerritconfiguration from
- * the suggest section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#suggest-info
- */
-export interface SuggestInfo {
-  from: number;
-}
-
-/**
  * The SummaryInfo entity contains information about the current state of the
  * server.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html
@@ -1146,15 +687,6 @@
 }
 
 /**
- * The UserConfigInfo entity contains information about Gerrit configuration
- * from the user section.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-config.html#user-config-info
- */
-export interface UserConfigInfo {
-  anonymous_coward_name: string;
-}
-
-/*
  * The CommentInfo entity contains information about an inline comment.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#comment-info
  */
@@ -1190,45 +722,7 @@
   context_line: string;
 }
 
-/**
- * The ProjectInfo entity contains information about a project
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#project-info
- */
-export interface ProjectInfo {
-  id: UrlEncodedRepoName;
-  // name is not set if returned in a map where the project name is used as
-  // map key
-  name?: RepoName;
-  // ?-<n> if the parent project is not visible (<n> is a number which
-  // is increased for each non-visible project).
-  parent?: RepoName;
-  description?: string;
-  state?: ProjectState;
-  branches?: {[branchName: string]: CommitId};
-  // labels is filled for Create Project and Get Project calls.
-  labels?: LabelNameToLabelTypeInfoMap;
-  // Links to the project in external sites
-  web_links?: WebLinkInfo[];
-}
-
-export interface ProjectInfoWithName extends ProjectInfo {
-  name: RepoName;
-}
-
 export type NameToProjectInfoMap = {[projectName: string]: ProjectInfo};
-export type LabelNameToLabelTypeInfoMap = {[labelName: string]: LabelTypeInfo};
-
-/**
- * The LabelTypeInfo entity contains metadata about the labels that a project
- * has.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#label-type-info
- */
-export interface LabelTypeInfo {
-  values: LabelTypeInfoValues;
-  default_value: number;
-}
-
-export type LabelTypeInfoValues = {[value: string]: string};
 
 export type FilePathToDiffInfoMap = {[path: string]: DiffInfo};
 
@@ -1268,114 +762,6 @@
 }
 
 /**
- * A boolean value that can also be inherited.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#inherited-boolean-info
- */
-export interface InheritedBooleanInfo {
-  value: boolean;
-  configured_value: InheritedBooleanInfoConfiguredValue;
-  inherited_value?: boolean;
-}
-
-/**
- * The MaxObjectSizeLimitInfo entity contains information about the max object
- * size limit of a project.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#max-object-size-limit-info
- */
-export interface MaxObjectSizeLimitInfo {
-  value?: string;
-  configured_value?: string;
-  summary?: string;
-}
-
-/**
- * Information about the default submittype of a project, taking into account
- * project inheritance.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#submit-type-info
- */
-export interface SubmitTypeInfo {
-  value: Exclude<SubmitType, SubmitType.INHERIT>;
-  configured_value: SubmitType;
-  inherited_value: Exclude<SubmitType, SubmitType.INHERIT>;
-}
-
-/**
- * The CommentLinkInfo entity describes acommentlink.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#commentlink-info
- */
-export interface CommentLinkInfo {
-  match: string;
-  link?: string;
-  enabled?: boolean;
-  html?: string;
-}
-
-/**
- * The ConfigParameterInfo entity describes a project configurationparameter.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-parameter-info
- */
-export interface ConfigParameterInfoBase {
-  display_name?: string;
-  description?: string;
-  warning?: string;
-  type: ConfigParameterInfoType;
-  value?: string;
-  values?: string[];
-  editable?: boolean;
-  permitted_values?: string[];
-  inheritable?: boolean;
-  configured_value?: string;
-  inherited_value?: string;
-}
-
-export interface ConfigArrayParameterInfo extends ConfigParameterInfoBase {
-  type: ConfigParameterInfoType.ARRAY;
-  values: string[];
-}
-
-export interface ConfigListParameterInfo extends ConfigParameterInfoBase {
-  type: ConfigParameterInfoType.LIST;
-  permitted_values?: string[];
-}
-
-export type ConfigParameterInfo =
-  | ConfigParameterInfoBase
-  | ConfigArrayParameterInfo
-  | ConfigListParameterInfo;
-
-export interface CommentLinks {
-  [name: string]: CommentLinkInfo;
-}
-
-/**
- * The ConfigInfo entity contains information about the effective
- * project configuration.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-info
- */
-export interface ConfigInfo {
-  description?: string;
-  use_contributor_agreements?: InheritedBooleanInfo;
-  use_content_merge?: InheritedBooleanInfo;
-  use_signed_off_by?: InheritedBooleanInfo;
-  create_new_change_for_all_not_in_target?: InheritedBooleanInfo;
-  require_change_id?: InheritedBooleanInfo;
-  enable_signed_push?: InheritedBooleanInfo;
-  require_signed_push?: InheritedBooleanInfo;
-  reject_implicit_merges?: InheritedBooleanInfo;
-  private_by_default: InheritedBooleanInfo;
-  work_in_progress_by_default: InheritedBooleanInfo;
-  max_object_size_limit: MaxObjectSizeLimitInfo;
-  default_submit_type: SubmitTypeInfo;
-  submit_type: SubmitType;
-  match_author_to_committer_date?: InheritedBooleanInfo;
-  state?: ProjectState;
-  commentlinks: CommentLinks;
-  plugin_config?: PluginNameToPluginParametersMap;
-  actions?: {[viewName: string]: ActionInfo};
-  reject_empty_commit?: InheritedBooleanInfo;
-}
-
-/**
  * The ProjectAccessInfo entity contains information about the access rights for
  * a project.
  * https://gerrit-review.googlesource.com/Documentation/rest-api-access.html#project-access-info
@@ -1483,17 +869,6 @@
   plugin_config_values?: PluginNameToPluginParametersMap;
   commentlinks?: ConfigInfoCommentLinks;
 }
-/**
- * Plugin configuration values as map which maps the plugin name to a map of parameter names to values
- * https://gerrit-review.googlesource.com/Documentation/rest-api-projects.html#config-input
- */
-export type PluginNameToPluginParametersMap = {
-  [pluginName: string]: PluginParameterToConfigParameterInfoMap;
-};
-
-export type PluginParameterToConfigParameterInfoMap = {
-  [parameterName: string]: ConfigParameterInfo;
-};
 
 export type ConfigInfoCommentLinks = {
   [commentLinkName: string]: CommentLinkInfo;
@@ -1577,6 +952,7 @@
   notify_all_comments?: boolean;
   notify_submitted_changes?: boolean;
   notify_abandoned_changes?: boolean;
+  _is_local?: boolean; // Added manually
 }
 /**
  * The DeleteDraftCommentsInput entity contains information specifying a set of draft comments that should be deleted
@@ -1766,6 +1142,8 @@
   email_strategy: EmailStrategy;
   default_base_for_merges: DefaultBase;
   publish_comments_on_push?: boolean;
+  disable_keyboard_shortcuts?: boolean;
+  disable_token_highlighting?: boolean;
   work_in_progress_by_default?: boolean;
   // The email_format doesn't mentioned in doc, but exists in Java class GeneralPreferencesInfo
   email_format?: EmailFormat;
@@ -1829,7 +1207,8 @@
 /**
  * The AddReviewerResult entity describes the result of adding a reviewer to a
  * change.
- * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#add-reviewer-result
+ * https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#reviewer-result
+ * TODO(paiking): update this to ReviewerResult while considering removals.
  */
 export interface AddReviewerResult {
   input: AccountId | GroupId | EmailAddress;
diff --git a/polygerrit-ui/app/types/diff.ts b/polygerrit-ui/app/types/diff.ts
index c3b38c6..223f290 100644
--- a/polygerrit-ui/app/types/diff.ts
+++ b/polygerrit-ui/app/types/diff.ts
@@ -20,22 +20,31 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+import {WebLinkInfo} from '../api/rest-api';
+import {
+  ChangeType,
+  DiffContent as DiffContentApi,
+  DiffFileMetaInfo as DiffFileMetaInfoApi,
+  DiffInfo as DiffInfoApi,
+  DiffIntralineInfo,
+  DiffResponsiveMode,
+  DiffPreferencesInfo as DiffPreferenceInfoApi,
+  IgnoreWhitespaceType,
+  MarkLength,
+  MoveDetails,
+  SkipLength,
+} from '../api/diff';
 
 export {
   ChangeType,
+  DiffIntralineInfo,
+  DiffResponsiveMode,
+  IgnoreWhitespaceType,
+  MarkLength,
   MoveDetails,
   SkipLength,
-  MarkLength,
-  DiffIntralineInfo,
-  IgnoreWhitespaceType,
-} from '../api/diff';
-
-import {
-  DiffInfo as DiffInfoApi,
-  DiffFileMetaInfo as DiffFileMetaInfoApi,
-  DiffContent as DiffContentApi,
-  DiffPreferencesInfo as DiffPreferenceInfoApi,
-} from '../api/diff';
+  WebLinkInfo,
+};
 
 export interface DiffInfo extends DiffInfoApi {
   /** Meta information about the file on side A as a DiffFileMetaInfo entity. */
@@ -51,6 +60,12 @@
    * entries.
    */
   web_links?: DiffWebLinkInfo[];
+
+  /**
+   * Links to edit the file in external sites as a list of WebLinkInfo
+   * entries.
+   */
+  edit_web_links?: WebLinkInfo[];
 }
 
 /**
@@ -76,19 +91,6 @@
   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;
@@ -104,7 +106,6 @@
   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 ff08c57..b6376c4 100644
--- a/polygerrit-ui/app/types/events.ts
+++ b/polygerrit-ui/app/types/events.ts
@@ -14,26 +14,33 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
-import {PatchSetNum, UrlEncodedCommentId} from './common';
+import {PatchSetNum} from './common';
 import {UIComment} from '../utils/comment-util';
 import {FetchRequest} from './types';
-import {MovedLinkClickedEventDetail} from '../api/diff';
+import {LineNumberEventDetail, MovedLinkClickedEventDetail} from '../api/diff';
 import {Category, RunStatus} from '../api/checks';
 import {ChangeMessage} from '../elements/change/gr-message/gr-message';
 
 export enum EventType {
+  CHANGE = 'change',
+  CHANGED = 'changed',
   CHANGE_MESSAGE_DELETED = 'change-message-deleted',
+  COMMIT = 'commit',
   DIALOG_CHANGE = 'dialog-change',
+  DROP = 'drop',
   EDITABLE_CONTENT_SAVE = 'editable-content-save',
   GR_RPC_LOG = 'gr-rpc-log',
-  LOCATION_CHANGE = 'location-change',
   IRON_ANNOUNCE = 'iron-announce',
+  KEYDOWN = 'keydown',
+  KEYPRESS = 'keypress',
+  LOCATION_CHANGE = 'location-change',
   MOVED_LINK_CLICKED = 'moved-link-clicked',
   NETWORK_ERROR = 'network-error',
   OPEN_FIX_PREVIEW = 'open-fix-preview',
   CLOSE_FIX_PREVIEW = 'close-fix-preview',
   PAGE_ERROR = 'page-error',
+  RECREATE_CHANGE_VIEW = 'recreate-change-view',
+  RECREATE_DIFF_VIEW = 'recreate-diff-view',
   RELOAD = 'reload',
   REPLY = 'reply',
   SERVER_ERROR = 'server-error',
@@ -42,30 +49,42 @@
   SHOW_ERROR = 'show-error',
   SHOW_PRIMARY_TAB = 'show-primary-tab',
   SHOW_SECONDARY_TAB = 'show-secondary-tab',
-  THREAD_LIST_MODIFIED = 'thread-list-modified',
+  TAP_ITEM = 'tap-item',
   TITLE_CHANGE = 'title-change',
 }
 
 declare global {
   interface HTMLElementEventMap {
+    /* prettier-ignore */
+    'change': ChangeEvent;
+    /* prettier-ignore */
+    'changed': ChangedEvent;
     'change-message-deleted': ChangeMessageDeletedEvent;
+    /* prettier-ignore */
+    'commit': CommitEvent;
     'dialog-change': DialogChangeEvent;
+    /* prettier-ignore */
+    'drop': DropEvent;
     'editable-content-save': EditableContentSaveEvent;
     'location-change': LocationChangeEvent;
     'iron-announce': IronAnnounceEvent;
+    'line-mouse-enter': LineNumberEvent;
+    'line-mouse-leave': LineNumberEvent;
+    'line-cursor-moved-in': LineNumberEvent;
+    'line-cursor-moved-out': LineNumberEvent;
     'moved-link-clicked': MovedLinkClickedEvent;
     'open-fix-preview': OpenFixPreviewEvent;
     'close-fix-preview': CloseFixPreviewEvent;
+    'create-fix-comment': CreateFixCommentEvent;
     /* prettier-ignore */
     'reload': ReloadEvent;
     /* prettier-ignore */
     'reply': ReplyEvent;
-    'shortcut-triggered': ShortcutTriggeredEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
     'show-primary-tab': SwitchTabEvent;
     'show-secondary-tab': SwitchTabEvent;
-    'thread-list-modified': ThreadListModifiedEvent;
+    'tap-item': TapItemEvent;
     'title-change': TitleChangeEvent;
   }
 }
@@ -75,16 +94,25 @@
     'gr-rpc-log': RpcLogEvent;
     'network-error': NetworkErrorEvent;
     'page-error': PageErrorEvent;
+    /* prettier-ignore */
+    'reload': ReloadEvent;
     'server-error': ServerErrorEvent;
     'show-alert': ShowAlertEvent;
     'show-error': ShowErrorEvent;
   }
 }
 
+export type ChangeEvent = InputEvent;
+
+export type ChangedEvent = CustomEvent<string>;
+
 export interface ChangeMessageDeletedEventDetail {
   message: ChangeMessage;
 }
-export type ChangeMessageDeletedEvent = CustomEvent<ChangeMessageDeletedEventDetail>;
+export type ChangeMessageDeletedEvent =
+  CustomEvent<ChangeMessageDeletedEventDetail>;
+
+export type CommitEvent = CustomEvent;
 
 // 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
@@ -94,10 +122,13 @@
 }
 export type DialogChangeEvent = CustomEvent<DialogChangeEventDetail>;
 
+export type DropEvent = DragEvent;
+
 export interface EditableContentSaveEventDetail {
   content: string;
 }
-export type EditableContentSaveEvent = CustomEvent<EditableContentSaveEventDetail>;
+export type EditableContentSaveEvent =
+  CustomEvent<EditableContentSaveEventDetail>;
 
 export interface RpcLogEventDetail {
   status: number | null;
@@ -120,6 +151,8 @@
 
 export type MovedLinkClickedEvent = CustomEvent<MovedLinkClickedEventDetail>;
 
+export type LineNumberEvent = CustomEvent<LineNumberEventDetail>;
+
 export interface NetworkErrorEventDetail {
   error: Error;
 }
@@ -135,6 +168,11 @@
   fixApplied: boolean;
 }
 export type CloseFixPreviewEvent = CustomEvent<CloseFixPreviewEventDetail>;
+export interface CreateFixCommentEventDetail {
+  patchNum?: PatchSetNum;
+  comment?: UIComment;
+}
+export type CreateFixCommentEvent = CustomEvent<CreateFixCommentEventDetail>;
 
 export interface PageErrorEventDetail {
   response?: Response;
@@ -157,13 +195,6 @@
 }
 export type ServerErrorEvent = CustomEvent<ServerErrorEventDetail>;
 
-export interface ShortcutTriggeredEventDetail {
-  event: CustomKeyboardEvent;
-  goKey: boolean;
-  vKey: boolean;
-}
-export type ShortcutTriggeredEvent = CustomEvent<ShortcutTriggeredEventDetail>;
-
 export interface ShowAlertEventDetail {
   message: string;
   dismissOnNavigation?: boolean;
@@ -204,32 +235,9 @@
 }
 export type SwitchTabEvent = CustomEvent<SwitchTabEventDetail>;
 
-export interface ThreadListModifiedDetail {
-  rootId: UrlEncodedCommentId;
-  path: string;
-}
-export type ThreadListModifiedEvent = CustomEvent<ThreadListModifiedDetail>;
+export type TapItemEvent = CustomEvent;
 
 export interface TitleChangeEventDetail {
   title: string;
 }
 export type TitleChangeEvent = CustomEvent<TitleChangeEventDetail>;
-
-/**
- * Keyboard events emitted from polymer elements.
- */
-export interface CustomKeyboardEvent extends CustomEvent, EventApi {
-  event: CustomKeyboardEvent;
-  detail: {
-    keyboardEvent?: CustomKeyboardEvent;
-    // TODO(TS): maybe should mark as optional and check before accessing
-    key: string;
-  };
-  readonly altKey: boolean;
-  readonly changedTouches: TouchList;
-  readonly ctrlKey: boolean;
-  readonly metaKey: boolean;
-  readonly shiftKey: boolean;
-  readonly keyCode: number;
-  readonly repeat: boolean;
-}
diff --git a/polygerrit-ui/app/types/globals.ts b/polygerrit-ui/app/types/globals.ts
index 267ea12..b5bd2aa 100644
--- a/polygerrit-ui/app/types/globals.ts
+++ b/polygerrit-ui/app/types/globals.ts
@@ -29,11 +29,6 @@
       options: {callback: (text: string, href?: string) => void}
     ): void;
     ASSETS_PATH?: string;
-    // TODO(TS): define gerrit type
-    Gerrit?: {
-      Nav?: unknown;
-      Auth?: unknown;
-    };
     // TODO(TS): define polymer type
     Polymer: {
       IronFocusablesHelper: {
@@ -57,8 +52,6 @@
       dashboardQuery?: string[];
     };
 
-    VERSION_INFO?: string;
-
     /** Enhancements on Gr elements or utils */
     // TODO(TS): should clean up those and removing them may break certain plugin behaviors
     // TODO(TS): as @brohlfs suggested, to avoid importing anything from elements/ to types/
@@ -82,4 +75,8 @@
     lineNumber?: number; // non-standard property
     columnNumber?: number; // non-standard property
   }
+
+  interface ShadowRoot {
+    getSelection?: () => Selection | null;
+  }
 }
diff --git a/polygerrit-ui/app/types/types.ts b/polygerrit-ui/app/types/types.ts
index e3e1ada..2d4d412 100644
--- a/polygerrit-ui/app/types/types.ts
+++ b/polygerrit-ui/app/types/types.ts
@@ -123,8 +123,33 @@
    * The index of the element in the dom-repeat.
    */
   index: number;
-  get: (name: string) => T;
-  set: (name: string, val: T) => void;
+  get(name: 'item'): T;
+  // Typed get for item.prop_name
+  get<K extends keyof T>(name: `item.${K extends string ? K : never}`): T[K];
+  // Typed get for item.prop_name.nested_prop_name
+  get<K1 extends keyof T, K2 extends keyof T[K1]>(
+    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
+      ? K2
+      : never}`
+  ): T[K1][K2];
+  // Untyped get for other cases
+  get(name: string): unknown; // force get(...) as Type for nested properties
+
+  set(name: 'item', val: T): void;
+  // Typed set for item.prop_name
+  set<K extends keyof T>(
+    name: `item.${K extends string ? K : never}`,
+    val: T[K]
+  ): void;
+  // Typed get for item.prop_name.nested_prop_name
+  set<K1 extends keyof T, K2 extends keyof T[K1]>(
+    name: `item.${K1 extends string ? K1 : never}.${K2 extends string
+      ? K2
+      : never}`,
+    val: T[K1][K2]
+  ): void;
+  // Untyped set for other cases
+  set(name: string, val: unknown): void;
 }
 
 /** https://highlightjs.readthedocs.io/en/latest/api.html */
@@ -164,7 +189,6 @@
   showDownloadDialog: boolean;
   diffMode: DiffViewMode | null;
   numFilesShown: number | null;
-  scrollTop?: number;
   diffViewMode?: boolean;
 }
 
diff --git a/polygerrit-ui/app/utils/access-util.ts b/polygerrit-ui/app/utils/access-util.ts
index 44830e2..165eacf 100644
--- a/polygerrit-ui/app/utils/access-util.ts
+++ b/polygerrit-ui/app/utils/access-util.ts
@@ -14,7 +14,7 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import {LabelName} from '../types/common';
+import {GitRef, LabelName} from '../types/common';
 
 export enum AccessPermissionId {
   ABANDON = 'abandon',
@@ -156,7 +156,7 @@
 }
 
 export interface PermissionArrayItem<T> {
-  id: string;
+  id: GitRef;
   value: T;
 }
 
@@ -175,7 +175,7 @@
   return Object.keys(obj)
     .map(key => {
       return {
-        id: key,
+        id: key as GitRef,
         value: obj[key],
       };
     })
diff --git a/polygerrit-ui/app/utils/account-util.ts b/polygerrit-ui/app/utils/account-util.ts
index bb9f328..08a625c 100644
--- a/polygerrit-ui/app/utils/account-util.ts
+++ b/polygerrit-ui/app/utils/account-util.ts
@@ -15,8 +15,24 @@
  * limitations under the License.
  */
 
-import {AccountId, AccountInfo, EmailAddress} from '../types/common';
-import {AccountTag} from '../constants/constants';
+import {
+  AccountId,
+  AccountInfo,
+  ChangeInfo,
+  EmailAddress,
+  GroupId,
+  GroupInfo,
+  isAccount,
+  isGroup,
+  ReviewerInput,
+  ServerInfo,
+} from '../types/common';
+import {AccountTag, ReviewerState} from '../constants/constants';
+import {assertNever} from './common-util';
+import {AccountAddition} from '../elements/shared/gr-account-list/gr-account-list';
+import {getDisplayName} from './display-name-util';
+
+export const ACCOUNT_TEMPLATE_REGEX = '<GERRIT_ACCOUNT_(\\d+)>';
 
 export function accountKey(account: AccountInfo): AccountId | EmailAddress {
   if (account._account_id) return account._account_id;
@@ -24,6 +40,30 @@
   throw new Error('Account has neither _account_id nor email.');
 }
 
+export function mapReviewer(addition: AccountAddition): ReviewerInput {
+  if (addition.account) {
+    return {reviewer: accountKey(addition.account)};
+  }
+  if (addition.group) {
+    const reviewer = decodeURIComponent(addition.group.id) as GroupId;
+    const confirmed = addition.group.confirmed;
+    return {reviewer, confirmed};
+  }
+  throw new Error('Reviewer must be either an account or a group.');
+}
+
+export function isReviewerOrCC(
+  change: ChangeInfo,
+  reviewerAddition: AccountAddition
+): boolean {
+  const reviewers = [
+    ...(change.reviewers[ReviewerState.CC] ?? []),
+    ...(change.reviewers[ReviewerState.REVIEWER] ?? []),
+  ];
+  const reviewer = mapReviewer(reviewerAddition);
+  return reviewers.some(r => accountOrGroupKey(r) === reviewer.reviewer);
+}
+
 export function isServiceUser(account?: AccountInfo): boolean {
   return !!account?.tags?.includes(AccountTag.SERVICE_USER);
 }
@@ -40,6 +80,12 @@
   return account?.avatars?.[0]?.url === other?.avatars?.[0]?.url;
 }
 
+export function accountOrGroupKey(entry: AccountInfo | GroupInfo) {
+  if (isAccount(entry)) return accountKey(entry);
+  if (isGroup(entry)) return entry.id;
+  assertNever(entry, 'entry must be account or group');
+}
+
 export function uniqueDefinedAvatar(
   account: AccountInfo,
   index: number,
@@ -49,3 +95,37 @@
     index === accountArray.findIndex(other => hasSameAvatar(account, other))
   );
 }
+
+/**
+ * @desc Get account in pseudonymized form, that can be send to the backend.
+ *
+ * If account is not present, returns anonymous user name according to config.
+ */
+export function getAccountTemplate(account?: AccountInfo, config?: ServerInfo) {
+  return account?._account_id
+    ? `<GERRIT_ACCOUNT_${account._account_id}>`
+    : getDisplayName(config);
+}
+
+/**
+ * @desc Replace account templates with user display names in text, received from the backend.
+ */
+export function replaceTemplates(
+  text: string,
+  accountsInText?: AccountInfo[],
+  config?: ServerInfo
+) {
+  return text.replace(
+    new RegExp(ACCOUNT_TEMPLATE_REGEX, 'g'),
+    (_accountIdTemplate, accountId) => {
+      const parsedAccountId = Number(accountId) as AccountId;
+      const accountInText = (accountsInText || []).find(
+        account => account._account_id === parsedAccountId
+      );
+      if (!accountInText) {
+        return `Gerrit Account ${parsedAccountId}`;
+      }
+      return getDisplayName(config, accountInText);
+    }
+  );
+}
diff --git a/polygerrit-ui/app/utils/account-util_test.js b/polygerrit-ui/app/utils/account-util_test.js
deleted file mode 100644
index 0628f2d..0000000
--- a/polygerrit-ui/app/utils/account-util_test.js
+++ /dev/null
@@ -1,46 +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 {isServiceUser, removeServiceUsers} from './account-util.js';
-import {AccountTag} from '../constants/constants.js';
-
-const EMPTY = {};
-const ERNIE = {name: 'Ernie'};
-const KERMIT = {name: 'Kermit', tags: ['FROG']};
-const SERVY = {name: 'Servy', tags: [AccountTag.SERVICE_USER]};
-const BOTTY = {name: 'Botty', tags: [AccountTag.SERVICE_USER]};
-
-suite('account-util tests 3', () => {
-  test('isServiceUser', () => {
-    assert.isFalse(isServiceUser());
-    assert.isFalse(isServiceUser(EMPTY));
-    assert.isFalse(isServiceUser(ERNIE));
-    assert.isFalse(isServiceUser(KERMIT));
-    assert.isTrue(isServiceUser(SERVY));
-    assert.isTrue(isServiceUser(BOTTY));
-  });
-
-  test('removeServiceUsers', () => {
-    assert.sameMembers(removeServiceUsers([]), []);
-    assert.sameMembers(removeServiceUsers([EMPTY, ERNIE, KERMIT]),
-        [EMPTY, ERNIE, KERMIT]);
-    assert.sameMembers(removeServiceUsers([SERVY, BOTTY]), []);
-    assert.sameMembers(removeServiceUsers([EMPTY, SERVY, ERNIE, BOTTY, KERMIT]),
-        [EMPTY, ERNIE, KERMIT]);
-  });
-});
diff --git a/polygerrit-ui/app/utils/account-util_test.ts b/polygerrit-ui/app/utils/account-util_test.ts
new file mode 100644
index 0000000..8ec9181
--- /dev/null
+++ b/polygerrit-ui/app/utils/account-util_test.ts
@@ -0,0 +1,149 @@
+/**
+ * @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 {
+  getAccountTemplate,
+  isServiceUser,
+  removeServiceUsers,
+  replaceTemplates,
+} from './account-util';
+import {
+  AccountsVisibility,
+  AccountTag,
+  DefaultDisplayNameConfig,
+} from '../constants/constants';
+import {AccountId, AccountInfo, ServerInfo} from '../api/rest-api';
+import {createServerInfo} from '../test/test-data-generators';
+
+const EMPTY = {};
+const ERNIE = {name: 'Ernie'};
+const SERVY = {name: 'Servy', tags: [AccountTag.SERVICE_USER]};
+const BOTTY = {name: 'Botty', tags: [AccountTag.SERVICE_USER]};
+
+const config: ServerInfo = {
+  ...createServerInfo(),
+  user: {
+    anonymous_coward_name: 'Unidentified User',
+  },
+  accounts: {
+    visibility: AccountsVisibility.ALL,
+    default_display_name: DefaultDisplayNameConfig.USERNAME,
+  },
+};
+const accounts: AccountInfo[] = [
+  {
+    _account_id: 1 as AccountId,
+    name: 'Test User #1',
+    username: 'test-username-1',
+  },
+  {
+    _account_id: 2 as AccountId,
+    name: 'Test User #2',
+  },
+];
+
+suite('account-util tests 3', () => {
+  test('isServiceUser', () => {
+    assert.isFalse(isServiceUser());
+    assert.isFalse(isServiceUser(EMPTY));
+    assert.isFalse(isServiceUser(ERNIE));
+    assert.isTrue(isServiceUser(SERVY));
+    assert.isTrue(isServiceUser(BOTTY));
+  });
+
+  test('removeServiceUsers', () => {
+    assert.sameMembers(removeServiceUsers([]), []);
+    assert.sameMembers(removeServiceUsers([EMPTY, ERNIE]), [EMPTY, ERNIE]);
+    assert.sameMembers(removeServiceUsers([SERVY, BOTTY]), []);
+    assert.sameMembers(removeServiceUsers([EMPTY, SERVY, ERNIE, BOTTY]), [
+      EMPTY,
+      ERNIE,
+    ]);
+  });
+
+  test('replaceTemplates with display config', () => {
+    assert.equal(
+      replaceTemplates(
+        'Text with action by <GERRIT_ACCOUNT_0000001>',
+        accounts,
+        config
+      ),
+      'Text with action by test-username-1'
+    );
+    assert.equal(
+      replaceTemplates(
+        'Text with action by <GERRIT_ACCOUNT_0000002>',
+        accounts,
+        config
+      ),
+      'Text with action by Test User #2'
+    );
+    assert.equal(
+      replaceTemplates(
+        'Text with action by <GERRIT_ACCOUNT_3>',
+        accounts,
+        config
+      ),
+      'Text with action by Gerrit Account 3'
+    );
+    assert.equal(
+      replaceTemplates(
+        'Text with multiple accounts: <GERRIT_ACCOUNT_0000003>, <GERRIT_ACCOUNT_0000002>, <GERRIT_ACCOUNT_0000001>',
+        accounts,
+        config
+      ),
+      'Text with multiple accounts: Gerrit Account 3, Test User #2, test-username-1'
+    );
+  });
+
+  test('replaceTemplates no display config', () => {
+    assert.equal(
+      replaceTemplates(
+        'Text with action by <GERRIT_ACCOUNT_0000001>',
+        accounts
+      ),
+      'Text with action by Test User #1'
+    );
+    assert.equal(
+      replaceTemplates(
+        'Text with action by <GERRIT_ACCOUNT_0000002>',
+        accounts
+      ),
+      'Text with action by Test User #2'
+    );
+
+    assert.equal(
+      replaceTemplates('Text with action by <GERRIT_ACCOUNT_3>', accounts),
+      'Text with action by Gerrit Account 3'
+    );
+
+    assert.equal(
+      replaceTemplates(
+        'Text with multiple accounts: <GERRIT_ACCOUNT_0000003>, <GERRIT_ACCOUNT_0000002>, <GERRIT_ACCOUNT_0000001>',
+        accounts
+      ),
+      'Text with multiple accounts: Gerrit Account 3, Test User #2, Test User #1'
+    );
+  });
+
+  test('getTemplate', () => {
+    assert.equal(getAccountTemplate(accounts[0], config), '<GERRIT_ACCOUNT_1>');
+    assert.equal(getAccountTemplate({}, config), 'Unidentified User');
+    assert.equal(getAccountTemplate(), 'Anonymous');
+  });
+});
diff --git a/polygerrit-ui/app/utils/admin-nav-util.ts b/polygerrit-ui/app/utils/admin-nav-util.ts
index 275f9e6..8bd22ef 100644
--- a/polygerrit-ui/app/utils/admin-nav-util.ts
+++ b/polygerrit-ui/app/utils/admin-nav-util.ts
@@ -190,9 +190,14 @@
   return {
     name: repoName,
     view: GerritNav.View.REPO,
-    url: GerritNav.getUrlForRepo(repoName),
     children: [
       {
+        name: 'General',
+        view: GerritNav.View.REPO,
+        detailType: GerritNav.RepoDetailView.GENERAL,
+        url: GerritNav.getUrlForRepo(repoName),
+      },
+      {
         name: 'Access',
         view: GerritNav.View.REPO,
         detailType: GerritNav.RepoDetailView.ACCESS,
@@ -230,7 +235,7 @@
   name: string;
   view: GerritView;
   detailType?: RepoDetailView | GroupDetailView;
-  url: string;
+  url?: string;
   children?: SubsectionInterface[];
 }
 
diff --git a/polygerrit-ui/app/utils/admin-nav-util_test.js b/polygerrit-ui/app/utils/admin-nav-util_test.js
index a3dc87a..2a13904 100644
--- a/polygerrit-ui/app/utils/admin-nav-util_test.js
+++ b/polygerrit-ui/app/utils/admin-nav-util_test.js
@@ -27,71 +27,69 @@
     menuLinkStub = sinon.stub().returns([]);
   });
 
-  const testAdminLinks = (account, options, expected, done) => {
-    getAdminLinks(account,
+  const testAdminLinks = async (account, options, expected) => {
+    const res = await getAdminLinks(account,
         capabilityStub,
         menuLinkStub,
-        options)
-        .then(res => {
-          assert.equal(expected.totalLength, res.links.length);
-          assert.equal(res.links[0].name, 'Repositories');
-          // Repos
-          if (expected.groupListShown) {
-            assert.equal(res.links[1].name, 'Groups');
-          }
+        options);
 
-          if (expected.pluginListShown) {
-            assert.equal(res.links[2].name, 'Plugins');
-            assert.isNotOk(res.links[2].subsection);
-          }
+    assert.equal(expected.totalLength, res.links.length);
+    assert.equal(res.links[0].name, 'Repositories');
+    // Repos
+    if (expected.groupListShown) {
+      assert.equal(res.links[1].name, 'Groups');
+    }
 
-          if (expected.projectPageShown) {
-            assert.isOk(res.links[0].subsection);
-            assert.equal(res.links[0].subsection.children.length, 5);
-          } else {
-            assert.isNotOk(res.links[0].subsection);
-          }
-          // Groups
-          if (expected.groupPageShown) {
-            assert.isOk(res.links[1].subsection);
-            assert.equal(res.links[1].subsection.children.length,
-                expected.groupSubpageLength);
-          } else if ( expected.totalLength > 1) {
-            assert.isNotOk(res.links[1].subsection);
-          }
+    if (expected.pluginListShown) {
+      assert.equal(res.links[2].name, 'Plugins');
+      assert.isNotOk(res.links[2].subsection);
+    }
 
-          if (expected.pluginGeneratedLinks) {
-            for (const link of expected.pluginGeneratedLinks) {
-              const linkMatch = res.links
-                  .find(l => (l.url === link.url && l.name === link.text));
-              assert.isTrue(!!linkMatch);
+    if (expected.projectPageShown) {
+      assert.isOk(res.links[0].subsection);
+      assert.equal(res.links[0].subsection.children.length, 6);
+    } else {
+      assert.isNotOk(res.links[0].subsection);
+    }
+    // Groups
+    if (expected.groupPageShown) {
+      assert.isOk(res.links[1].subsection);
+      assert.equal(res.links[1].subsection.children.length,
+          expected.groupSubpageLength);
+    } else if ( expected.totalLength > 1) {
+      assert.isNotOk(res.links[1].subsection);
+    }
 
-              // External links should open in new tab.
-              if (link.url[0] !== '/') {
-                assert.equal(linkMatch.target, '_blank');
-              } else {
-                assert.isNotOk(linkMatch.target);
-              }
-            }
-          }
+    if (expected.pluginGeneratedLinks) {
+      for (const link of expected.pluginGeneratedLinks) {
+        const linkMatch = res.links
+            .find(l => (l.url === link.url && l.name === link.text));
+        assert.isTrue(!!linkMatch);
 
-          // Current section
-          if (expected.projectPageShown || expected.groupPageShown) {
-            assert.isOk(res.expandedSection);
-            assert.isOk(res.expandedSection.children);
-          } else {
-            assert.isNotOk(res.expandedSection);
-          }
-          if (expected.projectPageShown) {
-            assert.equal(res.expandedSection.name, 'my-repo');
-            assert.equal(res.expandedSection.children.length, 5);
-          } else if (expected.groupPageShown) {
-            assert.equal(res.expandedSection.name, 'my-group');
-            assert.equal(res.expandedSection.children.length,
-                expected.groupSubpageLength);
-          }
-          done();
-        });
+        // External links should open in new tab.
+        if (link.url[0] !== '/') {
+          assert.equal(linkMatch.target, '_blank');
+        } else {
+          assert.isNotOk(linkMatch.target);
+        }
+      }
+    }
+
+    // Current section
+    if (expected.projectPageShown || expected.groupPageShown) {
+      assert.isOk(res.expandedSection);
+      assert.isOk(res.expandedSection.children);
+    } else {
+      assert.isNotOk(res.expandedSection);
+    }
+    if (expected.projectPageShown) {
+      assert.equal(res.expandedSection.name, 'my-repo');
+      assert.equal(res.expandedSection.children.length, 6);
+    } else if (expected.groupPageShown) {
+      assert.equal(res.expandedSection.name, 'my-group');
+      assert.equal(res.expandedSection.children.length,
+          expected.groupSubpageLength);
+    }
   };
 
   suite('logged out', () => {
@@ -106,25 +104,25 @@
       };
     });
 
-    test('without a specific repo or group', done => {
+    test('without a specific repo or group', async () => {
       let options;
       expected = Object.assign(expected, {
         totalLength: 1,
         projectPageShown: false,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('with a repo', done => {
+    test('with a repo', async () => {
       const options = {repoName: 'my-repo'};
       expected = Object.assign(expected, {
         totalLength: 1,
         projectPageShown: true,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('with plugin generated links', done => {
+    test('with plugin generated links', async () => {
       let options;
       const generatedLinks = [
         {text: 'internal link text', url: '/internal/link/url'},
@@ -136,7 +134,7 @@
         projectPageShown: false,
         pluginGeneratedLinks: generatedLinks,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
   });
 
@@ -154,17 +152,17 @@
       capabilityStub.returns(Promise.resolve({}));
     });
 
-    test('without a specific project or group', done => {
+    test('without a specific project or group', async () => {
       let options;
       expected = Object.assign(expected, {
         projectPageShown: false,
         groupListShown: true,
         groupPageShown: false,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('with a repo', done => {
+    test('with a repo', async () => {
       const account = {
         name: 'test-user',
       };
@@ -174,7 +172,7 @@
         groupListShown: true,
         groupPageShown: false,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
   });
 
@@ -193,25 +191,25 @@
       };
     });
 
-    test('without a specific repo or group', done => {
+    test('without a specific repo or group', async () => {
       let options;
       expected = Object.assign(expected, {
         projectPageShown: false,
         groupPageShown: false,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('with a repo', done => {
+    test('with a repo', async () => {
       const options = {repoName: 'my-repo'};
       expected = Object.assign(expected, {
         projectPageShown: true,
         groupPageShown: false,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('admin with internal group', done => {
+    test('admin with internal group', async () => {
       const options = {
         groupId: 'a15262',
         groupName: 'my-group',
@@ -224,10 +222,10 @@
         groupPageShown: true,
         groupSubpageLength: 2,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('group owner with internal group', done => {
+    test('group owner with internal group', async () => {
       const options = {
         groupId: 'a15262',
         groupName: 'my-group',
@@ -240,10 +238,10 @@
         groupPageShown: true,
         groupSubpageLength: 2,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('non owner or admin with internal group', done => {
+    test('non owner or admin with internal group', async () => {
       const options = {
         groupId: 'a15262',
         groupName: 'my-group',
@@ -256,10 +254,10 @@
         groupPageShown: true,
         groupSubpageLength: 1,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
 
-    test('admin with external group', done => {
+    test('admin with external group', async () => {
       const options = {
         groupId: 'a15262',
         groupName: 'my-group',
@@ -272,7 +270,7 @@
         groupPageShown: true,
         groupSubpageLength: 0,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
   });
 
@@ -287,7 +285,7 @@
       expected = {};
     });
 
-    test('with plugin with capabilities', done => {
+    test('with plugin with capabilities', async () => {
       let options;
       const generatedLinks = [
         {text: 'without capability', url: '/without'},
@@ -300,7 +298,7 @@
         totalLength: 4,
         pluginGeneratedLinks: generatedLinks,
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
   });
 
@@ -315,7 +313,7 @@
       expected = {};
     });
 
-    test('with plugin with capabilities', done => {
+    test('with plugin with capabilities', async () => {
       let options;
       const generatedLinks = [
         {text: 'without capability', url: '/without'},
@@ -328,7 +326,7 @@
         totalLength: 3,
         pluginGeneratedLinks: [generatedLinks[0]],
       });
-      testAdminLinks(account, options, expected, done);
+      await testAdminLinks(account, options, expected);
     });
   });
 });
diff --git a/polygerrit-ui/app/utils/async-util.ts b/polygerrit-ui/app/utils/async-util.ts
index 2b36fee..90ee5a5 100644
--- a/polygerrit-ui/app/utils/async-util.ts
+++ b/polygerrit-ui/app/utils/async-util.ts
@@ -110,3 +110,23 @@
   existingTask?.cancel();
   return new DelayedTask(callback, waitMs);
 }
+
+const THROTTLE_INTERVAL_MS = 500;
+
+/**
+ * Ensure only one call is made within THROTTLE_INTERVAL_MS and any call within
+ * this interval is ignored
+ */
+export function throttleWrap<T>(fn: (e: T) => void) {
+  let lastCall: number | undefined;
+  return (e: T) => {
+    if (
+      lastCall !== undefined &&
+      Date.now() - lastCall < THROTTLE_INTERVAL_MS
+    ) {
+      return;
+    }
+    lastCall = Date.now();
+    fn(e);
+  };
+}
diff --git a/polygerrit-ui/app/utils/async-util_test.js b/polygerrit-ui/app/utils/async-util_test.js
deleted file mode 100644
index df29e97..0000000
--- a/polygerrit-ui/app/utils/async-util_test.js
+++ /dev/null
@@ -1,46 +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 {asyncForeach} from './async-util.js';
-
-suite('async-util tests', () => {
-  test('loops over each item', () => {
-    const fn = sinon.stub().returns(Promise.resolve());
-    return asyncForeach([1, 2, 3], fn)
-        .then(() => {
-          assert.isTrue(fn.calledThrice);
-          assert.equal(fn.getCall(0).args[0], 1);
-          assert.equal(fn.getCall(1).args[0], 2);
-          assert.equal(fn.getCall(2).args[0], 3);
-        });
-  });
-
-  test('halts on stop condition', () => {
-    const stub = sinon.stub();
-    const fn = (e, stop) => {
-      stub(e);
-      stop();
-      return Promise.resolve();
-    };
-    return asyncForeach([1, 2, 3], fn)
-        .then(() => {
-          assert.isTrue(stub.calledOnce);
-          assert.equal(stub.lastCall.args[0], 1);
-        });
-  });
-});
diff --git a/polygerrit-ui/app/utils/async-util_test.ts b/polygerrit-ui/app/utils/async-util_test.ts
new file mode 100644
index 0000000..5c8f610
--- /dev/null
+++ b/polygerrit-ui/app/utils/async-util_test.ts
@@ -0,0 +1,46 @@
+/**
+ * @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';
+import {asyncForeach} from './async-util';
+
+suite('async-util tests', () => {
+  test('loops over each item', async () => {
+    const fn = sinon.stub().resolves();
+
+    await asyncForeach([1, 2, 3], fn);
+
+    assert.isTrue(fn.calledThrice);
+    assert.equal(fn.firstCall.firstArg, 1);
+    assert.equal(fn.secondCall.firstArg, 2);
+    assert.equal(fn.thirdCall.firstArg, 3);
+  });
+
+  test('halts on stop condition', async () => {
+    const stub = sinon.stub();
+    const fn = (item: number, stopCallback: () => void) => {
+      stub(item);
+      stopCallback();
+      return Promise.resolve();
+    };
+
+    await asyncForeach([1, 2, 3], fn);
+
+    assert.isTrue(stub.calledOnce);
+    assert.equal(stub.lastCall.firstArg, 1);
+  });
+});
diff --git a/polygerrit-ui/app/utils/attention-set-util.ts b/polygerrit-ui/app/utils/attention-set-util.ts
index 5b2762c..b0b7ef8 100644
--- a/polygerrit-ui/app/utils/attention-set-util.ts
+++ b/polygerrit-ui/app/utils/attention-set-util.ts
@@ -15,51 +15,105 @@
  * limitations under the License.
  */
 
-import {AccountInfo, ChangeInfo} from '../types/common';
-import {isServiceUser} from './account-util';
+import {AccountInfo, ChangeInfo, ServerInfo} from '../types/common';
+import {ParsedChangeInfo} from '../types/types';
+import {
+  getAccountTemplate,
+  isSelf,
+  isServiceUser,
+  replaceTemplates,
+} 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.
-interface SimpleServerInfo {
-  change?: {
-    enable_attention_set?: boolean;
-  };
-}
-
-const CONFIG_ENABLED: SimpleServerInfo = {
-  change: {enable_attention_set: true},
-};
-
-export function isAttentionSetEnabled(config?: SimpleServerInfo): boolean {
-  return !!config?.change?.enable_attention_set;
-}
-
 export function canHaveAttention(account?: AccountInfo): boolean {
   return !!account?._account_id && !isServiceUser(account);
 }
 
 export function hasAttention(
-  config?: SimpleServerInfo,
   account?: AccountInfo,
-  change?: ChangeInfo
+  change?: ChangeInfo | ParsedChangeInfo
 ): boolean {
   return (
-    isAttentionSetEnabled(config) &&
     canHaveAttention(account) &&
     !!change?.attention_set &&
     hasOwnProperty(change?.attention_set, account!._account_id!)
   );
 }
 
-export function getReason(account?: AccountInfo, change?: ChangeInfo) {
-  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
-  const entry = change!.attention_set![account!._account_id!];
-  return entry?.reason ? entry.reason : '';
+export function getReason(
+  config?: ServerInfo,
+  account?: AccountInfo,
+  change?: ChangeInfo | ParsedChangeInfo
+) {
+  if (!hasAttention(account, change)) return '';
+  if (change?.attention_set === undefined) return '';
+  if (account?._account_id === undefined) return '';
+
+  const attentionSetInfo = change.attention_set[account._account_id!];
+
+  if (attentionSetInfo?.reason === undefined) return '';
+
+  return replaceTemplates(
+    attentionSetInfo.reason,
+    attentionSetInfo?.reason_account ? [attentionSetInfo.reason_account] : [],
+    config
+  );
+}
+
+export function getAddedByReason(account?: AccountInfo, config?: ServerInfo) {
+  return `Added by ${getAccountTemplate(
+    account,
+    config
+  )} using the hovercard menu`;
+}
+
+export function getRemovedByReason(account?: AccountInfo, config?: ServerInfo) {
+  return `Removed by ${getAccountTemplate(
+    account,
+    config
+  )} using the hovercard menu`;
+}
+
+export function getReplyByReason(account?: AccountInfo, config?: ServerInfo) {
+  return `${getAccountTemplate(account, config)} replied on the change`;
+}
+
+export function getRemovedByIconClickReason(
+  account?: AccountInfo,
+  config?: ServerInfo
+) {
+  return `Removed by ${getAccountTemplate(
+    account,
+    config
+  )} by clicking the attention icon`;
 }
 
 export function getLastUpdate(account?: AccountInfo, change?: ChangeInfo) {
-  if (!hasAttention(CONFIG_ENABLED, account, change)) return '';
+  if (!hasAttention(account, change)) return '';
   const entry = change!.attention_set![account!._account_id!];
   return entry?.last_update ? entry.last_update : '';
 }
+
+/**
+ *  Sort order:
+ * 1. The user themselves
+ * 2. Human users in the attention set.
+ * 3. Other human users.
+ * 4. Service users.
+ */
+export function sortReviewers(
+  r1: AccountInfo,
+  r2: AccountInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
+  selfAccount?: AccountInfo
+) {
+  if (selfAccount) {
+    if (isSelf(r1, selfAccount)) return -1;
+    if (isSelf(r2, selfAccount)) return 1;
+  }
+  const a1 = hasAttention(r1, change) ? 1 : 0;
+  const a2 = hasAttention(r2, change) ? 1 : 0;
+  const s1 = isServiceUser(r1) ? -2 : 0;
+  const s2 = isServiceUser(r2) ? -2 : 0;
+  return a2 - a1 + s2 - s1;
+}
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.js b/polygerrit-ui/app/utils/attention-set-util_test.js
deleted file mode 100644
index 71735d5..0000000
--- a/polygerrit-ui/app/utils/attention-set-util_test.js
+++ /dev/null
@@ -1,57 +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 {
-  hasAttention, getReason,
-} from './attention-set-util.js';
-
-const KERMIT = {
-  email: 'kermit@gmail.com',
-  username: 'kermit',
-  name: 'Kermit The Frog',
-  _account_id: '31415926535',
-};
-
-suite('attention-set-util', () => {
-  test('hasAttention', () => {
-    const config = {
-      change: {enable_attention_set: true},
-    };
-    const change = {
-      attention_set: {
-        31415926535: {
-          reason: 'a good reason',
-        },
-      },
-    };
-
-    assert.isTrue(hasAttention(config, KERMIT, change));
-  });
-
-  test('getReason', () => {
-    const change = {
-      attention_set: {
-        31415926535: {
-          reason: 'a good reason',
-        },
-      },
-    };
-
-    assert.equal(getReason(KERMIT, change), 'a good reason');
-  });
-});
diff --git a/polygerrit-ui/app/utils/attention-set-util_test.ts b/polygerrit-ui/app/utils/attention-set-util_test.ts
new file mode 100644
index 0000000..14832c0
--- /dev/null
+++ b/polygerrit-ui/app/utils/attention-set-util_test.ts
@@ -0,0 +1,80 @@
+/**
+ * @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 {createChange, createServerInfo} from '../test/test-data-generators';
+import {
+  AccountId,
+  AccountInfo,
+  ChangeInfo,
+  EmailAddress,
+  ServerInfo,
+} from '../types/common';
+import {getReason, hasAttention} from './attention-set-util';
+import {DefaultDisplayNameConfig} from '../api/rest-api';
+import {AccountsVisibility} from '../constants/constants';
+
+const KERMIT: AccountInfo = {
+  email: 'kermit@gmail.com' as EmailAddress,
+  username: 'kermit',
+  name: 'Kermit The Frog',
+  _account_id: 31415926535 as AccountId,
+};
+
+const OTHER_ACCOUNT: AccountInfo = {
+  email: 'other@gmail.com' as EmailAddress,
+  username: 'other',
+  name: 'Other User',
+  _account_id: 31415926536 as AccountId,
+};
+
+const change: ChangeInfo = {
+  ...createChange(),
+  attention_set: {
+    '31415926535': {
+      account: KERMIT,
+      reason: 'a good reason',
+    },
+    '31415926536': {
+      account: OTHER_ACCOUNT,
+      reason: 'Added by <GERRIT_ACCOUNT_31415926535>',
+      reason_account: KERMIT,
+    },
+  },
+};
+
+const config: ServerInfo = {
+  ...createServerInfo(),
+  user: {
+    anonymous_coward_name: 'Unidentified User',
+  },
+  accounts: {
+    visibility: AccountsVisibility.ALL,
+    default_display_name: DefaultDisplayNameConfig.USERNAME,
+  },
+};
+
+suite('attention-set-util', () => {
+  test('hasAttention', () => {
+    assert.isTrue(hasAttention(KERMIT, change));
+  });
+
+  test('getReason', () => {
+    assert.equal(getReason(config, KERMIT, change), 'a good reason');
+    assert.equal(getReason(config, OTHER_ACCOUNT, change), 'Added by kermit');
+  });
+});
diff --git a/polygerrit-ui/app/utils/change-metadata-util.ts b/polygerrit-ui/app/utils/change-metadata-util.ts
index eda988a..9692ab31 100644
--- a/polygerrit-ui/app/utils/change-metadata-util.ts
+++ b/polygerrit-ui/app/utils/change-metadata-util.ts
@@ -24,6 +24,7 @@
   SUBMITTED = 'Submitted',
   PARENT = 'Parent',
   MERGED_AS = 'Merged as',
+  REVERT_CREATED_AS = 'Revert Created as',
   STRATEGY = 'Strategy',
   UPDATED = 'Updated',
   CC = 'CC',
@@ -56,6 +57,7 @@
   ALWAYS_HIDE: [
     Metadata.PARENT,
     Metadata.MERGED_AS,
+    Metadata.REVERT_CREATED_AS,
     Metadata.STRATEGY,
     Metadata.UPDATED,
   ],
diff --git a/polygerrit-ui/app/utils/change-util.ts b/polygerrit-ui/app/utils/change-util.ts
index 3426dec..278e7f3 100644
--- a/polygerrit-ui/app/utils/change-util.ts
+++ b/polygerrit-ui/app/utils/change-util.ts
@@ -24,6 +24,7 @@
   RelatedChangeAndCommitInfo,
 } from '../types/common';
 import {ParsedChangeInfo} from '../types/types';
+import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
 
 // This can be wrong! See WARNING above
 interface ChangeStatusesOptions {
@@ -104,6 +105,9 @@
    * deletions field (number of lines deleted)
    */
   SKIP_DIFFSTAT: 23,
+
+  /** Include the evaluated submit requirements for the caller. */
+  SUBMIT_REQUIREMENTS: 24,
 };
 
 export function listChangesOptionsToHex(...args: number[]) {
@@ -147,24 +151,24 @@
 export function changeStatuses(
   change: ChangeInfo,
   opt_options?: ChangeStatusesOptions
-) {
+): ChangeStates[] {
   const states = [];
   if (change.status === ChangeStatus.MERGED) {
-    states.push('Merged');
+    states.push(ChangeStates.MERGED);
   } else if (change.status === ChangeStatus.ABANDONED) {
-    states.push('Abandoned');
+    states.push(ChangeStates.ABANDONED);
   } else if (
     change.mergeable === false ||
     (opt_options && opt_options.mergeable === false)
   ) {
     // 'mergeable' prop may not always exist (@see Issue 6819)
-    states.push('Merge Conflict');
+    states.push(ChangeStates.MERGE_CONFLICT);
   }
   if (change.work_in_progress) {
-    states.push('WIP');
+    states.push(ChangeStates.WIP);
   }
   if (change.is_private) {
-    states.push('Private');
+    states.push(ChangeStates.PRIVATE);
   }
 
   // If there are any pre-defined statuses, only return those. Otherwise,
@@ -175,36 +179,43 @@
 
   // If no missing requirements, either active or ready to submit.
   if (change.submittable && opt_options.submitEnabled) {
-    states.push('Ready to submit');
+    states.push(ChangeStates.READY_TO_SUBMIT);
   } else {
     // Otherwise it is active.
-    states.push('Active');
+    states.push(ChangeStates.ACTIVE);
   }
   return states;
 }
 
-export function isOwner(change?: ChangeInfo, account?: AccountInfo): boolean {
+export function isOwner(
+  change?: ChangeInfo | ParsedChangeInfo,
+  account?: AccountInfo
+): boolean {
   if (!change || !account) return false;
   return change.owner?._account_id === account._account_id;
 }
 
 export function isReviewer(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   if (!change || !account) return false;
+  if (isOwner(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 {
+export function isCc(
+  change?: ChangeInfo | ParsedChangeInfo,
+  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,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   if (!change || !account) return false;
@@ -213,7 +224,7 @@
 }
 
 export function isInvolved(
-  change?: ChangeInfo,
+  change?: ChangeInfo | ParsedChangeInfo,
   account?: AccountInfo
 ): boolean {
   const owner = isOwner(change, account);
diff --git a/polygerrit-ui/app/utils/change-util_test.js b/polygerrit-ui/app/utils/change-util_test.js
deleted file mode 100644
index d62a5ff..0000000
--- a/polygerrit-ui/app/utils/change-util_test.js
+++ /dev/null
@@ -1,259 +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 {
-  changeBaseURL,
-  changeIsOpen,
-  changeIsMerged,
-  changeIsAbandoned,
-  changePath,
-  changeStatuses,
-  isRemovableReviewer,
-} from './change-util.js';
-
-suite('change-util tests', () => {
-  let originalCanonicalPath;
-
-  suiteSetup(() => {
-    originalCanonicalPath = window.CANONICAL_PATH;
-    window.CANONICAL_PATH = '/r';
-  });
-
-  suiteTeardown(() => {
-    window.CANONICAL_PATH = originalCanonicalPath;
-  });
-
-  test('changeBaseURL', () => {
-    assert.deepEqual(
-        changeBaseURL('test/project', '1', '2'),
-        '/r/changes/test%2Fproject~1/revisions/2'
-    );
-  });
-
-  test('changePath', () => {
-    assert.deepEqual(changePath('1'), '/r/c/1');
-  });
-
-  test('Open status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: true,
-    };
-    let statuses = changeStatuses(change);
-    assert.deepEqual(statuses, []);
-
-    change.submittable = false;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // With no missing labels but no submitEnabled option.
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Active']);
-
-    // Without missing labels and enabled submit
-    statuses = changeStatuses(change,
-        {includeDerived: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.mergeable = false;
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-
-    delete change.mergeable;
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true, mergeable: true, submitEnabled: true});
-    assert.deepEqual(statuses, ['Ready to submit']);
-
-    change.submittable = true;
-    statuses = changeStatuses(change,
-        {includeDerived: true, mergeable: false});
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('Merge conflict', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merge Conflict']);
-  });
-
-  test('mergeable prop undefined', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, []);
-  });
-
-  test('Merged status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'MERGED',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merged']);
-  });
-
-  test('Abandoned status', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'ABANDONED',
-      labels: {},
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Abandoned']);
-  });
-
-  test('Open status with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: true,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['WIP', 'Private']);
-  });
-
-  test('Merge conflict with private and wip', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    const statuses = changeStatuses(change);
-    assert.deepEqual(statuses, ['Merge Conflict', 'WIP', 'Private']);
-  });
-
-  test('isRemovableReviewer', () => {
-    let change = {
-      removable_reviewers: [{_account_id: 1}],
-    };
-    const reviewer = {_account_id: 1};
-
-    assert.equal(isRemovableReviewer(change, reviewer), true);
-
-    change = {
-      removable_reviewers: [{_account_id: 2}],
-    };
-    assert.equal(isRemovableReviewer(change, reviewer), false);
-  });
-
-  test('changeIsOpen', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'NEW',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    assert.isTrue(changeIsOpen(change));
-    change.status = 'MERGED';
-    assert.isFalse(changeIsOpen(change));
-  });
-
-  test('changeIsMerged', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'MERGED',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    assert.isTrue(changeIsMerged(change));
-    change.status = 'NEW';
-    assert.isFalse(changeIsMerged(change));
-  });
-
-  test('changeIsAbandoned', () => {
-    const change = {
-      change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-      revisions: {
-        rev1: {_number: 1},
-      },
-      current_revision: 'rev1',
-      status: 'ABANDONED',
-      is_private: true,
-      work_in_progress: true,
-      labels: {},
-      mergeable: false,
-    };
-    assert.isTrue(changeIsAbandoned(change));
-    change.status = 'NEW';
-    assert.isFalse(changeIsAbandoned(change));
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/change-util_test.ts b/polygerrit-ui/app/utils/change-util_test.ts
new file mode 100644
index 0000000..ccec27e
--- /dev/null
+++ b/polygerrit-ui/app/utils/change-util_test.ts
@@ -0,0 +1,240 @@
+/**
+ * @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 {ChangeStatus} from '../constants/constants';
+import {ChangeStates} from '../elements/shared/gr-change-status/gr-change-status';
+import '../test/common-test-setup-karma';
+import {createChange, createRevisions} from '../test/test-data-generators';
+import {
+  AccountId,
+  CommitId,
+  NumericChangeId,
+  PatchSetNum,
+} from '../types/common';
+import {
+  changeBaseURL,
+  changeIsOpen,
+  changeIsMerged,
+  changeIsAbandoned,
+  changePath,
+  changeStatuses,
+  isRemovableReviewer,
+} from './change-util';
+
+suite('change-util tests', () => {
+  let originalCanonicalPath: string | undefined;
+
+  suiteSetup(() => {
+    originalCanonicalPath = window.CANONICAL_PATH;
+    window.CANONICAL_PATH = '/r';
+  });
+
+  suiteTeardown(() => {
+    window.CANONICAL_PATH = originalCanonicalPath;
+  });
+
+  test('changeBaseURL', () => {
+    assert.deepEqual(
+      changeBaseURL('test/project', 1 as NumericChangeId, '2' as PatchSetNum),
+      '/r/changes/test%2Fproject~1/revisions/2'
+    );
+  });
+
+  test('changePath', () => {
+    assert.deepEqual(changePath(1 as NumericChangeId), '/r/c/1');
+  });
+
+  test('Open status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      mergeable: true,
+    };
+    let statuses = changeStatuses(change);
+    assert.deepEqual(statuses, []);
+
+    change.submittable = false;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
+
+    // With no missing labels but no submitEnabled option.
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.ACTIVE]);
+
+    // Without missing labels and enabled submit
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
+
+    change.mergeable = false;
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+
+    change.mergeable = true;
+    statuses = changeStatuses(change, {mergeable: true, submitEnabled: true});
+    assert.deepEqual(statuses, [ChangeStates.READY_TO_SUBMIT]);
+
+    change.submittable = true;
+    statuses = changeStatuses(change, {mergeable: false, submitEnabled: false});
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+  });
+
+  test('Merge conflict', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.MERGE_CONFLICT]);
+  });
+
+  test('mergeable prop undefined', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, []);
+  });
+
+  test('Merged status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.MERGED]);
+  });
+
+  test('Abandoned status', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.ABANDONED,
+      mergeable: false,
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.ABANDONED]);
+  });
+
+  test('Open status with private and wip', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: true,
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [ChangeStates.WIP, ChangeStates.PRIVATE]);
+  });
+
+  test('Merge conflict with private and wip', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      is_private: true,
+      work_in_progress: true,
+      labels: {},
+    };
+    const statuses = changeStatuses(change);
+    assert.deepEqual(statuses, [
+      ChangeStates.MERGE_CONFLICT,
+      ChangeStates.WIP,
+      ChangeStates.PRIVATE,
+    ]);
+  });
+
+  test('isRemovableReviewer', () => {
+    let change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      removable_reviewers: [{_account_id: 1 as AccountId}],
+    };
+    const reviewer = {_account_id: 1 as AccountId};
+
+    assert.equal(isRemovableReviewer(change, reviewer), true);
+
+    change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+      removable_reviewers: [{_account_id: 2 as AccountId}],
+    };
+    assert.equal(isRemovableReviewer(change, reviewer), false);
+  });
+
+  test('changeIsOpen', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.NEW,
+      mergeable: false,
+    };
+    assert.isTrue(changeIsOpen(change));
+    change.status = ChangeStatus.MERGED;
+    assert.isFalse(changeIsOpen(change));
+  });
+
+  test('changeIsMerged', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.MERGED,
+      mergeable: false,
+    };
+    assert.isTrue(changeIsMerged(change));
+    change.status = ChangeStatus.NEW;
+    assert.isFalse(changeIsMerged(change));
+  });
+
+  test('changeIsAbandoned', () => {
+    const change = {
+      ...createChange(),
+      revisions: createRevisions(1),
+      current_revision: 'rev1' as CommitId,
+      status: ChangeStatus.ABANDONED,
+      mergeable: false,
+    };
+    assert.isTrue(changeIsAbandoned(change));
+    change.status = ChangeStatus.NEW;
+    assert.isFalse(changeIsAbandoned(change));
+  });
+});
diff --git a/polygerrit-ui/app/utils/comment-util.ts b/polygerrit-ui/app/utils/comment-util.ts
index 2d5f66e..5b08fab 100644
--- a/polygerrit-ui/app/utils/comment-util.ts
+++ b/polygerrit-ui/app/utils/comment-util.ts
@@ -27,6 +27,8 @@
   ContextLine,
   BasePatchSetNum,
   RevisionPatchSetNum,
+  AccountInfo,
+  AccountDetailInfo,
 } from '../types/common';
 import {CommentSide, Side, SpecialFilePath} from '../constants/constants';
 import {parseDate} from './date-util';
@@ -110,14 +112,12 @@
   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;
+        if (comment.id) idThreadMap[comment.id] = thread;
         continue;
       }
     }
@@ -129,6 +129,7 @@
     const newThread: CommentThread = {
       comments: [comment],
       patchNum: comment.patch_set,
+      diffSide: Side.LEFT,
       commentSide: comment.side ?? CommentSide.REVISION,
       mergeParentNum: comment.parent,
       path: comment.path,
@@ -147,7 +148,7 @@
       newThread.line = 'FILE';
     }
     threads.push(newThread);
-    idThreadMap[comment.id] = newThread;
+    if (comment.id) idThreadMap[comment.id] = newThread;
   }
   return threads;
 }
@@ -330,3 +331,54 @@
   };
   return diff;
 }
+
+export function getCommentAuthors(
+  threads?: CommentThread[],
+  user?: AccountDetailInfo
+) {
+  if (!threads || !user) return [];
+  const ids = new Set();
+  const authors: AccountInfo[] = [];
+  threads.forEach(t =>
+    t.comments.forEach(c => {
+      if (isDraft(c) && !ids.has(user._account_id)) {
+        ids.add(user._account_id);
+        authors.push(user);
+        return;
+      }
+      if (c.author && !ids.has(c.author._account_id)) {
+        ids.add(c.author._account_id);
+        authors.push(c.author);
+      }
+    })
+  );
+  return authors;
+}
+
+export function computeId(comment: UIComment) {
+  if (comment.id) return comment.id;
+  if (isDraft(comment)) return comment.__draftID;
+  throw new Error('Missing id in root comment.');
+}
+
+/**
+ * Add path info to every comment as CommentInfo returned
+ * from server does not have that.
+ *
+ * TODO(taoalpha): should consider changing BE to send path
+ * back within CommentInfo
+ */
+export function addPath<T>(comments: {[path: string]: T[]} = {}): {
+  [path: string]: Array<T & {path: string}>;
+} {
+  const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
+  for (const filePath of Object.keys(comments)) {
+    const allCommentsForPath = comments[filePath] || [];
+    if (allCommentsForPath.length) {
+      updatedComments[filePath] = allCommentsForPath.map(comment => {
+        return {...comment, path: filePath};
+      });
+    }
+  }
+  return updatedComments;
+}
diff --git a/polygerrit-ui/app/utils/common-util.ts b/polygerrit-ui/app/utils/common-util.ts
index 08b5e49..9e3bc74 100644
--- a/polygerrit-ui/app/utils/common-util.ts
+++ b/polygerrit-ui/app/utils/common-util.ts
@@ -90,6 +90,36 @@
   }
 }
 
+export function queryAll<E extends Element = Element>(
+  el: Element,
+  selector: string
+): NodeListOf<E> {
+  if (!el) throw new Error('element not defined');
+  const root = el.shadowRoot ?? el;
+  return root.querySelectorAll<E>(selector);
+}
+
+export function query<E extends Element = Element>(
+  el: Element | null | undefined,
+  selector: string
+): E | undefined {
+  if (!el) return undefined;
+  if (el.shadowRoot) {
+    const r = el.shadowRoot.querySelector<E>(selector);
+    if (r) return r;
+  }
+  return el.querySelector<E>(selector) ?? undefined;
+}
+
+export function queryAndAssert<E extends Element = Element>(
+  el: Element | null | undefined,
+  selector: string
+): E {
+  const found = query<E>(el, selector);
+  if (!found) throw new Error(`selector '${selector}' did not match anything'`);
+  return found;
+}
+
 /**
  * Returns true, if both sets contain the same members.
  */
diff --git a/polygerrit-ui/app/utils/common-util_test.js b/polygerrit-ui/app/utils/common-util_test.ts
similarity index 90%
rename from polygerrit-ui/app/utils/common-util_test.js
rename to polygerrit-ui/app/utils/common-util_test.ts
index d6d66d7..4156729 100644
--- a/polygerrit-ui/app/utils/common-util_test.js
+++ b/polygerrit-ui/app/utils/common-util_test.ts
@@ -15,14 +15,14 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {hasOwnProperty, areSetsEqual, containsAll} from './common-util.js';
+import '../test/common-test-setup-karma';
+import {hasOwnProperty, areSetsEqual, containsAll} from './common-util';
 
 suite('common-util tests', () => {
   suite('hasOwnProperty', () => {
     test('object with the default prototype', () => {
       const obj = {
-        'abc': 3,
+        abc: 3,
         'name with spaces': 5,
       };
       assert.isTrue(hasOwnProperty(obj, 'abc'));
@@ -30,13 +30,15 @@
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
     test('object prototype has overridden hasOwnProperty', () => {
-      const F = function() {
-        this.abc = 23;
-      };
-      F.prototype.hasOwnProperty = function(key) {
-        return true;
-      };
-      const obj = new F();
+      class MyObject {
+        abc = 123;
+
+        hasOwnProperty(_key: PropertyKey) {
+          return true;
+        }
+      }
+
+      const obj = new MyObject();
       assert.isTrue(hasOwnProperty(obj, 'abc'));
       assert.isFalse(hasOwnProperty(obj, 'def'));
     });
diff --git a/polygerrit-ui/app/utils/compare-util.ts b/polygerrit-ui/app/utils/compare-util.ts
new file mode 100644
index 0000000..dd20915
--- /dev/null
+++ b/polygerrit-ui/app/utils/compare-util.ts
@@ -0,0 +1,39 @@
+/**
+ * @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 function deepEqualStringDict(
+  a: {[name: string]: string},
+  b: {[name: string]: string}
+): boolean {
+  const aKeys = Object.keys(a);
+  const bKeys = Object.keys(b);
+  if (aKeys.length !== bKeys.length) return false;
+  for (const key of aKeys) {
+    if (a[key] !== b[key]) return false;
+  }
+  return true;
+}
+
+export function equalArray(a?: unknown[], b?: unknown[]): boolean {
+  if (a === b) return true;
+  if (a === undefined) return b === undefined;
+  if (b === undefined) return a === undefined;
+  if (a.length !== b.length) return false;
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] !== b[i]) return false;
+  }
+  return true;
+}
diff --git a/polygerrit-ui/app/utils/compare-util_test.ts b/polygerrit-ui/app/utils/compare-util_test.ts
new file mode 100644
index 0000000..7cd71bf
--- /dev/null
+++ b/polygerrit-ui/app/utils/compare-util_test.ts
@@ -0,0 +1,43 @@
+/**
+ * @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';
+import {deepEqualStringDict, equalArray} from './compare-util';
+
+suite('compare-utils tests', () => {
+  test('deepEqual', () => {
+    assert.isTrue(deepEqualStringDict({}, {}));
+    assert.isTrue(deepEqualStringDict({x: 'y'}, {x: 'y'}));
+    assert.isTrue(deepEqualStringDict({x: 'y', p: 'q'}, {p: 'q', x: 'y'}));
+
+    assert.isFalse(deepEqualStringDict({}, {x: 'y'}));
+    assert.isFalse(deepEqualStringDict({x: 'y'}, {x: 'z'}));
+    assert.isFalse(deepEqualStringDict({x: 'y'}, {z: 'y'}));
+  });
+
+  test('equalArray', () => {
+    assert.isTrue(equalArray(undefined, undefined));
+    assert.isTrue(equalArray([], []));
+    assert.isTrue(equalArray([1], [1]));
+    assert.isTrue(equalArray(['a', 'b'], ['a', 'b']));
+
+    assert.isFalse(equalArray(undefined, []));
+    assert.isFalse(equalArray([], undefined));
+    assert.isFalse(equalArray([], [1]));
+    assert.isFalse(equalArray([1], [2]));
+    assert.isFalse(equalArray([1, 2], [1]));
+  });
+});
diff --git a/polygerrit-ui/app/utils/date-util_test.js b/polygerrit-ui/app/utils/date-util_test.js
deleted file mode 100644
index 96d5bc1..0000000
--- a/polygerrit-ui/app/utils/date-util_test.js
+++ /dev/null
@@ -1,144 +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 {isValidDate, parseDate, fromNow, isWithinDay, isWithinHalfYear, formatDate, wasYesterday} from './date-util.js';
-
-suite('date-util tests', () => {
-  suite('parseDate', () => {
-    test('parseDate server date', () => {
-      const parsed = parseDate('2015-09-15 20:34:00.000000000');
-      assert.equal('2015-09-15T20:34:00.000Z', parsed.toISOString());
-    });
-  });
-
-  suite('isValidDate', () => {
-    test('date is valid', () => {
-      assert.isTrue(isValidDate(new Date()));
-    });
-    test('broken date is invalid', () => {
-      assert.isFalse(isValidDate(new Date('xxx')));
-    });
-  });
-
-  suite('fromNow', () => {
-    test('test all variants', () => {
-      const fakeNow = new Date('May 08 2020 12:00:00');
-      sinon.useFakeTimers(fakeNow.getTime());
-      assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
-      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
-      assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
-      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
-      assert.equal(
-          '1 hour 5 min ago', fromNow(new Date('May 08 2020 10:55:00')));
-      assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
-      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
-      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
-      assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
-      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
-      assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
-      assert.equal('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', () => {
-    test('basics works', () => {
-      assert.isTrue(isWithinDay(new Date('May 08 2020 12:00:00'),
-          new Date('May 08 2020 02:00:00')));
-      assert.isFalse(isWithinDay(new Date('May 08 2020 12:00:00'),
-          new Date('May 07 2020 12:00:00')));
-    });
-  });
-
-  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'),
-          new Date('Feb 08 2020 12:00:00')));
-      assert.isFalse(isWithinHalfYear(new Date('May 08 2020 12:00:00'),
-          new Date('Nov 07 2019 12:00:00')));
-    });
-  });
-
-  suite('formatDate', () => {
-    test('works for standard format', () => {
-      const stdFormat = 'MMM DD, YYYY';
-      assert.equal('May 08, 2020',
-          formatDate(new Date('May 08 2020 12:00:00'), stdFormat));
-      assert.equal('Feb 28, 2020',
-          formatDate(new Date('Feb 28 2020 12:00:00'), stdFormat));
-
-      const time24Format = 'HH:mm:ss';
-      assert.equal('Feb 28, 2020 12:01:12',
-          formatDate(new Date('Feb 28 2020 12:01:12'), stdFormat + ' '
-          + time24Format));
-    });
-    test('works for euro format', () => {
-      const euroFormat = 'DD.MM.YYYY';
-      assert.equal('01.12.2019',
-          formatDate(new Date('Dec 01 2019 12:00:00'), euroFormat));
-      assert.equal('20.01.2002',
-          formatDate(new Date('Jan 20 2002 12:00:00'), euroFormat));
-
-      const time24Format = 'HH:mm:ss';
-      assert.equal('28.02.2020 00:01:12',
-          formatDate(new Date('Feb 28 2020 00:01:12'), euroFormat + ' '
-          + time24Format));
-    });
-    test('works for iso format', () => {
-      const isoFormat = 'YYYY-MM-DD';
-      assert.equal('2015-01-01',
-          formatDate(new Date('Jan 01 2015 12:00:00'), isoFormat));
-      assert.equal('2013-07-03',
-          formatDate(new Date('Jul 03 2013 12:00:00'), isoFormat));
-
-      const timeFormat = 'h:mm:ss A';
-      assert.equal('2013-07-03 5:00:00 AM',
-          formatDate(new Date('Jul 03 2013 05:00:00'), isoFormat + ' '
-          + timeFormat));
-      assert.equal('2013-07-03 5:00:00 PM',
-          formatDate(new Date('Jul 03 2013 17:00:00'), isoFormat + ' '
-          + timeFormat));
-    });
-    test('h:mm:ss A shows correctly midnight and midday', () => {
-      const timeFormat = 'h:mm A';
-      assert.equal('12:14 PM',
-          formatDate(new Date('Jul 03 2013 12:14:00'), timeFormat));
-      assert.equal('12:15 AM',
-          formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat));
-    });
-  });
-});
diff --git a/polygerrit-ui/app/utils/date-util_test.ts b/polygerrit-ui/app/utils/date-util_test.ts
new file mode 100644
index 0000000..f17ced3
--- /dev/null
+++ b/polygerrit-ui/app/utils/date-util_test.ts
@@ -0,0 +1,219 @@
+/**
+ * @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 {Timestamp} from '../types/common';
+import '../test/common-test-setup-karma';
+import {
+  isValidDate,
+  parseDate,
+  fromNow,
+  isWithinDay,
+  isWithinHalfYear,
+  formatDate,
+  wasYesterday,
+} from './date-util';
+
+suite('date-util tests', () => {
+  suite('parseDate', () => {
+    test('parseDate server date', () => {
+      const parsed = parseDate('2015-09-15 20:34:00.000000000' as Timestamp);
+      assert.equal('2015-09-15T20:34:00.000Z', parsed.toISOString());
+    });
+  });
+
+  suite('isValidDate', () => {
+    test('date is valid', () => {
+      assert.isTrue(isValidDate(new Date()));
+    });
+    test('broken date is invalid', () => {
+      assert.isFalse(isValidDate(new Date('xxx')));
+    });
+  });
+
+  suite('fromNow', () => {
+    test('test all variants', () => {
+      const fakeNow = new Date('May 08 2020 12:00:00');
+      sinon.useFakeTimers(fakeNow.getTime());
+      assert.equal('just now', fromNow(new Date('May 08 2020 11:59:30')));
+      assert.equal('1 minute ago', fromNow(new Date('May 08 2020 11:59:00')));
+      assert.equal('5 minutes ago', fromNow(new Date('May 08 2020 11:55:00')));
+      assert.equal('1 hour ago', fromNow(new Date('May 08 2020 11:00:00')));
+      assert.equal(
+        '1 hour 5 min ago',
+        fromNow(new Date('May 08 2020 10:55:00'))
+      );
+      assert.equal('3 hours ago', fromNow(new Date('May 08 2020 9:00:00')));
+      assert.equal('1 day ago', fromNow(new Date('May 07 2020 12:00:00')));
+      assert.equal('1 day 2 hr ago', fromNow(new Date('May 07 2020 10:00:00')));
+      assert.equal('3 days ago', fromNow(new Date('May 05 2020 12:00:00')));
+      assert.equal('1 month ago', fromNow(new Date('Apr 05 2020 12:00:00')));
+      assert.equal('2 months ago', fromNow(new Date('Mar 05 2020 12:00:00')));
+      assert.equal('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', () => {
+    test('basics works', () => {
+      assert.isTrue(
+        isWithinDay(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 08 2020 02:00:00')
+        )
+      );
+      assert.isFalse(
+        isWithinDay(
+          new Date('May 08 2020 12:00:00'),
+          new Date('May 07 2020 12:00:00')
+        )
+      );
+    });
+  });
+
+  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'),
+          new Date('Feb 08 2020 12:00:00')
+        )
+      );
+      assert.isFalse(
+        isWithinHalfYear(
+          new Date('May 08 2020 12:00:00'),
+          new Date('Nov 07 2019 12:00:00')
+        )
+      );
+    });
+  });
+
+  suite('formatDate', () => {
+    test('works for standard format', () => {
+      const stdFormat = 'MMM DD, YYYY';
+      assert.equal(
+        'May 08, 2020',
+        formatDate(new Date('May 08 2020 12:00:00'), stdFormat)
+      );
+      assert.equal(
+        'Feb 28, 2020',
+        formatDate(new Date('Feb 28 2020 12:00:00'), stdFormat)
+      );
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal(
+        'Feb 28, 2020 12:01:12',
+        formatDate(
+          new Date('Feb 28 2020 12:01:12'),
+          stdFormat + ' ' + time24Format
+        )
+      );
+    });
+    test('works for euro format', () => {
+      const euroFormat = 'DD.MM.YYYY';
+      assert.equal(
+        '01.12.2019',
+        formatDate(new Date('Dec 01 2019 12:00:00'), euroFormat)
+      );
+      assert.equal(
+        '20.01.2002',
+        formatDate(new Date('Jan 20 2002 12:00:00'), euroFormat)
+      );
+
+      const time24Format = 'HH:mm:ss';
+      assert.equal(
+        '28.02.2020 00:01:12',
+        formatDate(
+          new Date('Feb 28 2020 00:01:12'),
+          euroFormat + ' ' + time24Format
+        )
+      );
+    });
+    test('works for iso format', () => {
+      const isoFormat = 'YYYY-MM-DD';
+      assert.equal(
+        '2015-01-01',
+        formatDate(new Date('Jan 01 2015 12:00:00'), isoFormat)
+      );
+      assert.equal(
+        '2013-07-03',
+        formatDate(new Date('Jul 03 2013 12:00:00'), isoFormat)
+      );
+
+      const timeFormat = 'h:mm:ss A';
+      assert.equal(
+        '2013-07-03 5:00:00 AM',
+        formatDate(
+          new Date('Jul 03 2013 05:00:00'),
+          isoFormat + ' ' + timeFormat
+        )
+      );
+      assert.equal(
+        '2013-07-03 5:00:00 PM',
+        formatDate(
+          new Date('Jul 03 2013 17:00:00'),
+          isoFormat + ' ' + timeFormat
+        )
+      );
+    });
+    test('h:mm:ss A shows correctly midnight and midday', () => {
+      const timeFormat = 'h:mm A';
+      assert.equal(
+        '12:14 PM',
+        formatDate(new Date('Jul 03 2013 12:14:00'), timeFormat)
+      );
+      assert.equal(
+        '12:15 AM',
+        formatDate(new Date('Jul 03 2013 00:15:00'), timeFormat)
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/display-name-util_test.js b/polygerrit-ui/app/utils/display-name-util_test.js
deleted file mode 100644
index 9bb68dc..0000000
--- a/polygerrit-ui/app/utils/display-name-util_test.js
+++ /dev/null
@@ -1,200 +0,0 @@
-/**
- * @license
- * Copyright (C) 2019 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {getDisplayName, getUserName, getGroupDisplayName, getAccountDisplayName, _testOnly_accountEmail} from './display-name-util.js';
-
-suite('display-name-utils tests', () => {
-  // eslint-disable-next-line no-unused-vars
-  const config = {
-    user: {
-      anonymous_coward_name: 'Anonymous Coward',
-    },
-  };
-
-  test('getDisplayName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.equal(getDisplayName(config, account),
-        'test-name');
-  });
-
-  test('getDisplayName prefer displayName', () => {
-    const account = {
-      name: 'test-name',
-      display_name: 'better-name',
-    };
-    assert.equal(getDisplayName(config, account),
-        'better-name');
-  });
-
-  test('getDisplayName prefer username default', () => {
-    const account = {
-      name: 'test-name',
-      username: 'user-name',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'USERNAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'user-name');
-  });
-
-  test('getDisplayName firstNameOnly', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    assert.equal(getDisplayName(config, account, true), 'firstname');
-  });
-
-  test('getDisplayName prefer first name default', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FIRST_NAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'firstname');
-  });
-
-  test('getDisplayName ignore leading whitespace for first name', () => {
-    const account = {
-      name: '   firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FIRST_NAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'firstname');
-  });
-
-  test('getDisplayName full name default', () => {
-    const account = {
-      name: 'firstname lastname',
-    };
-    const config = {
-      accounts: {
-        default_display_name: 'FULL_NAME',
-      },
-    };
-    assert.equal(getDisplayName(config, account),
-        'firstname lastname');
-  });
-
-  test('getDisplayName name only', () => {
-    const account = {
-      name: 'test-name',
-    };
-    assert.deepEqual(getUserName(config, account),
-        'test-name');
-  });
-
-  test('getUserName username only', () => {
-    const account = {
-      username: 'test-user',
-    };
-    assert.deepEqual(getUserName(config, account),
-        'test-user');
-  });
-
-  test('getUserName email only', () => {
-    const account = {
-      email: 'test-user@test-url.com',
-    };
-    assert.deepEqual(getUserName(config, account),
-        'test-user@test-url.com');
-  });
-
-  test('getUserName returns not Anonymous Coward as the anon name', () => {
-    assert.deepEqual(getUserName(config, null),
-        'Anonymous');
-  });
-
-  test('getUserName for the config returning the anon name', () => {
-    const config = {
-      user: {
-        anonymous_coward_name: 'Test Anon',
-      },
-    };
-    assert.deepEqual(getUserName(config, null),
-        'Test Anon');
-  });
-
-  test('getAccountDisplayName - account with name only', () => {
-    assert.equal(
-        getAccountDisplayName(config,
-            {name: 'Some user name'}),
-        'Some user name');
-  });
-
-  test('getAccountDisplayName - account with email only', () => {
-    assert.equal(
-        getAccountDisplayName(config,
-            {email: 'my@example.com'}),
-        'my@example.com <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with name and status', () => {
-    assert.equal(
-        getAccountDisplayName(config, {
-          name: 'Some name',
-          status: 'OOO',
-        }),
-        'Some name (OOO)');
-  });
-
-  test('getAccountDisplayName - account with name and email', () => {
-    assert.equal(
-        getAccountDisplayName(config, {
-          name: 'Some name',
-          email: 'my@example.com',
-        }),
-        'Some name <my@example.com>');
-  });
-
-  test('getAccountDisplayName - account with name, email and status', () => {
-    assert.equal(
-        getAccountDisplayName(config, {
-          name: 'Some name',
-          email: 'my@example.com',
-          status: 'OOO',
-        }),
-        'Some name <my@example.com> (OOO)');
-  });
-
-  test('getGroupDisplayName', () => {
-    assert.equal(
-        getGroupDisplayName({name: 'Some user name'}),
-        'Some user name (group)');
-  });
-
-  test('_accountEmail', () => {
-    assert.equal(
-        _testOnly_accountEmail('email@gerritreview.com'),
-        '<email@gerritreview.com>');
-    assert.equal(_testOnly_accountEmail(undefined), '');
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/display-name-util_test.ts b/polygerrit-ui/app/utils/display-name-util_test.ts
new file mode 100644
index 0000000..e6d4704
--- /dev/null
+++ b/polygerrit-ui/app/utils/display-name-util_test.ts
@@ -0,0 +1,225 @@
+/**
+ * @license
+ * Copyright (C) 2019 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  AccountInfo,
+  DefaultDisplayNameConfig,
+  EmailAddress,
+  GroupName,
+  ServerInfo,
+} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {
+  getDisplayName,
+  getUserName,
+  getGroupDisplayName,
+  getAccountDisplayName,
+  _testOnly_accountEmail,
+} from './display-name-util';
+import {
+  createAccountsConfig,
+  createGroupInfo,
+  createServerInfo,
+} from '../test/test-data-generators';
+
+suite('display-name-utils tests', () => {
+  const config: ServerInfo = {
+    ...createServerInfo(),
+    user: {
+      anonymous_coward_name: 'Anonymous Coward',
+    },
+  };
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.equal(getDisplayName(config, account), 'test-name');
+  });
+
+  test('getDisplayName prefer displayName', () => {
+    const account = {
+      name: 'test-name',
+      display_name: 'better-name',
+    };
+    assert.equal(getDisplayName(config, account), 'better-name');
+  });
+
+  test('getDisplayName prefer username default', () => {
+    const account = {
+      name: 'test-name',
+      username: 'user-name',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.USERNAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'user-name');
+  });
+
+  test('getDisplayName firstNameOnly', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    assert.equal(getDisplayName(config, account, true), 'firstname');
+  });
+
+  test('getDisplayName prefer first name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.FIRST_NAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'firstname');
+  });
+
+  test('getDisplayName ignore leading whitespace for first name', () => {
+    const account = {
+      name: '   firstname lastname',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.FIRST_NAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'firstname');
+  });
+
+  test('getDisplayName full name default', () => {
+    const account = {
+      name: 'firstname lastname',
+    };
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      accounts: {
+        ...createAccountsConfig(),
+        default_display_name: DefaultDisplayNameConfig.FULL_NAME,
+      },
+    };
+    assert.equal(getDisplayName(config, account), 'firstname lastname');
+  });
+
+  test('getDisplayName name only', () => {
+    const account = {
+      name: 'test-name',
+    };
+    assert.deepEqual(getUserName(config, account), 'test-name');
+  });
+
+  test('getUserName username only', () => {
+    const account = {
+      username: 'test-user',
+    };
+    assert.deepEqual(getUserName(config, account), 'test-user');
+  });
+
+  test('getUserName email only', () => {
+    const account: AccountInfo = {
+      email: 'test-user@test-url.com' as EmailAddress,
+    };
+    assert.deepEqual(getUserName(config, account), 'test-user@test-url.com');
+  });
+
+  test('getUserName returns not Anonymous Coward as the anon name', () => {
+    assert.deepEqual(getUserName(config, undefined), 'Anonymous');
+  });
+
+  test('getUserName for the config returning the anon name', () => {
+    const config: ServerInfo = {
+      ...createServerInfo(),
+      user: {
+        anonymous_coward_name: 'Test Anon',
+      },
+    };
+    assert.deepEqual(getUserName(config, undefined), 'Test Anon');
+  });
+
+  test('getAccountDisplayName - account with name only', () => {
+    assert.equal(
+      getAccountDisplayName(config, {name: 'Some user name'}),
+      'Some user name'
+    );
+  });
+
+  test('getAccountDisplayName - account with email only', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        email: 'my@example.com' as EmailAddress,
+      }),
+      'my@example.com <my@example.com>'
+    );
+  });
+
+  test('getAccountDisplayName - account with name and status', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        name: 'Some name',
+        status: 'OOO',
+      }),
+      'Some name (OOO)'
+    );
+  });
+
+  test('getAccountDisplayName - account with name and email', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        name: 'Some name',
+        email: 'my@example.com' as EmailAddress,
+      }),
+      'Some name <my@example.com>'
+    );
+  });
+
+  test('getAccountDisplayName - account with name, email and status', () => {
+    assert.equal(
+      getAccountDisplayName(config, {
+        name: 'Some name',
+        email: 'my@example.com' as EmailAddress,
+        status: 'OOO',
+      }),
+      'Some name <my@example.com> (OOO)'
+    );
+  });
+
+  test('getGroupDisplayName', () => {
+    assert.equal(
+      getGroupDisplayName({
+        ...createGroupInfo(),
+        name: 'Some user name' as GroupName,
+      }),
+      'Some user name (group)'
+    );
+  });
+
+  test('_accountEmail', () => {
+    assert.equal(
+      _testOnly_accountEmail('email@gerritreview.com'),
+      '<email@gerritreview.com>'
+    );
+    assert.equal(_testOnly_accountEmail(undefined), '');
+  });
+});
diff --git a/polygerrit-ui/app/utils/dom-util.ts b/polygerrit-ui/app/utils/dom-util.ts
index 3affef7..dd408d3 100644
--- a/polygerrit-ui/app/utils/dom-util.ts
+++ b/polygerrit-ui/app/utils/dom-util.ts
@@ -14,7 +14,6 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
 import {EventApi} from '@polymer/polymer/lib/legacy/polymer.dom';
 import {check} from './common-util';
 
@@ -36,6 +35,17 @@
   return 'shadowRoot' in el;
 }
 
+export function isElement(node: Node): node is Element {
+  return node.nodeType === 1;
+}
+
+export function isElementTarget(
+  target: EventTarget | null | undefined
+): target is Element {
+  if (!target) return false;
+  return 'nodeType' in target && isElement(target as Node);
+}
+
 // TODO: maybe should have a better name for this
 function getPathFromNode(el: EventTarget) {
   let tagName = '';
@@ -170,7 +180,7 @@
  *  getEventPath(e); // eg: div.class1>p#pid.class2
  * }
  */
-export function getEventPath<T extends PolymerEvent>(e?: T) {
+export function getEventPath<T extends MouseEvent>(e?: T) {
   if (!e) return '';
 
   let path = e.composedPath();
@@ -226,7 +236,7 @@
 // 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,
+  root: Document | ShadowRoot | null,
   ignoreDialogs?: boolean
 ): HTMLElement | null {
   if (root === null) {
@@ -256,7 +266,7 @@
 export function isSafari() {
   return (
     /^((?!chrome|android).)*safari/i.test(navigator.userAgent) ||
-    (/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream)
+    /iPad|iPhone|iPod/.test(navigator.userAgent)
   );
 }
 
@@ -292,3 +302,167 @@
     el.classList.remove(className);
   }
 }
+
+/**
+ * For matching the `key` property of KeyboardEvents. These are known to work
+ * with Firefox, Safari and Chrome.
+ */
+export enum Key {
+  ENTER = 'Enter',
+  ESC = 'Escape',
+  TAB = 'Tab',
+  SPACE = ' ',
+  LEFT = 'ArrowLeft',
+  RIGHT = 'ArrowRight',
+  UP = 'ArrowUp',
+  DOWN = 'ArrowDown',
+}
+
+export enum Modifier {
+  ALT_KEY,
+  CTRL_KEY,
+  META_KEY,
+  SHIFT_KEY,
+}
+
+export enum ComboKey {
+  G = 'g',
+  V = 'v',
+}
+
+export interface Binding {
+  key: string | Key;
+  /** Defaults to false. */
+  docOnly?: boolean;
+  /** Defaults to not being a combo shortcut. */
+  combo?: ComboKey;
+  /** Defaults to no modifiers. */
+  modifiers?: Modifier[];
+}
+
+const ALPHA_NUM = new RegExp(/^[A-Za-z0-9]$/);
+
+/**
+ * For "normal" keys we do not check that the SHIFT modifier is pressed or not,
+ * because that depends on the keyboard layout. Just checking the key string is
+ * sufficient.
+ *
+ * But for some special keys it is important whether SHIFT is pressed at the
+ * same time, for example we want to distinguish Enter from Shift+Enter.
+ */
+function shiftMustMatch(key: string | Key) {
+  return Object.values(Key).includes(key as Key);
+}
+
+/**
+ * For a-zA-Z0-9 and for Enter, Tab, etc. we want to check the ALT modifier.
+ *
+ * But for special chars like []/? we don't care whether the user is pressing
+ * the ALT modifier to produce the special char. For example on a German
+ * keyboard layout you have to press ALT to produce a [.
+ */
+function altMustMatch(key: string | Key) {
+  return ALPHA_NUM.test(key) || Object.values(Key).includes(key as Key);
+}
+
+export function eventMatchesShortcut(
+  e: KeyboardEvent,
+  shortcut: Binding
+): boolean {
+  if (e.key !== shortcut.key) return false;
+  const modifiers = shortcut.modifiers ?? [];
+  if (e.ctrlKey !== modifiers.includes(Modifier.CTRL_KEY)) return false;
+  if (e.metaKey !== modifiers.includes(Modifier.META_KEY)) return false;
+  if (
+    altMustMatch(e.key) &&
+    e.altKey !== modifiers.includes(Modifier.ALT_KEY)
+  ) {
+    return false;
+  }
+  if (
+    shiftMustMatch(e.key) &&
+    e.shiftKey !== modifiers.includes(Modifier.SHIFT_KEY)
+  ) {
+    return false;
+  }
+  return true;
+}
+
+export function addGlobalShortcut(
+  shortcut: Binding,
+  listener: (e: KeyboardEvent) => void
+) {
+  return addShortcut(document.body, shortcut, listener);
+}
+
+export function addShortcut(
+  element: HTMLElement,
+  shortcut: Binding,
+  listener: (e: KeyboardEvent) => void,
+  options: {
+    shouldSuppress: boolean;
+  } = {
+    shouldSuppress: false,
+  }
+) {
+  const wrappedListener = (e: KeyboardEvent) => {
+    if (e.repeat) return;
+    if (options.shouldSuppress && shouldSuppress(e)) return;
+    if (eventMatchesShortcut(e, shortcut)) {
+      listener(e);
+    }
+  };
+  element.addEventListener('keydown', wrappedListener);
+  return () => element.removeEventListener('keydown', wrappedListener);
+}
+
+export function modifierPressed(e: KeyboardEvent) {
+  return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
+}
+
+export function shiftPressed(e: KeyboardEvent) {
+  return e.shiftKey;
+}
+
+/**
+ * When you listen on keyboard events, then within Gerrit's web app you may want
+ * to avoid firing in certain common scenarios such as key strokes from <input>
+ * elements. But this can also be undesirable, for example Ctrl-Enter from
+ * <input> should trigger a save event.
+ *
+ * The shortcuts-service has a stateful method `shouldSuppress()` with
+ * reporting functionality, which delegates to here.
+ */
+export function shouldSuppress(e: KeyboardEvent): boolean {
+  // Note that when you listen on document, then `e.currentTarget` will be the
+  // document and `e.target` will be `<gr-app>` due to shadow dom, but by
+  // using the composedPath() you can actually find the true origin of the
+  // event.
+  const rootTarget = e.composedPath()[0];
+  if (!isElementTarget(rootTarget)) return false;
+  const tagName = rootTarget.tagName;
+  const type = rootTarget.getAttribute('type');
+
+  if (
+    // Suppress shortcuts on <input> and <textarea>, but not on
+    // checkboxes, because we want to enable workflows like 'click
+    // mark-reviewed and then press ] to go to the next file'.
+    (tagName === 'INPUT' && type !== 'checkbox') ||
+    tagName === 'TEXTAREA' ||
+    // Suppress shortcuts if the key is 'enter'
+    // and target is an anchor or button or paper-tab.
+    (e.keyCode === 13 &&
+      (tagName === 'A' ||
+        tagName === 'BUTTON' ||
+        tagName === 'GR-BUTTON' ||
+        tagName === 'PAPER-TAB'))
+  ) {
+    return true;
+  }
+  const path: EventTarget[] = e.composedPath() ?? [];
+  for (const el of path) {
+    if (!isElementTarget(el)) continue;
+    if (el.tagName === 'GR-OVERLAY') return true;
+  }
+  return false;
+}
diff --git a/polygerrit-ui/app/utils/dom-util_test.js b/polygerrit-ui/app/utils/dom-util_test.js
deleted file mode 100644
index bcb4505..0000000
--- a/polygerrit-ui/app/utils/dom-util_test.js
+++ /dev/null
@@ -1,155 +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 {strToClassName, getComputedStyleValue, querySelector, querySelectorAll, descendedFromClass, getEventPath} from './dom-util.js';
-import {PolymerElement} from '@polymer/polymer/polymer-element.js';
-import {html} from '@polymer/polymer/lib/utils/html-tag.js';
-
-class TestEle extends PolymerElement {
-  static get is() {
-    return 'dom-util-test-element';
-  }
-
-  static get template() {
-    return html`
-    <div>
-      <div class="a">
-        <div class="b">
-          <div class="c"></div>
-        </div>
-        <span class="ss"></span>
-      </div>
-      <span class="ss"></span>
-    </div>
-    `;
-  }
-}
-
-customElements.define(TestEle.is, TestEle);
-
-const basicFixture = fixtureFromTemplate(html`
-  <div id="test" class="a b c">
-    <a class="testBtn" style="color:red;"></a>
-    <dom-util-test-element></dom-util-test-element>
-    <span class="ss"></span>
-  </div>
-`);
-
-suite('dom-util tests', () => {
-  suite('getEventPath', () => {
-    test('empty event', () => {
-      assert.equal(getEventPath(), '');
-      assert.equal(getEventPath(null), '');
-      assert.equal(getEventPath(undefined), '');
-      assert.equal(getEventPath({composedPath: () => []}), '');
-    });
-
-    test('event with fake path', () => {
-      assert.equal(getEventPath({composedPath: () => []}), '');
-      const dd = document.createElement('dd');
-      assert.equal(getEventPath({composedPath: () => [dd]}), 'dd');
-    });
-
-    test('event with fake complicated path', () => {
-      const dd = document.createElement('dd');
-      dd.setAttribute('id', 'test');
-      dd.className = 'a b';
-      const divNode = document.createElement('DIV');
-      divNode.id = 'test2';
-      divNode.className = 'a b c';
-      assert.equal(getEventPath(
-          {composedPath: () => [dd, divNode]}),
-      'div#test2.a.b.c>dd#test.a.b'
-      );
-    });
-
-    test('event with fake target', () => {
-      const fakeTargetParent1 = document.createElement('dd');
-      fakeTargetParent1.setAttribute('id', 'test');
-      fakeTargetParent1.className = 'a b';
-      const fakeTargetParent2 = document.createElement('DIV');
-      fakeTargetParent2.id = 'test2';
-      fakeTargetParent2.className = 'a b c';
-      fakeTargetParent2.appendChild(fakeTargetParent1);
-      const fakeTarget = document.createElement('SPAN');
-      fakeTargetParent1.appendChild(fakeTarget);
-      assert.equal(
-          getEventPath({composedPath: () => {}, target: fakeTarget}),
-          'div#test2.a.b.c>dd#test.a.b>span'
-      );
-    });
-
-    test('event with real click', () => {
-      const element = basicFixture.instantiate();
-      const aLink = element.querySelector('a');
-      let path;
-      aLink.onclick = e => path = getEventPath(e);
-      MockInteractions.click(aLink);
-      assert.equal(
-          path,
-          `html>body>test-fixture#${basicFixture.fixtureId}>` +
-          'div#test.a.b.c>a.testBtn'
-      );
-    });
-  });
-
-  suite('querySelector and querySelectorAll', () => {
-    test('query cross shadow dom', () => {
-      const element = basicFixture.instantiate();
-      const theFirstEl = querySelector(element, '.ss');
-      const allEls = querySelectorAll(element, '.ss');
-      assert.equal(allEls.length, 3);
-      assert.equal(theFirstEl, allEls[0]);
-    });
-  });
-
-  suite('getComputedStyleValue', () => {
-    test('color style', () => {
-      const element = basicFixture.instantiate();
-      const testBtn = querySelector(element, '.testBtn');
-      assert.equal(
-          getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)'
-      );
-    });
-  });
-
-  suite('descendedFromClass', () => {
-    test('basic tests', () => {
-      const element = basicFixture.instantiate();
-      const testEl = querySelector(element, 'dom-util-test-element');
-      // .c is a child of .a and not vice versa.
-      assert.isTrue(descendedFromClass(querySelector(testEl, '.c'), 'a'));
-      assert.isFalse(descendedFromClass(querySelector(testEl, '.a'), 'c'));
-
-      // Stops at stop element.
-      assert.isFalse(descendedFromClass(querySelector(testEl, '.c'), 'a',
-          querySelector(testEl, '.b')));
-    });
-  });
-
-  suite('strToClassName', () => {
-    test('basic tests', () => {
-      assert.equal(strToClassName(''), 'generated_');
-      assert.equal(strToClassName('11'), 'generated_11');
-      assert.equal(strToClassName('0.123'), 'generated_0_123');
-      assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
-      assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
-      assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
-      assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
-    });
-  });
-});
\ No newline at end of file
diff --git a/polygerrit-ui/app/utils/dom-util_test.ts b/polygerrit-ui/app/utils/dom-util_test.ts
new file mode 100644
index 0000000..5429550
--- /dev/null
+++ b/polygerrit-ui/app/utils/dom-util_test.ts
@@ -0,0 +1,344 @@
+/**
+ * @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 {
+  descendedFromClass,
+  eventMatchesShortcut,
+  getComputedStyleValue,
+  getEventPath,
+  Modifier,
+  querySelectorAll,
+  shouldSuppress,
+  strToClassName,
+} from './dom-util';
+import {PolymerElement} from '@polymer/polymer/polymer-element';
+import {html} from '@polymer/polymer/lib/utils/html-tag';
+import * as MockInteractions from '@polymer/iron-test-helpers/mock-interactions';
+import {queryAndAssert} from '../test/test-utils';
+
+async function keyEventOn(
+  el: HTMLElement,
+  callback: (e: KeyboardEvent) => void,
+  keyCode = 75,
+  key = 'k'
+): Promise<KeyboardEvent> {
+  let resolve: (e: KeyboardEvent) => void;
+  const promise = new Promise<KeyboardEvent>(r => (resolve = r));
+  el.addEventListener('keydown', (e: KeyboardEvent) => {
+    callback(e);
+    resolve(e);
+  });
+  MockInteractions.keyDownOn(el, keyCode, null, key);
+  return await promise;
+}
+
+class TestEle extends PolymerElement {
+  static get is() {
+    return 'dom-util-test-element';
+  }
+
+  static get template() {
+    return html`
+      <div>
+        <div class="a">
+          <div class="b">
+            <div class="c"></div>
+          </div>
+          <span class="ss"></span>
+        </div>
+        <span class="ss"></span>
+      </div>
+    `;
+  }
+}
+
+customElements.define(TestEle.is, TestEle);
+
+const basicFixture = fixtureFromTemplate(html`
+  <div id="test" class="a b c">
+    <a class="testBtn" style="color:red;"></a>
+    <dom-util-test-element></dom-util-test-element>
+    <span class="ss"></span>
+  </div>
+`);
+
+suite('dom-util tests', () => {
+  suite('getEventPath', () => {
+    test('empty event', () => {
+      assert.equal(getEventPath(), '');
+      assert.equal(getEventPath(undefined), '');
+      assert.equal(getEventPath(new MouseEvent('click')), '');
+    });
+
+    test('event with fake path', () => {
+      assert.equal(getEventPath(new MouseEvent('click')), '');
+      const dd = document.createElement('dd');
+      assert.equal(
+        getEventPath({...new MouseEvent('click'), composedPath: () => [dd]}),
+        'dd'
+      );
+    });
+
+    test('event with fake complicated path', () => {
+      const dd = document.createElement('dd');
+      dd.setAttribute('id', 'test');
+      dd.className = 'a b';
+      const divNode = document.createElement('DIV');
+      divNode.id = 'test2';
+      divNode.className = 'a b c';
+      assert.equal(
+        getEventPath({
+          ...new MouseEvent('click'),
+          composedPath: () => [dd, divNode],
+        }),
+        'div#test2.a.b.c>dd#test.a.b'
+      );
+    });
+
+    test('event with fake target', () => {
+      const fakeTargetParent1 = document.createElement('dd');
+      fakeTargetParent1.setAttribute('id', 'test');
+      fakeTargetParent1.className = 'a b';
+      const fakeTargetParent2 = document.createElement('DIV');
+      fakeTargetParent2.id = 'test2';
+      fakeTargetParent2.className = 'a b c';
+      fakeTargetParent2.appendChild(fakeTargetParent1);
+      const fakeTarget = document.createElement('SPAN');
+      fakeTargetParent1.appendChild(fakeTarget);
+      assert.equal(
+        getEventPath({
+          ...new MouseEvent('click'),
+          composedPath: () => [],
+          target: fakeTarget,
+        }),
+        'div#test2.a.b.c>dd#test.a.b>span'
+      );
+    });
+
+    test('event with real click', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const aLink = queryAndAssert(element, 'a');
+      let path;
+      aLink.addEventListener('click', (e: Event) => {
+        path = getEventPath(e as MouseEvent);
+      });
+      MockInteractions.click(aLink);
+      assert.equal(
+        path,
+        `html>body>test-fixture#${basicFixture.fixtureId}>` +
+          'div#test.a.b.c>a.testBtn'
+      );
+    });
+  });
+
+  suite('querySelector and querySelectorAll', () => {
+    test('query cross shadow dom', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const theFirstEl = queryAndAssert(element, '.ss');
+      const allEls = querySelectorAll(element, '.ss');
+      assert.equal(allEls.length, 3);
+      assert.equal(theFirstEl, allEls[0]);
+    });
+  });
+
+  suite('getComputedStyleValue', () => {
+    test('color style', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const testBtn = queryAndAssert(element, '.testBtn');
+      assert.equal(getComputedStyleValue('color', testBtn), 'rgb(255, 0, 0)');
+    });
+  });
+
+  suite('descendedFromClass', () => {
+    test('basic tests', () => {
+      const element = basicFixture.instantiate() as HTMLElement;
+      const testEl = queryAndAssert(element, 'dom-util-test-element');
+      // .c is a child of .a and not vice versa.
+      assert.isTrue(descendedFromClass(queryAndAssert(testEl, '.c'), 'a'));
+      assert.isFalse(descendedFromClass(queryAndAssert(testEl, '.a'), 'c'));
+
+      // Stops at stop element.
+      assert.isFalse(
+        descendedFromClass(
+          queryAndAssert(testEl, '.c'),
+          'a',
+          queryAndAssert(testEl, '.b')
+        )
+      );
+    });
+  });
+
+  suite('strToClassName', () => {
+    test('basic tests', () => {
+      assert.equal(strToClassName(''), 'generated_');
+      assert.equal(strToClassName('11'), 'generated_11');
+      assert.equal(strToClassName('0.123'), 'generated_0_123');
+      assert.equal(strToClassName('0.123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0>123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0<123', 'prefix_'), 'prefix_0_123');
+      assert.equal(strToClassName('0+1+23', 'prefix_'), 'prefix_0_1_23');
+    });
+  });
+
+  suite('eventMatchesShortcut', () => {
+    test('basic tests', () => {
+      const a = new KeyboardEvent('keydown', {key: 'a'});
+      const b = new KeyboardEvent('keydown', {key: 'B'});
+      assert.isTrue(eventMatchesShortcut(a, {key: 'a'}));
+      assert.isFalse(eventMatchesShortcut(a, {key: 'B'}));
+      assert.isFalse(eventMatchesShortcut(b, {key: 'a'}));
+      assert.isTrue(eventMatchesShortcut(b, {key: 'B'}));
+    });
+
+    test('check modifiers for a', () => {
+      const e = new KeyboardEvent('keydown', {key: 'a'});
+      const s = {key: 'a'};
+      assert.isTrue(eventMatchesShortcut(e, s));
+
+      const eAlt = new KeyboardEvent('keydown', {key: 'a', altKey: true});
+      const sAlt = {key: 'a', modifiers: [Modifier.ALT_KEY]};
+      assert.isFalse(eventMatchesShortcut(eAlt, s));
+      assert.isFalse(eventMatchesShortcut(e, sAlt));
+      const eCtrl = new KeyboardEvent('keydown', {key: 'a', ctrlKey: true});
+      const sCtrl = {key: 'a', modifiers: [Modifier.CTRL_KEY]};
+      assert.isFalse(eventMatchesShortcut(eCtrl, s));
+      assert.isFalse(eventMatchesShortcut(e, sCtrl));
+      const eMeta = new KeyboardEvent('keydown', {key: 'a', metaKey: true});
+      const sMeta = {key: 'a', modifiers: [Modifier.META_KEY]};
+      assert.isFalse(eventMatchesShortcut(eMeta, s));
+      assert.isFalse(eventMatchesShortcut(e, sMeta));
+
+      // Do NOT check SHIFT for alphanum keys.
+      const eShift = new KeyboardEvent('keydown', {key: 'a', shiftKey: true});
+      const sShift = {key: 'a', modifiers: [Modifier.SHIFT_KEY]};
+      assert.isTrue(eventMatchesShortcut(eShift, s));
+      assert.isTrue(eventMatchesShortcut(e, sShift));
+    });
+
+    test('check modifiers for Enter', () => {
+      const e = new KeyboardEvent('keydown', {key: 'Enter'});
+      const s = {key: 'Enter'};
+      assert.isTrue(eventMatchesShortcut(e, s));
+
+      const eAlt = new KeyboardEvent('keydown', {key: 'Enter', altKey: true});
+      const sAlt = {key: 'Enter', modifiers: [Modifier.ALT_KEY]};
+      assert.isFalse(eventMatchesShortcut(eAlt, s));
+      assert.isFalse(eventMatchesShortcut(e, sAlt));
+      const eCtrl = new KeyboardEvent('keydown', {key: 'Enter', ctrlKey: true});
+      const sCtrl = {key: 'Enter', modifiers: [Modifier.CTRL_KEY]};
+      assert.isFalse(eventMatchesShortcut(eCtrl, s));
+      assert.isFalse(eventMatchesShortcut(e, sCtrl));
+      const eMeta = new KeyboardEvent('keydown', {key: 'Enter', metaKey: true});
+      const sMeta = {key: 'Enter', modifiers: [Modifier.META_KEY]};
+      assert.isFalse(eventMatchesShortcut(eMeta, s));
+      assert.isFalse(eventMatchesShortcut(e, sMeta));
+      const eShift = new KeyboardEvent('keydown', {
+        key: 'Enter',
+        shiftKey: true,
+      });
+      const sShift = {key: 'Enter', modifiers: [Modifier.SHIFT_KEY]};
+      assert.isFalse(eventMatchesShortcut(eShift, s));
+      assert.isFalse(eventMatchesShortcut(e, sShift));
+    });
+
+    test('check modifiers for [', () => {
+      const e = new KeyboardEvent('keydown', {key: '['});
+      const s = {key: '['};
+      assert.isTrue(eventMatchesShortcut(e, s));
+
+      const eCtrl = new KeyboardEvent('keydown', {key: '[', ctrlKey: true});
+      const sCtrl = {key: '[', modifiers: [Modifier.CTRL_KEY]};
+      assert.isFalse(eventMatchesShortcut(eCtrl, s));
+      assert.isFalse(eventMatchesShortcut(e, sCtrl));
+      const eMeta = new KeyboardEvent('keydown', {key: '[', metaKey: true});
+      const sMeta = {key: '[', modifiers: [Modifier.META_KEY]};
+      assert.isFalse(eventMatchesShortcut(eMeta, s));
+      assert.isFalse(eventMatchesShortcut(e, sMeta));
+
+      // Do NOT check SHIFT and ALT for special chars like [.
+      const eAlt = new KeyboardEvent('keydown', {key: '[', altKey: true});
+      const sAlt = {key: '[', modifiers: [Modifier.ALT_KEY]};
+      assert.isTrue(eventMatchesShortcut(eAlt, s));
+      assert.isTrue(eventMatchesShortcut(e, sAlt));
+      const eShift = new KeyboardEvent('keydown', {
+        key: '[',
+        shiftKey: true,
+      });
+      const sShift = {key: '[', modifiers: [Modifier.SHIFT_KEY]};
+      assert.isTrue(eventMatchesShortcut(eShift, s));
+      assert.isTrue(eventMatchesShortcut(e, sShift));
+    });
+  });
+
+  suite('shouldSuppress', () => {
+    test('do not suppress shortcut event from <div>', async () => {
+      await keyEventOn(document.createElement('div'), e => {
+        assert.isFalse(shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from <input>', async () => {
+      await keyEventOn(document.createElement('input'), e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from <textarea>', async () => {
+      await keyEventOn(document.createElement('textarea'), e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
+    test('do not suppress shortcut event from checkbox <input>', async () => {
+      const inputEl = document.createElement('input');
+      inputEl.setAttribute('type', 'checkbox');
+      await keyEventOn(inputEl, e => {
+        assert.isFalse(shouldSuppress(e));
+      });
+    });
+
+    test('suppress shortcut event from children of <gr-overlay>', async () => {
+      const overlay = document.createElement('gr-overlay');
+      const div = document.createElement('div');
+      overlay.appendChild(div);
+      await keyEventOn(div, e => {
+        assert.isTrue(shouldSuppress(e));
+      });
+    });
+
+    test('suppress "enter" shortcut event from <gr-button>', async () => {
+      await keyEventOn(
+        document.createElement('gr-button'),
+        e => assert.isTrue(shouldSuppress(e)),
+        13,
+        'enter'
+      );
+    });
+
+    test('suppress "enter" shortcut event from <a>', async () => {
+      await keyEventOn(document.createElement('a'), e => {
+        assert.isFalse(shouldSuppress(e));
+      });
+      await keyEventOn(
+        document.createElement('a'),
+        e => assert.isTrue(shouldSuppress(e)),
+        13,
+        'enter'
+      );
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/event-util.ts b/polygerrit-ui/app/utils/event-util.ts
index da075f1..2018eeb 100644
--- a/polygerrit-ui/app/utils/event-util.ts
+++ b/polygerrit-ui/app/utils/event-util.ts
@@ -15,7 +15,6 @@
  * limitations under the License.
  */
 
-import {UrlEncodedCommentId} from '../types/common';
 import {FetchRequest} from '../types/types';
 import {
   DialogChangeEventDetail,
@@ -33,21 +32,19 @@
   );
 }
 
-type HTMLElementEventDetailType<
-  K extends keyof HTMLElementEventMap
-> = HTMLElementEventMap[K] extends CustomEvent<infer DT>
-  ? unknown extends DT
-    ? never
-    : DT
-  : never;
+type HTMLElementEventDetailType<K extends keyof HTMLElementEventMap> =
+  HTMLElementEventMap[K] extends CustomEvent<infer DT>
+    ? unknown extends DT
+      ? never
+      : DT
+    : never;
 
-type DocumentEventDetailType<
-  K extends keyof DocumentEventMap
-> = DocumentEventMap[K] extends CustomEvent<infer DT>
-  ? unknown extends DT
-    ? never
-    : DT
-  : never;
+type DocumentEventDetailType<K extends keyof DocumentEventMap> =
+  DocumentEventMap[K] extends CustomEvent<infer DT>
+    ? unknown extends DT
+      ? never
+      : DT
+    : never;
 
 export function fire<K extends keyof DocumentEventMap>(
   target: Document,
@@ -108,17 +105,6 @@
   fire(target, EventType.IRON_ANNOUNCE, {text});
 }
 
-export function fireThreadListModifiedEvent(
-  target: EventTarget,
-  rootId: UrlEncodedCommentId,
-  path: string
-) {
-  fire(target, EventType.THREAD_LIST_MODIFIED, {
-    rootId,
-    path,
-  });
-}
-
 export function fireShowPrimaryTab(
   target: EventTarget,
   tab: string,
@@ -133,6 +119,10 @@
   fire(target, EventType.CLOSE_FIX_PREVIEW, {fixApplied});
 }
 
+export function fireReload(target: EventTarget, clearPatchset?: boolean) {
+  fire(target, EventType.RELOAD, {clearPatchset: !!clearPatchset});
+}
+
 export function waitForEventOnce<K extends keyof HTMLElementEventMap>(
   el: EventTarget,
   eventName: K
diff --git a/polygerrit-ui/app/utils/label-util.ts b/polygerrit-ui/app/utils/label-util.ts
index 4eed0a0..c50bbe2 100644
--- a/polygerrit-ui/app/utils/label-util.ts
+++ b/polygerrit-ui/app/utils/label-util.ts
@@ -15,16 +15,35 @@
  * limitations under the License.
  */
 import {
+  isQuickLabelInfo,
+  SubmitRequirementResultInfo,
+  SubmitRequirementStatus,
+} from '../api/rest-api';
+import {
   AccountInfo,
   ApprovalInfo,
   DetailedLabelInfo,
   isDetailedLabelInfo,
   LabelInfo,
+  LabelNameToInfoMap,
   VotingRangeInfo,
 } from '../types/common';
+import {assertNever, unique} from './common-util';
 
 // Name of the standard Code-Review label.
-export const CODE_REVIEW = 'Code-Review';
+export enum StandardLabels {
+  CODE_REVIEW = 'Code-Review',
+  CODE_OWNERS = 'Code Owners',
+  PRESUBMIT_VERIFIED = 'Presubmit-Verified',
+}
+
+export enum LabelStatus {
+  APPROVED = 'APPROVED',
+  REJECTED = 'REJECTED',
+  RECOMMENDED = 'RECOMMENDED',
+  DISLIKED = 'DISLIKED',
+  NEUTRAL = 'NEUTRAL',
+}
 
 export function getVotingRange(label?: LabelInfo): VotingRangeInfo | undefined {
   if (!label || !isDetailedLabelInfo(label) || !label.values) return undefined;
@@ -39,6 +58,74 @@
   return range ? range : {min: 0, max: 0};
 }
 
+/**
+ * If we don't know the label config, then we still need some way to decide
+ * which vote value is the most important one, so we apply the standard rule
+ * of a Code-Review label, where -2 blocks. So the most negative vote is
+ * regarded as representative, if its absolute value is greater than or equal
+ * to the most positive vote.
+ */
+export function getRepresentativeValue(label?: DetailedLabelInfo): number {
+  if (!label?.all) return 0;
+  const allValues = label.all.map(approvalInfo => approvalInfo.value ?? 0);
+  if (allValues.length === 0) return 0;
+  const max = Math.max(...allValues);
+  const min = Math.min(...allValues);
+  return max > -min ? max : min;
+}
+
+export function getLabelStatus(label?: LabelInfo, vote?: number): LabelStatus {
+  if (!label) return LabelStatus.NEUTRAL;
+  if (isDetailedLabelInfo(label)) {
+    const value = vote ?? getRepresentativeValue(label);
+    const range = getVotingRangeOrDefault(label);
+    if (value < 0) {
+      return value === range.min ? LabelStatus.REJECTED : LabelStatus.DISLIKED;
+    }
+    if (value > 0) {
+      return value === range.max
+        ? LabelStatus.APPROVED
+        : LabelStatus.RECOMMENDED;
+    }
+  } else if (isQuickLabelInfo(label)) {
+    if (label.approved) return LabelStatus.RECOMMENDED;
+    if (label.rejected) return LabelStatus.DISLIKED;
+  }
+  return LabelStatus.NEUTRAL;
+}
+
+export function hasNeutralStatus(
+  label: DetailedLabelInfo,
+  approvalInfo?: ApprovalInfo
+) {
+  if (approvalInfo?.value === undefined) return true;
+  return getLabelStatus(label, approvalInfo.value) === LabelStatus.NEUTRAL;
+}
+
+export function classForLabelStatus(status: LabelStatus) {
+  switch (status) {
+    case LabelStatus.APPROVED:
+      return 'max';
+    case LabelStatus.RECOMMENDED:
+      return 'positive';
+    case LabelStatus.DISLIKED:
+      return 'negative';
+    case LabelStatus.REJECTED:
+      return 'min';
+    case LabelStatus.NEUTRAL:
+      return 'neutral';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+export function valueString(value?: number) {
+  if (!value) return ' 0';
+  let s = `${value}`;
+  if (value > 0) s = `+${s}`;
+  return s;
+}
+
 export function getMaxAccounts(label?: LabelInfo): ApprovalInfo[] {
   if (!label || !isDetailedLabelInfo(label) || !label.all) return [];
   const votingRange = getVotingRangeOrDefault(label);
@@ -52,10 +139,119 @@
   return label.all?.filter(x => x._account_id === account._account_id)[0];
 }
 
+export function hasVoted(label: LabelInfo, account: AccountInfo) {
+  if (isDetailedLabelInfo(label)) {
+    return !hasNeutralStatus(label, getApprovalInfo(label, account));
+  } else if (isQuickLabelInfo(label)) {
+    return label.approved === account || label.rejected === account;
+  }
+  return false;
+}
+
+export function canVote(label: DetailedLabelInfo, account: AccountInfo) {
+  const approvalInfo = getApprovalInfo(label, account);
+  if (!approvalInfo) return false;
+  if (approvalInfo.permitted_voting_range) {
+    return approvalInfo.permitted_voting_range.max > 0;
+  }
+  // If value present, user can vote on the label.
+  return approvalInfo.value !== undefined;
+}
+
+export function getAllUniqueApprovals(labelInfo?: LabelInfo) {
+  if (!labelInfo || !isDetailedLabelInfo(labelInfo)) return [];
+  const uniqueApprovals = (labelInfo.all ?? [])
+    .filter(
+      (approvalInfo, index, array) =>
+        index === array.findIndex(other => other.value === approvalInfo.value)
+    )
+    .sort((a, b) => -(a.value ?? 0) + (b.value ?? 0));
+  return uniqueApprovals;
+}
+
+export function hasVotes(labelInfo: LabelInfo): boolean {
+  if (isDetailedLabelInfo(labelInfo)) {
+    return (labelInfo.all ?? []).some(
+      approval => !hasNeutralStatus(labelInfo, approval)
+    );
+  }
+  if (isQuickLabelInfo(labelInfo)) {
+    return !!labelInfo.rejected || !!labelInfo.approved;
+  }
+  return false;
+}
+
 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;
+  if (
+    labelName1 === StandardLabels.CODE_REVIEW &&
+    labelName2 === StandardLabels.CODE_REVIEW
+  )
+    return 0;
+  if (labelName1 === StandardLabels.CODE_REVIEW) return -1;
+  if (labelName2 === StandardLabels.CODE_REVIEW) return 1;
 
   return labelName1.localeCompare(labelName2);
 }
+
+export function getCodeReviewLabel(
+  labels: LabelNameToInfoMap
+): LabelInfo | undefined {
+  for (const label of Object.keys(labels)) {
+    if (label === StandardLabels.CODE_REVIEW) {
+      return labels[label];
+    }
+  }
+  return;
+}
+
+export function extractAssociatedLabels(
+  requirement: SubmitRequirementResultInfo
+): string[] {
+  const pattern = new RegExp('label[0-9]*:([\\w-]+)', 'g');
+  const labels = [];
+  let match;
+  while (
+    (match = pattern.exec(
+      requirement.submittability_expression_result.expression
+    )) !== null
+  ) {
+    labels.push(match[1]);
+  }
+  return labels.filter(unique);
+}
+
+export function iconForStatus(status: SubmitRequirementStatus) {
+  switch (status) {
+    case SubmitRequirementStatus.SATISFIED:
+      return 'check';
+    case SubmitRequirementStatus.UNSATISFIED:
+      return 'close';
+    case SubmitRequirementStatus.OVERRIDDEN:
+      return 'overridden';
+    case SubmitRequirementStatus.NOT_APPLICABLE:
+      return 'info';
+    default:
+      assertNever(status, `Unsupported status: ${status}`);
+  }
+}
+
+// TODO(milutin): This may be temporary for demo purposes
+const PRIORITY_REQUIREMENTS_ORDER: string[] = [
+  StandardLabels.CODE_REVIEW,
+  StandardLabels.CODE_OWNERS,
+  StandardLabels.PRESUBMIT_VERIFIED,
+];
+export function orderSubmitRequirements(
+  requirements: SubmitRequirementResultInfo[]
+) {
+  let priorityRequirementList: SubmitRequirementResultInfo[] = [];
+  for (const label of PRIORITY_REQUIREMENTS_ORDER) {
+    const priorityRequirement = requirements.filter(r => r.name === label);
+    priorityRequirementList =
+      priorityRequirementList.concat(priorityRequirement);
+  }
+  const nonPriorityRequirements = requirements.filter(
+    r => !PRIORITY_REQUIREMENTS_ORDER.includes(r.name)
+  );
+  return priorityRequirementList.concat(nonPriorityRequirements);
+}
diff --git a/polygerrit-ui/app/utils/label-util_test.js b/polygerrit-ui/app/utils/label-util_test.js
deleted file mode 100644
index f9a30df..0000000
--- a/polygerrit-ui/app/utils/label-util_test.js
+++ /dev/null
@@ -1,124 +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 {
-  getVotingRange,
-  getVotingRangeOrDefault,
-  getMaxAccounts,
-  getApprovalInfo,
-  labelCompare,
-} from './label-util.js';
-
-const VALUES_1 = {
-  '-1': 'bad',
-  '0': 'neutral',
-  '+1': 'good',
-};
-
-const VALUES_2 = {
-  '-1': 'bad',
-  '+2': 'perfect',
-  '0': 'neutral',
-  '-2': 'blocking',
-  '+1': 'good',
-};
-
-suite('label-util', () => {
-  test('getVotingRange -1 to +1', () => {
-    const label = {values: VALUES_1};
-    const expectedRange = {min: -1, max: 1};
-    assert.deepEqual(getVotingRange(label), expectedRange);
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getVotingRange -2 to +2', () => {
-    const label = {values: VALUES_2};
-    const expectedRange = {min: -2, max: 2};
-    assert.deepEqual(getVotingRange(label), expectedRange);
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getVotingRange empty values', () => {
-    const label = {
-      values: {},
-    };
-    const expectedRange = {min: 0, max: 0};
-    assert.isUndefined(getVotingRange(label));
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getVotingRange no values', () => {
-    const label = {};
-    const expectedRange = {min: 0, max: 0};
-    assert.isUndefined(getVotingRange(label));
-    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
-  });
-
-  test('getMaxAccounts', () => {
-    const label = {
-      values: VALUES_2,
-      all: [
-        {value: 2, _account_id: 314},
-        {value: 1, _account_id: 777},
-      ],
-    };
-
-    const maxAccounts = getMaxAccounts(label);
-
-    assert.equal(maxAccounts.length, 1);
-    assert.equal(maxAccounts[0]._account_id, 314);
-  });
-
-  test('getMaxAccounts unset parameters', () => {
-    assert.isEmpty(getMaxAccounts());
-    assert.isEmpty(getMaxAccounts({}));
-    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
-  });
-
-  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/label-util_test.ts b/polygerrit-ui/app/utils/label-util_test.ts
new file mode 100644
index 0000000..9360688
--- /dev/null
+++ b/polygerrit-ui/app/utils/label-util_test.ts
@@ -0,0 +1,260 @@
+/**
+ * @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 {
+  extractAssociatedLabels,
+  getApprovalInfo,
+  getLabelStatus,
+  getMaxAccounts,
+  getRepresentativeValue,
+  getVotingRange,
+  getVotingRangeOrDefault,
+  hasNeutralStatus,
+  labelCompare,
+  LabelStatus,
+} from './label-util';
+import {
+  AccountId,
+  AccountInfo,
+  ApprovalInfo,
+  DetailedLabelInfo,
+  LabelInfo,
+  QuickLabelInfo,
+} from '../types/common';
+import {
+  createAccountWithEmail,
+  createSubmitRequirementExpressionInfo,
+  createSubmitRequirementResultInfo,
+} from '../test/test-data-generators';
+
+const VALUES_0 = {
+  '0': 'neutral',
+};
+
+const VALUES_1 = {
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+};
+
+const VALUES_2 = {
+  '-2': 'blocking',
+  '-1': 'bad',
+  '0': 'neutral',
+  '+1': 'good',
+  '+2': 'perfect',
+};
+
+suite('label-util', () => {
+  test('getVotingRange -1 to +1', () => {
+    const label = {values: VALUES_1};
+    const expectedRange = {min: -1, max: 1};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange -2 to +2', () => {
+    const label = {values: VALUES_2};
+    const expectedRange = {min: -2, max: 2};
+    assert.deepEqual(getVotingRange(label), expectedRange);
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange empty values', () => {
+    const label = {
+      values: {},
+    };
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getVotingRange no values', () => {
+    const label = {};
+    const expectedRange = {min: 0, max: 0};
+    assert.isUndefined(getVotingRange(label));
+    assert.deepEqual(getVotingRangeOrDefault(label), expectedRange);
+  });
+
+  test('getMaxAccounts', () => {
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314 as AccountId},
+        {value: 1, _account_id: 777 as AccountId},
+      ],
+    };
+
+    const maxAccounts = getMaxAccounts(label);
+
+    assert.equal(maxAccounts.length, 1);
+    assert.equal(maxAccounts[0]._account_id, 314 as AccountId);
+  });
+
+  test('getMaxAccounts unset parameters', () => {
+    assert.isEmpty(getMaxAccounts());
+    assert.isEmpty(getMaxAccounts({}));
+    assert.isEmpty(getMaxAccounts({values: VALUES_2}));
+  });
+
+  test('getApprovalInfo', () => {
+    const myAccountInfo: AccountInfo = {_account_id: 314 as AccountId};
+    const myApprovalInfo: ApprovalInfo = {
+      value: 2,
+      _account_id: 314 as AccountId,
+    };
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [myApprovalInfo, {value: 1, _account_id: 777 as AccountId}],
+    };
+    assert.equal(getApprovalInfo(label, myAccountInfo), myApprovalInfo);
+  });
+
+  test('getApprovalInfo no approval for user', () => {
+    const myAccountInfo: AccountInfo = {_account_id: 123 as AccountId};
+    const label: DetailedLabelInfo = {
+      values: VALUES_2,
+      all: [
+        {value: 2, _account_id: 314 as AccountId},
+        {value: 1, _account_id: 777 as AccountId},
+      ],
+    };
+    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']);
+  });
+
+  test('getLabelStatus', () => {
+    let labelInfo: DetailedLabelInfo = {all: [], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 0}], values: VALUES_0};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 0}], values: VALUES_1};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 0}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {all: [{value: 1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    labelInfo = {all: [{value: -1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.DISLIKED);
+    labelInfo = {all: [{value: 1}], values: VALUES_1};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: -1}], values: VALUES_1};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {all: [{value: 2}, {value: 1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: -2}, {value: -1}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {all: [{value: -2}, {value: 2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+    labelInfo = {all: [{value: -1}, {value: 2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: 0}, {value: 2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.APPROVED);
+    labelInfo = {all: [{value: 0}, {value: -2}], values: VALUES_2};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.REJECTED);
+  });
+
+  test('getLabelStatus - quicklabelinfo', () => {
+    let labelInfo: QuickLabelInfo = {};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+    labelInfo = {approved: createAccountWithEmail()};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.RECOMMENDED);
+    labelInfo = {rejected: createAccountWithEmail()};
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.DISLIKED);
+  });
+
+  test('getLabelStatus - detailed and quick info', () => {
+    let labelInfo: LabelInfo = {all: [], values: VALUES_2};
+    labelInfo = {
+      all: [{value: 0}],
+      values: VALUES_0,
+      rejected: createAccountWithEmail(),
+    };
+    assert.equal(getLabelStatus(labelInfo), LabelStatus.NEUTRAL);
+  });
+
+  test('hasNeutralStatus', () => {
+    const labelInfo: DetailedLabelInfo = {all: [], values: VALUES_2};
+    assert.isTrue(hasNeutralStatus(labelInfo));
+    assert.isTrue(hasNeutralStatus(labelInfo, {}));
+    assert.isTrue(hasNeutralStatus(labelInfo, {value: 0}));
+    assert.isFalse(hasNeutralStatus(labelInfo, {value: -1}));
+    assert.isFalse(hasNeutralStatus(labelInfo, {value: 1}));
+  });
+
+  test('getRepresentativeValue', () => {
+    let labelInfo: DetailedLabelInfo = {all: []};
+    assert.equal(getRepresentativeValue(labelInfo), 0);
+    labelInfo = {all: [{value: 0}]};
+    assert.equal(getRepresentativeValue(labelInfo), 0);
+    labelInfo = {all: [{value: 1}]};
+    assert.equal(getRepresentativeValue(labelInfo), 1);
+    labelInfo = {all: [{value: 2}, {value: 1}]};
+    assert.equal(getRepresentativeValue(labelInfo), 2);
+    labelInfo = {all: [{value: -2}, {value: -1}]};
+    assert.equal(getRepresentativeValue(labelInfo), -2);
+    labelInfo = {all: [{value: -2}, {value: 2}]};
+    assert.equal(getRepresentativeValue(labelInfo), -2);
+    labelInfo = {all: [{value: -1}, {value: 2}]};
+    assert.equal(getRepresentativeValue(labelInfo), 2);
+    labelInfo = {all: [{value: 0}, {value: 2}]};
+    assert.equal(getRepresentativeValue(labelInfo), 2);
+    labelInfo = {all: [{value: 0}, {value: -2}]};
+    assert.equal(getRepresentativeValue(labelInfo), -2);
+  });
+
+  suite('extractAssociatedLabels()', () => {
+    function createSubmitRequirementExpressionInfoWith(expression: string) {
+      return {
+        ...createSubmitRequirementResultInfo(),
+        submittability_expression_result: {
+          ...createSubmitRequirementExpressionInfo(),
+          expression,
+        },
+      };
+    }
+
+    test('1 label', () => {
+      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+        'label:Verified=MAX -label:Verified=MIN'
+      );
+      const labels = extractAssociatedLabels(submitRequirement);
+      assert.deepEqual(labels, ['Verified']);
+    });
+    test('label with number', () => {
+      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+        'label2:verified=MAX'
+      );
+      const labels = extractAssociatedLabels(submitRequirement);
+      assert.deepEqual(labels, ['verified']);
+    });
+    test('2 labels', () => {
+      const submitRequirement = createSubmitRequirementExpressionInfoWith(
+        'label:Verified=MAX -label:Code-Review=MIN'
+      );
+      const labels = extractAssociatedLabels(submitRequirement);
+      assert.deepEqual(labels, ['Verified', 'Code-Review']);
+    });
+  });
+});
diff --git a/polygerrit-ui/app/utils/message-util.ts b/polygerrit-ui/app/utils/message-util.ts
new file mode 100644
index 0000000..70dd286
--- /dev/null
+++ b/polygerrit-ui/app/utils/message-util.ts
@@ -0,0 +1,32 @@
+/**
+ * @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 {MessageTag} from '../constants/constants';
+import {ChangeId, ChangeMessageInfo} from '../types/common';
+
+function getRevertChangeIdFromMessage(msg: ChangeMessageInfo): ChangeId {
+  const REVERT_REGEX = /^Created a revert of this change as (.*)$/;
+  const changeId = msg.message.match(REVERT_REGEX)?.[1];
+  if (!changeId) throw new Error('revert changeId not found');
+  return changeId as ChangeId;
+}
+
+export function getRevertCreatedChangeIds(messages: ChangeMessageInfo[]) {
+  return messages
+    .filter(m => m.tag === MessageTag.TAG_REVERT)
+    .map(m => getRevertChangeIdFromMessage(m));
+}
diff --git a/polygerrit-ui/app/utils/patch-set-util.ts b/polygerrit-ui/app/utils/patch-set-util.ts
index 138479c..ce5e5a4 100644
--- a/polygerrit-ui/app/utils/patch-set-util.ts
+++ b/polygerrit-ui/app/utils/patch-set-util.ts
@@ -8,7 +8,6 @@
   BasePatchSetNum,
   RevisionPatchSetNum,
 } from '../types/common';
-import {RestApiService} from '../services/gr-rest-api/gr-rest-api';
 import {EditRevisionInfo, ParsedChangeInfo} from '../types/types';
 import {check} from './common-util';
 
@@ -287,7 +286,9 @@
   return (Number(patchset) - 1) as BasePatchSetNum;
 }
 
-export function hasEditBasedOnCurrentPatchSet(allPatchSets: PatchSet[]) {
+export function hasEditBasedOnCurrentPatchSet(
+  allPatchSets: PatchSet[]
+): boolean {
   if (!allPatchSets || allPatchSets.length < 2) {
     return false;
   }
@@ -302,40 +303,6 @@
 }
 
 /**
- * Check whether there is no newer patch than the latest patch that was
- * available when this change was loaded.
- *
- * @return A promise that yields true if the latest patch
- *     has been loaded, and false if a newer patch has been uploaded in the
- *     meantime. The promise is rejected on network error.
- */
-export function fetchChangeUpdates(
-  change: ChangeInfo | ParsedChangeInfo,
-  restAPI: RestApiService
-) {
-  const knownLatest = computeLatestPatchNum(computeAllPatchSets(change));
-  return restAPI.getChangeDetail(change._number).then(detail => {
-    if (!detail) {
-      const error = new Error('Change detail not found.');
-      return Promise.reject(error);
-    }
-    const actualLatest = computeLatestPatchNum(computeAllPatchSets(detail));
-    if (!actualLatest || !knownLatest) {
-      const error = new Error('Unable to check for latest patchset.');
-      return Promise.reject(error);
-    }
-    return {
-      isLatest: actualLatest <= knownLatest,
-      newStatus: change.status !== detail.status ? detail.status : null,
-      newMessages:
-        (change.messages || []).length < (detail.messages || []).length
-          ? detail.messages![detail.messages!.length - 1]
-          : undefined,
-    };
-  });
-}
-
-/**
  * @param revisions A sorted array of revisions.
  *
  * @return the index of the revision with the given patchNum.
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.js b/polygerrit-ui/app/utils/patch-set-util_test.js
deleted file mode 100644
index eefdcda..0000000
--- a/polygerrit-ui/app/utils/patch-set-util_test.js
+++ /dev/null
@@ -1,328 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {
-  _testOnly_computeWipForPatchSets, computeAllPatchSets,
-  fetchChangeUpdates, findEditParentPatchNum, findEditParentRevision,
-  getParentIndex, getRevisionByPatchNum,
-  isMergeParent,
-  sortRevisions,
-} from './patch-set-util.js';
-
-suite('gr-patch-set-util tests', () => {
-  test('getRevisionByPatchNum', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.deepEqual(getRevisionByPatchNum(revisions, 1), revisions[1]);
-    assert.deepEqual(getRevisionByPatchNum(revisions, 2), revisions[2]);
-    assert.equal(getRevisionByPatchNum(revisions, 3), undefined);
-  });
-
-  test('fetchChangeUpdates on latest', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(knownChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isNotOk(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates not on latest', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-        sha3: {description: 'patch 3', _number: 3},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isFalse(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.isNotOk(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates new status', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'MERGED',
-      messages: [],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.equal(result.newStatus, 'MERGED');
-          assert.isNotOk(result.newMessages);
-          done();
-        });
-  });
-
-  test('fetchChangeUpdates new messages', done => {
-    const knownChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [],
-    };
-    const actualChange = {
-      revisions: {
-        sha1: {description: 'patch 1', _number: 1},
-        sha2: {description: 'patch 2', _number: 2},
-      },
-      status: 'NEW',
-      messages: [{message: 'blah blah'}],
-    };
-    const mockRestApi = {
-      getChangeDetail() {
-        return Promise.resolve(actualChange);
-      },
-    };
-    fetchChangeUpdates(knownChange, mockRestApi)
-        .then(result => {
-          assert.isTrue(result.isLatest);
-          assert.isNotOk(result.newStatus);
-          assert.deepEqual(result.newMessages, {message: 'blah blah'});
-          done();
-        });
-  });
-
-  test('_computeWipForPatchSets', () => {
-    // Compute patch sets for a given timeline on a change. The initial WIP
-    // property of the change can be true or false. The map of tags by
-    // revision is keyed by patch set number. Each value is a list of change
-    // message tags in the order that they occurred in the timeline. These
-    // indicate actions that modify the WIP property of the change and/or
-    // create new patch sets.
-    //
-    // Returns the actual results with an assertWip method that can be used
-    // to compare against an expected value for a particular patch set.
-    const compute = (initialWip, tagsByRevision) => {
-      const change = {
-        messages: [],
-        work_in_progress: initialWip,
-      };
-      const revs = Object.keys(tagsByRevision).sort((a, b) => a - b);
-      for (const rev of revs) {
-        for (const tag of tagsByRevision[rev]) {
-          change.messages.push({
-            tag,
-            _revision_number: rev,
-          });
-        }
-      }
-      let patchNums = revs.map(rev => { return {num: rev}; });
-      patchNums = _testOnly_computeWipForPatchSets(
-          change, patchNums);
-      const actualWipsByRevision = {};
-      for (const patchNum of patchNums) {
-        actualWipsByRevision[patchNum.num] = patchNum.wip;
-      }
-      const verifier = {
-        assertWip(revision, expectedWip) {
-          const patchNum = patchNums.find(patchNum => patchNum.num == revision);
-          if (!patchNum) {
-            assert.fail('revision ' + revision + ' not found');
-          }
-          assert.equal(patchNum.wip, expectedWip,
-              'wip state for ' + revision + ' is ' +
-            patchNum.wip + '; expected ' + expectedWip);
-          return verifier;
-        },
-      };
-      return verifier;
-    };
-
-    compute(false, {1: ['upload']}).assertWip(1, false);
-    compute(true, {1: ['upload']}).assertWip(1, true);
-
-    const setWip = 'autogenerated:gerrit:setWorkInProgress';
-    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
-    const clearWip = 'autogenerated:gerrit:setReadyForReview';
-
-    compute(false, {
-      1: ['upload', setWip],
-      2: ['upload'],
-      3: ['upload', clearWip],
-      4: ['upload', setWip],
-    }).assertWip(1, false) // Change was created with PS1 ready for review
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review after upload
-        .assertWip(4, false); // PS4 was uploaded ready for review
-
-    compute(false, {
-      1: [uploadInWip, null, 'addReviewer'],
-      2: ['upload'],
-      3: ['upload', clearWip, setWip],
-      4: ['upload'],
-      5: ['upload', clearWip],
-      6: [uploadInWip],
-    }).assertWip(1, true) // Change was created in WIP
-        .assertWip(2, true) // PS2 was uploaded during WIP
-        .assertWip(3, false) // PS3 was marked ready for review
-        .assertWip(4, true) // PS4 was uploaded during WIP
-        .assertWip(5, false) // PS5 was marked ready for review
-        .assertWip(6, true); // PS6 was uploaded with WIP option
-  });
-
-  test('isMergeParent', () => {
-    assert.isFalse(isMergeParent(1));
-    assert.isFalse(isMergeParent(4321));
-    assert.isFalse(isMergeParent('52'));
-    assert.isFalse(isMergeParent('edit'));
-    assert.isFalse(isMergeParent('PARENT'));
-    assert.isFalse(isMergeParent(0));
-
-    assert.isTrue(isMergeParent(-23));
-    assert.isTrue(isMergeParent(-1));
-    assert.isTrue(isMergeParent('-42'));
-  });
-
-  test('findEditParentRevision', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
-    assert.strictEqual(findEditParentRevision(revisions), null);
-
-    revisions = [...revisions, {_number: 3}];
-    assert.deepEqual(findEditParentRevision(revisions), {_number: 3});
-  });
-
-  test('findEditParentPatchNum', () => {
-    let revisions = [
-      {_number: 0},
-      {_number: 1},
-      {_number: 2},
-    ];
-    assert.equal(findEditParentPatchNum(revisions), -1);
-
-    revisions =
-        [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
-    assert.deepEqual(findEditParentPatchNum(revisions), 3);
-  });
-
-  test('sortRevisions', () => {
-    const revisions = [
-      {_number: 0},
-      {_number: 2},
-      {_number: 1},
-    ];
-    const sorted = [
-      {_number: 2},
-      {_number: 1},
-      {_number: 0},
-    ];
-
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    // Edit patchset should follow directly after its basePatchNum.
-    revisions.push({_number: 'edit', basePatchNum: 2});
-    sorted.unshift({_number: 'edit', basePatchNum: 2});
-    assert.deepEqual(sortRevisions(revisions), sorted);
-
-    revisions[0].basePatchNum = 0;
-    const edit = sorted.shift();
-    edit.basePatchNum = 0;
-    // Edit patchset should be at index 2.
-    sorted.splice(2, 0, edit);
-    assert.deepEqual(sortRevisions(revisions), sorted);
-  });
-
-  test('getParentIndex', () => {
-    assert.equal(getParentIndex('-13'), 13);
-    assert.equal(getParentIndex(-4), 4);
-  });
-
-  test('computeAllPatchSets', () => {
-    const expected = [
-      {num: 4, desc: 'test', sha: 'rev4'},
-      {num: 3, desc: 'test', sha: 'rev3'},
-      {num: 2, desc: 'test', sha: 'rev2'},
-      {num: 1, desc: 'test', sha: 'rev1'},
-    ];
-    const patchNums = computeAllPatchSets({
-      revisions: {
-        rev3: {_number: 3, description: 'test', date: 3},
-        rev1: {_number: 1, description: 'test', date: 1},
-        rev4: {_number: 4, description: 'test', date: 4},
-        rev2: {_number: 2, description: 'test', date: 2},
-      },
-    });
-    assert.equal(patchNums.length, expected.length);
-    for (let i = 0; i < expected.length; i++) {
-      assert.deepEqual(patchNums[i], expected[i]);
-    }
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/patch-set-util_test.ts b/polygerrit-ui/app/utils/patch-set-util_test.ts
new file mode 100644
index 0000000..a9d9549
--- /dev/null
+++ b/polygerrit-ui/app/utils/patch-set-util_test.ts
@@ -0,0 +1,246 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {
+  createChange,
+  createChangeMessageInfo,
+  createRevision,
+} from '../test/test-data-generators';
+import {
+  BasePatchSetNum,
+  ChangeInfo,
+  EditPatchSetNum,
+  PatchSetNum,
+  ReviewInputTag,
+} from '../types/common';
+import {
+  _testOnly_computeWipForPatchSets,
+  computeAllPatchSets,
+  findEditParentPatchNum,
+  findEditParentRevision,
+  getParentIndex,
+  getRevisionByPatchNum,
+  isMergeParent,
+  sortRevisions,
+} from './patch-set-util';
+
+suite('gr-patch-set-util tests', () => {
+  test('getRevisionByPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 1 as PatchSetNum),
+      revisions[1]
+    );
+    assert.deepEqual(
+      getRevisionByPatchNum(revisions, 2 as PatchSetNum),
+      revisions[2]
+    );
+    assert.equal(getRevisionByPatchNum(revisions, 3 as PatchSetNum), undefined);
+  });
+
+  test('_computeWipForPatchSets', () => {
+    // Compute patch sets for a given timeline on a change. The initial WIP
+    // property of the change can be true or false. The map of tags by
+    // revision is keyed by patch set number. Each value is a list of change
+    // message tags in the order that they occurred in the timeline. These
+    // indicate actions that modify the WIP property of the change and/or
+    // create new patch sets.
+    //
+    // Returns the actual results with an assertWip method that can be used
+    // to compare against an expected value for a particular patch set.
+    const compute = (
+      initialWip: boolean,
+      tagsByRevision: Map<
+        number | 'edit' | 'PARENT',
+        (ReviewInputTag | undefined)[]
+      >
+    ) => {
+      const change: ChangeInfo = {
+        ...createChange(),
+        messages: [],
+        work_in_progress: initialWip,
+      };
+      for (const rev of tagsByRevision.keys()) {
+        for (const tag of tagsByRevision.get(rev)!) {
+          change.messages!.push({
+            ...createChangeMessageInfo(),
+            tag,
+            _revision_number: rev as PatchSetNum,
+          });
+        }
+      }
+      const patchSets = Array.from(tagsByRevision.keys()).map(rev => {
+        return {num: rev as PatchSetNum, desc: 'test', sha: `rev${rev}`};
+      });
+      const patchNums = _testOnly_computeWipForPatchSets(change, patchSets);
+      const verifier = {
+        assertWip(revision: number, expectedWip: boolean) {
+          const patchNum = patchNums.find(
+            patchNum => patchNum.num === (revision as PatchSetNum)
+          );
+          if (!patchNum) {
+            assert.fail(`revision ${revision} not found`);
+          }
+          assert.equal(
+            patchNum.wip,
+            expectedWip,
+            `wip state for ${revision} ` +
+              `is ${patchNum.wip}; expected ${expectedWip}`
+          );
+          return verifier;
+        },
+      };
+      return verifier;
+    };
+
+    const upload = 'upload' as ReviewInputTag;
+
+    compute(false, new Map([[1, [upload]]])).assertWip(1, false);
+    compute(true, new Map([[1, [upload]]])).assertWip(1, true);
+
+    const setWip = 'autogenerated:gerrit:setWorkInProgress' as ReviewInputTag;
+    const uploadInWip = 'autogenerated:gerrit:newWipPatchSet' as ReviewInputTag;
+    const clearWip = 'autogenerated:gerrit:setReadyForReview' as ReviewInputTag;
+
+    compute(
+      false,
+      new Map([
+        [1, [upload, setWip]],
+        [2, [upload]],
+        [3, [upload, clearWip]],
+        [4, [upload, setWip]],
+      ])
+    )
+      .assertWip(1, false) // Change was created with PS1 ready for review
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review after upload
+      .assertWip(4, false); // PS4 was uploaded ready for review
+
+    compute(
+      false,
+      new Map([
+        [1, [uploadInWip, undefined, 'addReviewer' as ReviewInputTag]],
+        [2, [upload]],
+        [3, [upload, clearWip, setWip]],
+        [4, [upload]],
+        [5, [upload, clearWip]],
+        [6, [uploadInWip]],
+      ])
+    )
+      .assertWip(1, true) // Change was created in WIP
+      .assertWip(2, true) // PS2 was uploaded during WIP
+      .assertWip(3, false) // PS3 was marked ready for review
+      .assertWip(4, true) // PS4 was uploaded during WIP
+      .assertWip(5, false) // PS5 was marked ready for review
+      .assertWip(6, true); // PS6 was uploaded with WIP option
+  });
+
+  test('isMergeParent', () => {
+    assert.isFalse(isMergeParent(1 as PatchSetNum));
+    assert.isFalse(isMergeParent(4321 as PatchSetNum));
+    assert.isFalse(isMergeParent('edit' as PatchSetNum));
+    assert.isFalse(isMergeParent('PARENT' as PatchSetNum));
+    assert.isFalse(isMergeParent(0 as PatchSetNum));
+
+    assert.isTrue(isMergeParent(-23 as PatchSetNum));
+    assert.isTrue(isMergeParent(-1 as PatchSetNum));
+  });
+
+  test('findEditParentRevision', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 3 as BasePatchSetNum,
+    });
+    assert.strictEqual(findEditParentRevision(revisions), null);
+
+    revisions.push(createRevision(3));
+    assert.deepEqual(findEditParentRevision(revisions), createRevision(3));
+  });
+
+  test('findEditParentPatchNum', () => {
+    const revisions = [createRevision(0), createRevision(1), createRevision(2)];
+    assert.equal(findEditParentPatchNum(revisions), -1);
+
+    revisions.push(
+      {
+        ...createRevision(),
+        _number: EditPatchSetNum,
+        basePatchNum: 3 as BasePatchSetNum,
+      },
+      createRevision(3)
+    );
+    assert.deepEqual(findEditParentPatchNum(revisions), 3);
+  });
+
+  test('sortRevisions', () => {
+    const revisions = [createRevision(0), createRevision(2), createRevision(1)];
+    const sorted = [createRevision(2), createRevision(1), createRevision(0)];
+
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    // Edit patchset should follow directly after its basePatchNum.
+    revisions.push({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    sorted.unshift({
+      ...createRevision(),
+      _number: EditPatchSetNum,
+      basePatchNum: 2 as BasePatchSetNum,
+    });
+    assert.deepEqual(sortRevisions(revisions), sorted);
+
+    revisions[0].basePatchNum = 0 as BasePatchSetNum;
+    const edit = sorted.shift()!;
+    edit.basePatchNum = 0 as BasePatchSetNum;
+    // Edit patchset should be at index 2.
+    sorted.splice(2, 0, edit);
+    assert.deepEqual(sortRevisions(revisions), sorted);
+  });
+
+  test('getParentIndex', () => {
+    assert.equal(getParentIndex(-4 as PatchSetNum), 4);
+  });
+
+  test('computeAllPatchSets', () => {
+    const expected = [
+      {num: 4 as PatchSetNum, desc: 'test', sha: 'rev4'},
+      {num: 3 as PatchSetNum, desc: 'test', sha: 'rev3'},
+      {num: 2 as PatchSetNum, desc: 'test', sha: 'rev2'},
+      {num: 1 as PatchSetNum, desc: 'test', sha: 'rev1'},
+    ];
+    const patchNums = computeAllPatchSets({
+      ...createChange(),
+      revisions: {
+        rev1: {...createRevision(1), description: 'test'},
+        rev2: {...createRevision(2), description: 'test'},
+        rev3: {...createRevision(3), description: 'test'},
+        rev4: {...createRevision(4), description: 'test'},
+      },
+    });
+    assert.equal(patchNums.length, expected.length);
+    for (let i = 0; i < expected.length; i++) {
+      assert.deepEqual(patchNums[i], expected[i]);
+    }
+  });
+});
diff --git a/polygerrit-ui/app/utils/path-list-util.ts b/polygerrit-ui/app/utils/path-list-util.ts
index dda6031..411421e 100644
--- a/polygerrit-ui/app/utils/path-list-util.ts
+++ b/polygerrit-ui/app/utils/path-list-util.ts
@@ -64,6 +64,8 @@
   return file === SpecialFilePath.PATCHSET_LEVEL_COMMENTS;
 }
 
+// In case there are files with comments on them but they are unchanged, then
+// we explicitly displays the file to render the comments with Unchanged status
 export function addUnmodifiedFiles(
   files: {[filename: string]: FileInfo},
   commentedPaths: {[fileName: string]: boolean}
@@ -73,6 +75,18 @@
     if (hasOwnProperty(files, commentedPath) || shouldHideFile(commentedPath)) {
       return;
     }
+
+    // if file is Renamed but has comments, then do not show the entry for the
+    // old file path name
+    if (
+      Object.values(files).some(
+        file =>
+          file.status === FileInfoStatus.RENAMED &&
+          file.old_path === commentedPath
+      )
+    ) {
+      return;
+    }
     // TODO(TS): either change FileInfo to mark delta and size optional
     // or fill in 0 here
     files[commentedPath] = {
@@ -81,13 +95,13 @@
   });
 }
 
-export function computeDisplayPath(path: string) {
+export function computeDisplayPath(path?: string) {
   if (path === SpecialFilePath.COMMIT_MESSAGE) {
     return 'Commit message';
   } else if (path === SpecialFilePath.MERGE_LIST) {
     return 'Merge list';
   }
-  return path;
+  return path ?? '';
 }
 
 export function isMagicPath(path?: string) {
diff --git a/polygerrit-ui/app/utils/path-list-util_test.js b/polygerrit-ui/app/utils/path-list-util_test.js
deleted file mode 100644
index 4d06344..0000000
--- a/polygerrit-ui/app/utils/path-list-util_test.js
+++ /dev/null
@@ -1,161 +0,0 @@
-/**
- * @license
- * Copyright (C) 2016 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import '../test/common-test-setup-karma.js';
-import {SpecialFilePath} from '../constants/constants.js';
-import {
-  addUnmodifiedFiles,
-  computeDisplayPath,
-  isMagicPath,
-  specialFilePathCompare, truncatePath,
-} from './path-list-util.js';
-
-suite('path-list-utl tests', () => {
-  test('special sort', () => {
-    const testFiles = [
-      '/a.h',
-      '/MERGE_LIST',
-      '/a.cpp',
-      '/COMMIT_MSG',
-      '/asdasd',
-      '/mrPeanutbutter.py',
-    ];
-    assert.deepEqual(
-        testFiles.sort(specialFilePathCompare),
-        [
-          '/COMMIT_MSG',
-          '/MERGE_LIST',
-          '/a.h',
-          '/a.cpp',
-          '/asdasd',
-          '/mrPeanutbutter.py',
-        ]);
-  });
-
-  test('special file path sorting', () => {
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.a', '.b', 'file']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
-            specialFilePathCompare),
-        ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']);
-
-    assert.deepEqual(
-        ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(
-            specialFilePathCompare),
-        ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
-
-    // Regression test for Issue 4448.
-    assert.deepEqual(
-        [
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_thread_writer.cc',
-          'minidump/minidump_thread_writer.h',
-        ].sort(specialFilePathCompare),
-        [
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_thread_writer.h',
-          'minidump/minidump_thread_writer.cc',
-        ]);
-
-    // Regression test for Issue 4545.
-    assert.deepEqual(
-        [
-          'task_test.go',
-          'task.go',
-        ].sort(specialFilePathCompare),
-        [
-          'task.go',
-          'task_test.go',
-        ]);
-  });
-
-  test('file display name', () => {
-    assert.equal(computeDisplayPath('/foo/bar/baz'), '/foo/bar/baz');
-    assert.equal(computeDisplayPath('/foobarbaz'), '/foobarbaz');
-    assert.equal(computeDisplayPath('/COMMIT_MSG'), 'Commit message');
-    assert.equal(computeDisplayPath('/MERGE_LIST'), 'Merge list');
-  });
-
-  test('isMagicPath', () => {
-    assert.isFalse(isMagicPath(undefined));
-    assert.isFalse(isMagicPath('/foo.cc'));
-    assert.isTrue(isMagicPath('/COMMIT_MSG'));
-    assert.isTrue(isMagicPath('/MERGE_LIST'));
-  });
-
-  test('patchset level comments are hidden', () => {
-    const commentedPaths = {
-      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: true,
-      'file1.txt': true,
-    };
-
-    const files = {'file2.txt': {status: 'M'}};
-    addUnmodifiedFiles(files, commentedPaths);
-    assert.equal(files['file1.txt'].status, 'U');
-    assert.equal(files['file2.txt'].status, 'M');
-    assert.isFalse(files.hasOwnProperty(
-        SpecialFilePath.PATCHSET_LEVEL_COMMENTS));
-  });
-
-  test('truncatePath with long path should add ellipsis', () => {
-    let path = 'level1/level2/level3/level4/file.js';
-    let shortenedPath = truncatePath(path);
-    // The expected path is truncated with an ellipsis.
-    const expectedPath = '\u2026/file.js';
-    assert.equal(shortenedPath, expectedPath);
-
-    path = 'level2/file.js';
-    shortenedPath = truncatePath(path);
-    assert.equal(shortenedPath, expectedPath);
-  });
-
-  test('truncatePath with opt_threshold', () => {
-    let path = 'level1/level2/level3/level4/file.js';
-    let shortenedPath = truncatePath(path, 2);
-    // The expected path is truncated with an ellipsis.
-    const expectedPath = '\u2026/level4/file.js';
-    assert.equal(shortenedPath, expectedPath);
-
-    path = 'level2/file.js';
-    shortenedPath = truncatePath(path, 2);
-    assert.equal(shortenedPath, path);
-  });
-
-  test('truncatePath with short path should not add ellipsis', () => {
-    const path = 'file.js';
-    const expectedPath = 'file.js';
-    const shortenedPath = truncatePath(path);
-    assert.equal(shortenedPath, expectedPath);
-  });
-});
-
diff --git a/polygerrit-ui/app/utils/path-list-util_test.ts b/polygerrit-ui/app/utils/path-list-util_test.ts
new file mode 100644
index 0000000..79b5f09
--- /dev/null
+++ b/polygerrit-ui/app/utils/path-list-util_test.ts
@@ -0,0 +1,170 @@
+/**
+ * @license
+ * Copyright (C) 2016 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../test/common-test-setup-karma';
+import {FileInfoStatus, SpecialFilePath} from '../constants/constants';
+import {
+  addUnmodifiedFiles,
+  computeDisplayPath,
+  isMagicPath,
+  specialFilePathCompare,
+  truncatePath,
+} from './path-list-util';
+import {FileInfo} from '../api/rest-api';
+import {hasOwnProperty} from './common-util';
+
+suite('path-list-utl tests', () => {
+  test('special sort', () => {
+    const testFiles = [
+      '/a.h',
+      '/MERGE_LIST',
+      '/a.cpp',
+      '/COMMIT_MSG',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ];
+    assert.deepEqual(testFiles.sort(specialFilePathCompare), [
+      '/COMMIT_MSG',
+      '/MERGE_LIST',
+      '/a.h',
+      '/a.cpp',
+      '/asdasd',
+      '/mrPeanutbutter.py',
+    ]);
+  });
+
+  test('special file path sorting', () => {
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', '.a', 'file'].sort(specialFilePathCompare),
+      ['/COMMIT_MSG', '.a', '.b', 'file']
+    );
+
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.h'].sort(
+        specialFilePathCompare
+      ),
+      ['/COMMIT_MSG', '.b', 'foo/bar/baz.h', 'foo/bar/baz.cc']
+    );
+
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hpp'].sort(
+        specialFilePathCompare
+      ),
+      ['/COMMIT_MSG', '.b', 'foo/bar/baz.hpp', 'foo/bar/baz.cc']
+    );
+
+    assert.deepEqual(
+      ['.b', '/COMMIT_MSG', 'foo/bar/baz.cc', 'foo/bar/baz.hxx'].sort(
+        specialFilePathCompare
+      ),
+      ['/COMMIT_MSG', '.b', 'foo/bar/baz.hxx', 'foo/bar/baz.cc']
+    );
+
+    assert.deepEqual(
+      ['foo/bar.h', 'foo/bar.hxx', 'foo/bar.hpp'].sort(specialFilePathCompare),
+      ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']
+    );
+
+    // Regression test for Issue 4448.
+    assert.deepEqual(
+      [
+        'minidump/minidump_memory_writer.cc',
+        'minidump/minidump_memory_writer.h',
+        'minidump/minidump_thread_writer.cc',
+        'minidump/minidump_thread_writer.h',
+      ].sort(specialFilePathCompare),
+      [
+        'minidump/minidump_memory_writer.h',
+        'minidump/minidump_memory_writer.cc',
+        'minidump/minidump_thread_writer.h',
+        'minidump/minidump_thread_writer.cc',
+      ]
+    );
+
+    // Regression test for Issue 4545.
+    assert.deepEqual(['task_test.go', 'task.go'].sort(specialFilePathCompare), [
+      'task.go',
+      'task_test.go',
+    ]);
+  });
+
+  test('file display name', () => {
+    assert.equal(computeDisplayPath('/foo/bar/baz'), '/foo/bar/baz');
+    assert.equal(computeDisplayPath('/foobarbaz'), '/foobarbaz');
+    assert.equal(computeDisplayPath('/COMMIT_MSG'), 'Commit message');
+    assert.equal(computeDisplayPath('/MERGE_LIST'), 'Merge list');
+  });
+
+  test('isMagicPath', () => {
+    assert.isFalse(isMagicPath(undefined));
+    assert.isFalse(isMagicPath('/foo.cc'));
+    assert.isTrue(isMagicPath('/COMMIT_MSG'));
+    assert.isTrue(isMagicPath('/MERGE_LIST'));
+  });
+
+  test('patchset level comments are hidden', () => {
+    const commentedPaths = {
+      [SpecialFilePath.PATCHSET_LEVEL_COMMENTS]: true,
+      'file1.txt': true,
+    };
+
+    const files: {[filename: string]: FileInfo} = {
+      'file2.txt': {
+        status: FileInfoStatus.REWRITTEN,
+        size_delta: 10,
+        size: 10,
+      },
+    };
+    addUnmodifiedFiles(files, commentedPaths);
+    assert.equal(files['file1.txt'].status, FileInfoStatus.UNMODIFIED);
+    assert.equal(files['file2.txt'].status, FileInfoStatus.REWRITTEN);
+    assert.isFalse(
+      hasOwnProperty(files, SpecialFilePath.PATCHSET_LEVEL_COMMENTS)
+    );
+  });
+
+  test('truncatePath with long path should add ellipsis', () => {
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+
+  test('truncatePath with opt_threshold', () => {
+    let path = 'level1/level2/level3/level4/file.js';
+    let shortenedPath = truncatePath(path, 2);
+    // The expected path is truncated with an ellipsis.
+    const expectedPath = '\u2026/level4/file.js';
+    assert.equal(shortenedPath, expectedPath);
+
+    path = 'level2/file.js';
+    shortenedPath = truncatePath(path, 2);
+    assert.equal(shortenedPath, path);
+  });
+
+  test('truncatePath with short path should not add ellipsis', () => {
+    const path = 'file.js';
+    const expectedPath = 'file.js';
+    const shortenedPath = truncatePath(path);
+    assert.equal(shortenedPath, expectedPath);
+  });
+});
diff --git a/polygerrit-ui/app/utils/safe-types-util_test.js b/polygerrit-ui/app/utils/safe-types-util_test.ts
similarity index 83%
rename from polygerrit-ui/app/utils/safe-types-util_test.js
rename to polygerrit-ui/app/utils/safe-types-util_test.ts
index e3968d0..03253e0 100644
--- a/polygerrit-ui/app/utils/safe-types-util_test.js
+++ b/polygerrit-ui/app/utils/safe-types-util_test.ts
@@ -15,12 +15,12 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
-import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util.js';
+import '../test/common-test-setup-karma';
+import {safeTypesBridge, _testOnly_SafeUrl} from './safe-types-util';
 
 suite('safe-types-util tests', () => {
   test('SafeUrl accepts valid urls', () => {
-    function accepts(url) {
+    function accepts(url: string) {
       const safeUrl = new _testOnly_SafeUrl(url);
       assert.isOk(safeUrl);
       assert.equal(url, safeUrl.toString());
@@ -35,8 +35,10 @@
   });
 
   test('SafeUrl rejects invalid urls', () => {
-    function rejects(url) {
-      assert.throws(() => { new _testOnly_SafeUrl(url); });
+    function rejects(url: string) {
+      assert.throws(() => {
+        new _testOnly_SafeUrl(url);
+      });
     }
     rejects('javascript://alert("evil");');
     rejects('ftp:example.com');
@@ -44,13 +46,14 @@
   });
 
   suite('safeTypesBridge', () => {
-    function acceptsString(value, type) {
-      assert.equal(safeTypesBridge(value, type),
-          value);
+    function acceptsString(value: string, type: string) {
+      assert.equal(safeTypesBridge(value, type), value);
     }
 
-    function rejects(value, type) {
-      assert.throws(() => { safeTypesBridge(value, type); });
+    function rejects(value: unknown, type: string) {
+      assert.throws(() => {
+        safeTypesBridge(value, type);
+      });
     }
 
     test('accepts valid URL strings', () => {
diff --git a/polygerrit-ui/app/utils/string-util.ts b/polygerrit-ui/app/utils/string-util.ts
index 6aae67f..43c0765 100644
--- a/polygerrit-ui/app/utils/string-util.ts
+++ b/polygerrit-ui/app/utils/string-util.ts
@@ -30,3 +30,11 @@
 export function charsOnly(s: string): string {
   return s.replace(/[^a-zA-Z]+/g, '');
 }
+
+export function ordinal(n?: number): string {
+  if (n === undefined) return '';
+  if (n % 10 === 1 && n % 100 !== 11) return `${n}st`;
+  if (n % 10 === 2 && n % 100 !== 12) return `${n}nd`;
+  if (n % 10 === 3 && n % 100 !== 13) return `${n}rd`;
+  return `${n}th`;
+}
diff --git a/polygerrit-ui/app/utils/string-util_test.ts b/polygerrit-ui/app/utils/string-util_test.ts
index 2eef50f..8de6ac2 100644
--- a/polygerrit-ui/app/utils/string-util_test.ts
+++ b/polygerrit-ui/app/utils/string-util_test.ts
@@ -16,7 +16,7 @@
  */
 
 import '../test/common-test-setup-karma';
-import {pluralize} from './string-util';
+import {pluralize, ordinal} from './string-util';
 
 suite('formatter util tests', () => {
   test('pluralize', () => {
@@ -25,4 +25,18 @@
     assert.equal(pluralize(1, noun), '1 comment');
     assert.equal(pluralize(2, noun), '2 comments');
   });
+
+  test('ordinal', () => {
+    assert.equal(ordinal(0), '0th');
+    assert.equal(ordinal(1), '1st');
+    assert.equal(ordinal(2), '2nd');
+    assert.equal(ordinal(3), '3rd');
+    assert.equal(ordinal(4), '4th');
+    assert.equal(ordinal(10), '10th');
+    assert.equal(ordinal(11), '11th');
+    assert.equal(ordinal(12), '12th');
+    assert.equal(ordinal(13), '13th');
+    assert.equal(ordinal(44413), '44413th');
+    assert.equal(ordinal(44451), '44451st');
+  });
 });
diff --git a/polygerrit-ui/app/utils/url-util.ts b/polygerrit-ui/app/utils/url-util.ts
index 4115062..de6462f 100644
--- a/polygerrit-ui/app/utils/url-util.ts
+++ b/polygerrit-ui/app/utils/url-util.ts
@@ -108,3 +108,10 @@
   const middle = paramString ? '?' : '';
   return pathname + middle + paramString;
 }
+
+/**
+ * Primary use case is to copy the absolute comments url to clipboard.
+ */
+export function generateAbsoluteUrl(url: string) {
+  return new URL(url, window.location.href).toString();
+}
diff --git a/polygerrit-ui/app/utils/url-util_test.js b/polygerrit-ui/app/utils/url-util_test.ts
similarity index 66%
rename from polygerrit-ui/app/utils/url-util_test.js
rename to polygerrit-ui/app/utils/url-util_test.ts
index 5cd4bb4..63dc81d 100644
--- a/polygerrit-ui/app/utils/url-util_test.js
+++ b/polygerrit-ui/app/utils/url-util_test.ts
@@ -15,7 +15,9 @@
  * limitations under the License.
  */
 
-import '../test/common-test-setup-karma.js';
+import {ServerInfo} from '../api/rest-api';
+import '../test/common-test-setup-karma';
+import {createGerritInfo, createServerInfo} from '../test/test-data-generators';
 import {
   getBaseUrl,
   getDocsBaseUrl,
@@ -25,11 +27,13 @@
   toPath,
   toPathname,
   toSearchParams,
-} from './url-util.js';
+} from './url-util';
+import {appContext} from '../services/app-context';
+import {stubRestApi} from '../test/test-utils';
 
 suite('url-util tests', () => {
   suite('getBaseUrl tests', () => {
-    let originalCanonicalPath;
+    let originalCanonicalPath: string | undefined;
 
     suiteSetup(() => {
       originalCanonicalPath = window.CANONICAL_PATH;
@@ -51,43 +55,50 @@
     });
 
     test('null config', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
-      };
-      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
-      assert.isTrue(
-          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      const probePathMock = stubRestApi('probePath').resolves(true);
+      const docsBaseUrl = await getDocsBaseUrl(
+        undefined,
+        appContext.restApiService
+      );
+      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
       assert.equal(docsBaseUrl, '/Documentation');
     });
 
     test('no doc config', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
+      const probePathMock = stubRestApi('probePath').resolves(true);
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        gerrit: createGerritInfo(),
       };
-      const config = {gerrit: {}};
-      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
-      assert.isTrue(
-          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      const docsBaseUrl = await getDocsBaseUrl(
+        config,
+        appContext.restApiService
+      );
+      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
       assert.equal(docsBaseUrl, '/Documentation');
     });
 
     test('has doc config', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(true)),
+      const probePathMock = stubRestApi('probePath').resolves(true);
+      const config: ServerInfo = {
+        ...createServerInfo(),
+        gerrit: {...createGerritInfo(), doc_url: 'foobar'},
       };
-      const config = {gerrit: {doc_url: 'foobar'}};
-      const docsBaseUrl = await getDocsBaseUrl(config, mockRestApi);
-      assert.isFalse(mockRestApi.probePath.called);
+      const docsBaseUrl = await getDocsBaseUrl(
+        config,
+        appContext.restApiService
+      );
+      assert.isFalse(probePathMock.called);
       assert.equal(docsBaseUrl, 'foobar');
     });
 
     test('no probe', async () => {
-      const mockRestApi = {
-        probePath: sinon.stub().returns(Promise.resolve(false)),
-      };
-      const docsBaseUrl = await getDocsBaseUrl(null, mockRestApi);
-      assert.isTrue(
-          mockRestApi.probePath.calledWith('/Documentation/index.html'));
+      const probePathMock = stubRestApi('probePath').resolves(false);
+      const docsBaseUrl = await getDocsBaseUrl(
+        undefined,
+        appContext.restApiService
+      );
+      assert.isTrue(probePathMock.calledWith('/Documentation/index.html'));
       assert.isNotOk(docsBaseUrl);
     });
   });
@@ -144,7 +155,9 @@
     assert.equal(toPath('asdf', params), 'asdf');
     params.set('qwer', 'zxcv');
     assert.equal(toPath('asdf', params), 'asdf?qwer=zxcv');
-    assert.equal(toPath(toPathname('asdf?qwer=zxcv'),
-        toSearchParams('asdf?qwer=zxcv')), 'asdf?qwer=zxcv');
+    assert.equal(
+      toPath(toPathname('asdf?qwer=zxcv'), toSearchParams('asdf?qwer=zxcv')),
+      'asdf?qwer=zxcv'
+    );
   });
 });
diff --git a/polygerrit-ui/app/yarn.lock b/polygerrit-ui/app/yarn.lock
index 96597c9..d3b22eb 100644
--- a/polygerrit-ui/app/yarn.lock
+++ b/polygerrit-ui/app/yarn.lock
@@ -2,6 +2,26 @@
 # yarn lockfile v1
 
 
+"@lit/reactive-element@^1.0.0":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@lit/reactive-element/-/reactive-element-1.0.1.tgz#853cacd4d78d79059f33f66f8e7b0e5c34bee294"
+  integrity sha512-nSD5AA2AZkKuXuvGs8IK7K5ZczLAogfDd26zT9l6S7WzvqALdVWcW5vMUiTnZyj5SPcNwNNANj0koeV1ieqTFQ==
+
+"@mapbox/node-pre-gyp@^1.0.0":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.5.tgz#2a0b32fcb416fb3f2250fd24cb2a81421a4f5950"
+  integrity sha512-4srsKPXWlIxp5Vbqz5uLfBN+du2fJChBoYn/f2h991WLdk7jUvcSk/McVLSv/X+xQIPI8eGD5GjrnygdyHnhPA==
+  dependencies:
+    detect-libc "^1.0.3"
+    https-proxy-agent "^5.0.0"
+    make-dir "^3.1.0"
+    node-fetch "^2.6.1"
+    nopt "^5.0.0"
+    npmlog "^4.1.2"
+    rimraf "^3.0.2"
+    semver "^7.3.4"
+    tar "^6.1.0"
+
 "@polymer/decorators@^3.0.0":
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/@polymer/decorators/-/decorators-3.0.0.tgz#e4212ac976d9abd1210f560b6e1be4165c1c0183"
@@ -20,13 +40,13 @@
   integrity sha512-tx5TauYSmzsIvmSqepUPDYbs4/Ejz2XbZ1IkD7JEGqkdNUJlh+9KU85G56Tfdk/xjEZ8zorFfN09OSwiMrIQWA==
 
 "@polymer/iron-a11y-announcer@^3.0.0-pre.26", "@polymer/iron-a11y-announcer@^3.1.0":
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.1.0.tgz#3d3712a165070ed3cdfc39e54f95515c913c9613"
-  integrity sha512-lc5i4NKB8kSQHH0Hwu8WS3ym93m+J69OHJWSSBxwd17FI+h2wmgxDzeG9LI4ojMMck17/uc2pLe7g/UHt5/K/A==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-announcer/-/iron-a11y-announcer-3.2.0.tgz#d04b1301413d473336cc5797dfd97b3b36dd0cd7"
+  integrity sha512-We+hyaFHcg7Ke8ovsoxUpYEXFIJLHxMCDaLehTB4dELS+C+K0zMnGSiqQvb/YzGS+nSYpAfkQIyg1msOCdHMtA==
   dependencies:
     "@polymer/polymer" "^3.0.0"
 
-"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26", "@polymer/iron-a11y-keys-behavior@^3.0.1":
+"@polymer/iron-a11y-keys-behavior@^3.0.0-pre.26":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@polymer/iron-a11y-keys-behavior/-/iron-a11y-keys-behavior-3.0.1.tgz#2868ea72912d2007ffab4734684a91f5aac49b84"
   integrity sha512-lnrjKq3ysbBPT/74l0Fj0U9H9C35Tpw2C/tpJ8a+5g8Y3YJs1WSZYnEl1yOkw6sEyaxOq/1DkzH0+60gGu5/PQ==
@@ -270,6 +290,17 @@
     "@polymer/paper-styles" "^3.0.0-pre.26"
     "@polymer/polymer" "^3.3.1"
 
+"@polymer/paper-fab@^3.0.1":
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/@polymer/paper-fab/-/paper-fab-3.0.1.tgz#2636359e7fb70dd5a549ed92ba9b3bdb9ff86bf8"
+  integrity sha512-LO8ckgd72MnAtC1WiPd5CFR27WC/dEuY/lOIQuHYdEjwI62+iiV7Bmr7uoQ9wvvV71qMFdMIOyq/03KklsuAzw==
+  dependencies:
+    "@polymer/iron-flex-layout" "^3.0.0-pre.26"
+    "@polymer/iron-icon" "^3.0.0-pre.26"
+    "@polymer/paper-behaviors" "^3.0.0-pre.27"
+    "@polymer/paper-styles" "^3.0.0-pre.26"
+    "@polymer/polymer" "^3.0.0"
+
 "@polymer/paper-icon-button@^3.0.0-pre.26":
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/@polymer/paper-icon-button/-/paper-icon-button-3.0.2.tgz#a1254faadc2c8dd135ce1ae33bcc161a94c31f65"
@@ -384,15 +415,25 @@
   dependencies:
     "@webcomponents/shadycss" "^1.9.1"
 
+"@types/resemblejs@^3.2.0":
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/@types/resemblejs/-/resemblejs-3.2.1.tgz#f4d6dc1549184f6a8ac71f831547f055421ba926"
+  integrity sha512-PEAcjrHtLYqxhjkRrHVCyyQBk1A58aVlDkpmFpcSIejE+PNz9ovEJKSH8iyNOOBoDPNA1JBvaaBUYtFgEbFjiw==
+
 "@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==
+  version "0.1.6"
+  resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.6.tgz#d8e6c2f830e2650dc06fe74464472ff64b54a302"
+  integrity sha512-61IfTac0s9jvNtBCpyo86QeaN8qqpMGHdK0uGKCCIy2dt5/Yk84VduHIdWAcmkC5QvdkPL0p5eWYgUZtHKKUVg==
+
+"@types/trusted-types@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756"
+  integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==
 
 "@webcomponents/shadycss@^1.10.2", "@webcomponents/shadycss@^1.9.1":
-  version "1.10.2"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.10.2.tgz#40e03cab6dc5e12f199949ba2b79e02f183d1e7b"
-  integrity sha512-9Iseu8bRtecb0klvv+WXZOVZatsRkbaH7M97Z+f+Pt909R4lDfgUODAnra23DOZTpeMTAkVpf4m/FZztN7Ox1A==
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
+  integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
 "@webcomponents/webcomponentsjs@^1.3.3":
   version "1.3.3"
@@ -400,29 +441,337 @@
   integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
 
 "@webcomponents/webcomponentsjs@^2.0.3":
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.5.0.tgz#61b27785a6ad5bfd68fa018201fe418b118cb38d"
-  integrity sha512-C0l51MWQZ9kLzcxOZtniOMohpIFdCLZum7/TEHv3XWFc1Fvt5HCpbSX84x8ltka/JuNKcuiDnxXFkiB2gaePcg==
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.6.0.tgz#7d1674c40bddf0c6dd974c44ffd34512fe7274ff"
+  integrity sha512-Moog+Smx3ORTbWwuPqoclr+uvfLnciVd6wdCaVscHPrxbmQ/IJKm3wbB7hpzJtXWjAq2l/6QMlO85aZiOdtv5Q==
 
-"ba-linkify@file:../../lib/ba-linkify/src":
+abbrev@1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
+  integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
+
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
+ansi-regex@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
+  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
+
+ansi-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
+  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
+
+aproba@^1.0.3:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
+  integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
+
+are-we-there-yet@~1.1.2:
+  version "1.1.7"
+  resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.7.tgz#b15474a932adab4ff8a50d9adfa7e4e926f21146"
+  integrity sha512-nxwy40TuMiUGqMyRHgCSWZ9FM4VAoRP4xUYSTv5ImRog+h9yISPbVH7H8fASCIzYn9wlEv4zvFL7uKDMCFQm3g==
+  dependencies:
+    delegates "^1.0.0"
+    readable-stream "^2.0.6"
+
+ba-linkify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/ba-linkify/-/ba-linkify-1.0.1.tgz#664cf5744947c6e8611f1fbbaf7d9f315f982f4c"
+  integrity sha1-Zkz1dElHxuhhHx+7r32fMV+YL0w=
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+canvas@2.8.0:
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/canvas/-/canvas-2.8.0.tgz#f99ca7f25e6e26686661ffa4fec1239bbef74461"
+  integrity sha512-gLTi17X8WY9Cf5GZ2Yns8T5lfBOcGgFehDFb+JQwDqdOoBOcECS9ZWMEAqMSVcMYwXD659J8NyzjRY/2aE+C2Q==
+  dependencies:
+    "@mapbox/node-pre-gyp" "^1.0.0"
+    nan "^2.14.0"
+    simple-get "^3.0.3"
+
+chownr@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece"
+  integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==
+
+code-point-at@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
+  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
+
+codemirror-minified@^5.62.2:
+  version "5.63.0"
+  resolved "https://registry.yarnpkg.com/codemirror-minified/-/codemirror-minified-5.63.0.tgz#29d1a78713a633c933a27853679afdc0bfea49cc"
+  integrity sha512-dMN2w0Qg5Zwn2p7UW3sYAoyrJ+QRBkiF5bfbQAvQ1bfqhEjGnZ++/zvOG7NivfnUbYRhSULz8lsFtzt4ldBNyQ==
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+console-control-strings@^1.0.0, console-control-strings@~1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
+  integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
+
+core-util-is@~1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
+  integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
+
+debug@4:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
+  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
+  dependencies:
+    ms "2.1.2"
+
+decompress-response@^4.2.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-4.2.1.tgz#414023cc7a302da25ce2ec82d0d5238ccafd8986"
+  integrity sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==
+  dependencies:
+    mimic-response "^2.0.0"
+
+delegates@^1.0.0:
   version "1.0.0"
+  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
+  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
+
+detect-libc@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+
+fs-minipass@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
+  integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==
+  dependencies:
+    minipass "^3.0.0"
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+gauge@~2.7.3:
+  version "2.7.4"
+  resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
+  integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
+  dependencies:
+    aproba "^1.0.3"
+    console-control-strings "^1.0.0"
+    has-unicode "^2.0.0"
+    object-assign "^4.1.0"
+    signal-exit "^3.0.0"
+    string-width "^1.0.1"
+    strip-ansi "^3.0.1"
+    wide-align "^1.1.0"
+
+glob@^7.1.3:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
+  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+has-unicode@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
+  integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
+
+https-proxy-agent@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
+  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
+  dependencies:
+    agent-base "6"
+    debug "4"
+
+immer@^9.0.5:
+  version "9.0.6"
+  resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.6.tgz#7a96bf2674d06c8143e327cbf73539388ddf1a73"
+  integrity sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+is-fullwidth-code-point@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
+  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
+  dependencies:
+    number-is-nan "^1.0.0"
+
+is-fullwidth-code-point@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
+  integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
 isarray@0.0.1:
   version "0.0.1"
   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"
+isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
-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==
+lit-element@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/lit-element/-/lit-element-3.0.1.tgz#3c545af17d8a46268bc1dd5623a47486e6ff76f4"
+  integrity sha512-vs9uybH9ORyK49CFjoNGN85HM9h5bmisU4TQ63phe/+GYlwvY/3SIFYKdjV6xNvzz8v2MnVC+9+QOkPqh+Q3Ew==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-html "^2.0.0"
+
+lit-html@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/lit-html/-/lit-html-2.0.1.tgz#63241015efa07bc9259b6f96f04abd052d2a1f95"
+  integrity sha512-KF5znvFdXbxTYM/GjpdOOnMsjgRcFGusTnB54ixnCTya5zUR0XqrDRj29ybuLS+jLXv1jji6Y8+g4W7WP8uL4w==
+  dependencies:
+    "@types/trusted-types" "^2.0.2"
+
+lit@2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lit/-/lit-2.0.2.tgz#5e6f422924e0732258629fb379556b6d23f7179c"
+  integrity sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==
+  dependencies:
+    "@lit/reactive-element" "^1.0.0"
+    lit-element "^3.0.0"
+    lit-html "^2.0.0"
+
+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"
+
+make-dir@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
+mimic-response@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-2.1.0.tgz#d13763d35f613d09ec37ebb30bac0469c0ee8f43"
+  integrity sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==
+
+minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minipass@^3.0.0:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.5.tgz#71f6251b0a33a49c01b3cf97ff77eda030dff732"
+  integrity sha512-+8NzxD82XQoNKNrl1d/FSi+X8wAEWR+sbYAfIvub4Nz0d22plFG72CEVVaufV8PNf4qSslFTD8VMOxNVhHCjTw==
+  dependencies:
+    yallist "^4.0.0"
+
+minizlib@^2.1.1:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
+  integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
+  dependencies:
+    minipass "^3.0.0"
+    yallist "^4.0.0"
+
+mkdirp@^1.0.3:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+nan@^2.14.0:
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
+  integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
+
+node-fetch@^2.6.1:
+  version "2.6.5"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.5.tgz#42735537d7f080a7e5f78b6c549b7146be1742fd"
+  integrity sha512-mmlIVHJEu5rnIxgEgez6b9GgWXbkZj5YZ7fx+2r94a2E+Uirsp6HsPTPlomfdHtpt/B0cdKviwkoaM6pyvUOpQ==
+  dependencies:
+    whatwg-url "^5.0.0"
+
+nopt@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-5.0.0.tgz#530942bb58a512fccafe53fe210f13a25355dc88"
+  integrity sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==
+  dependencies:
+    abbrev "1"
+
+npmlog@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
+  integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
+  dependencies:
+    are-we-there-yet "~1.1.2"
+    console-control-strings "~1.1.0"
+    gauge "~2.7.3"
+    set-blocking "~2.0.0"
+
+number-is-nan@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
+  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
+
+object-assign@^4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+
+once@^1.3.0, once@^1.3.1:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
 
 page@^1.11.6:
   version "1.11.6"
@@ -431,6 +780,11 @@
   dependencies:
     path-to-regexp "~1.2.1"
 
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
 path-to-regexp@~1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.2.1.tgz#b33705c140234d873c8721c7b9fd8b541ed3aff9"
@@ -449,6 +803,38 @@
     "@polymer/polymer" "^3.0.2"
     "@webcomponents/webcomponentsjs" "^2.0.3"
 
+process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+readable-stream@^2.0.6:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+resemblejs@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/resemblejs/-/resemblejs-4.0.0.tgz#5382f0484430d826ed293433833b9fc4e06e5496"
+  integrity sha512-vaGs/hFVx/941+RS4UJtd8DQvx5RuB61tPLOQCxPso3JpmjfDb6odH5HViT17S0d8DaZsexD01nRJI12giCz/A==
+  optionalDependencies:
+    canvas "2.8.0"
+
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
 rxjs@^6.6.7:
   version "6.6.7"
   resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
@@ -456,7 +842,138 @@
   dependencies:
     tslib "^1.9.0"
 
+safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+semver@^6.0.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.3.4:
+  version "7.3.5"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
+  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
+  dependencies:
+    lru-cache "^6.0.0"
+
+set-blocking@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
+
+signal-exit@^3.0.0:
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.5.tgz#9e3e8cc0c75a99472b44321033a7702e7738252f"
+  integrity sha512-KWcOiKeQj6ZyXx7zq4YxSMgHRlod4czeBQZrPb8OKcohcqAXShm7E20kEMle9WBt26hFcAf0qLOcp5zmY7kOqQ==
+
+simple-concat@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/simple-concat/-/simple-concat-1.0.1.tgz#f46976082ba35c2263f1c8ab5edfe26c41c9552f"
+  integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==
+
+simple-get@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/simple-get/-/simple-get-3.1.0.tgz#b45be062435e50d159540b576202ceec40b9c6b3"
+  integrity sha512-bCR6cP+aTdScaQCnQKbPKtJOKDp/hj9EDLJo3Nw4y1QksqaovlW/bnptB6/c1e+qmNIDHRK+oXFDdEqBT8WzUA==
+  dependencies:
+    decompress-response "^4.2.0"
+    once "^1.3.1"
+    simple-concat "^1.0.0"
+
+string-width@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
+  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
+  dependencies:
+    code-point-at "^1.0.0"
+    is-fullwidth-code-point "^1.0.0"
+    strip-ansi "^3.0.0"
+
+"string-width@^1.0.2 || 2":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
+  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
+  dependencies:
+    is-fullwidth-code-point "^2.0.0"
+    strip-ansi "^4.0.0"
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-ansi@^3.0.0, strip-ansi@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
+  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
+  dependencies:
+    ansi-regex "^2.0.0"
+
+strip-ansi@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
+  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
+  dependencies:
+    ansi-regex "^3.0.0"
+
+tar@^6.1.0:
+  version "6.1.11"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
+  integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
+  dependencies:
+    chownr "^2.0.0"
+    fs-minipass "^2.0.0"
+    minipass "^3.0.0"
+    minizlib "^2.1.1"
+    mkdirp "^1.0.3"
+    yallist "^4.0.0"
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=
+
 tslib@^1.9.0:
   version "1.14.1"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha1-lmRU6HZUYuN2RNNib2dCzotwll0=
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
+wide-align@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
+  integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
+  dependencies:
+    string-width "^1.0.2 || 2"
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
+
+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==
diff --git a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md b/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
deleted file mode 100644
index 717f683..0000000
--- a/polygerrit-ui/edit-walkthrough/edit-walkthrough.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# In-browser Editing in Gerrit
-
-### What's going on?
-
-Until Q1 of 2018, editing a file in the browser was not supported by Gerrit's
-new UI. This feature is now done and ready for use.
-
-Read on for a walkthrough of the feature!
-
-### Creating an edit
-
-Click on the "Edit" button to begin.
-
-One may also go to the project mmanagement page (Browse => Repository =>
-Commands => Create Change) to create a new change.
-
-![](./img/into_edit.png)
-
-### Performing an action
-
-The buttons in the file list header open dialogs to perform actions on any file
-in the repo.
-
-*   Open - opens an existing or new file from the repo in an editor.
-*   Delete - deletes an existing file from the repo.
-*   Rename - renames an existing file in the repo.
-
-To leave edit mode and restore the normal buttons to the file list, click "Stop
-editing".
-
-![](./img/in_edit_mode.png)
-
-### Performing an action on a file
-
-The "Actions" dropdown appears on each file, and is used to perform actions on
-that specific file.
-
-*   Open - opens this file in the editor.
-*   Delete - deletes this file from the repo.
-*   Rename - renames this file in the repo.
-*   Restore - restores this file to the state it existed in at the patch the
-edit was created on.
-
-![](./img/actions_overflow.png)
-
-### Modifying the file
-
-This is the editor view.
-
-Clicking on the file path allows you to rename the file, You can edit code in
-the textarea, and "Close" will discard any unsaved changes and navigate back to
-the previous view.
-
-![](./img/in_editor.png)
-
-### Saving the edit
-
-You can save changes to the code with `cmd+s`, `ctrl+s`, or by clicking the
-"Save" button.
-
-![](./img/edit_made.png)
-
-### Publishing the edit
-
-You may publish or delete the edit by clicking the buttons in the header.
-
-
-
-![](./img/edit_pending.png)
-
-### What if I have questions not answered here?
-
-Gerrit's [official docs](https://gerrit-review.googlesource.com/Documentation/user-inline-edit.html)
-are in the process of being updated and largely refer to the old UI, but the
-user experience is largely the same.
-
-Otherwise, please email
-[the repo-discuss mailing list](mailto:repo-discuss@google.com) or file a bug
-on Gerrit's official bug tracker,
-[Monorail](https://bugs.chromium.org/p/gerrit/issues/entry?template=PolyGerrit+Issue).
\ No newline at end of file
diff --git a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png b/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
deleted file mode 100644
index bf39763..0000000
--- a/polygerrit-ui/edit-walkthrough/img/actions_overflow.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_made.png b/polygerrit-ui/edit-walkthrough/img/edit_made.png
deleted file mode 100644
index 658245d..0000000
--- a/polygerrit-ui/edit-walkthrough/img/edit_made.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/edit_pending.png b/polygerrit-ui/edit-walkthrough/img/edit_pending.png
deleted file mode 100644
index a63f6ee..0000000
--- a/polygerrit-ui/edit-walkthrough/img/edit_pending.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png b/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
deleted file mode 100644
index 582ed66..0000000
--- a/polygerrit-ui/edit-walkthrough/img/in_edit_mode.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/in_editor.png b/polygerrit-ui/edit-walkthrough/img/in_editor.png
deleted file mode 100644
index 228d020..0000000
--- a/polygerrit-ui/edit-walkthrough/img/in_editor.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/edit-walkthrough/img/into_edit.png b/polygerrit-ui/edit-walkthrough/img/into_edit.png
deleted file mode 100644
index b6c14ed..0000000
--- a/polygerrit-ui/edit-walkthrough/img/into_edit.png
+++ /dev/null
Binary files differ
diff --git a/polygerrit-ui/karma.conf.js b/polygerrit-ui/karma.conf.js
index fe3fa0c..a3b694f 100644
--- a/polygerrit-ui/karma.conf.js
+++ b/polygerrit-ui/karma.conf.js
@@ -22,6 +22,7 @@
   if(runUnderBazel) {
     // Run under bazel
     return [
+      `external/plugins_npm/node_modules`,
       `external/ui_npm/node_modules`,
       `external/ui_dev_npm/node_modules`
     ];
@@ -58,20 +59,34 @@
 }
 
 module.exports = function(config) {
-  const localDirName = path.resolve(__dirname, '../.ts-out/polygerrit-ui/app');
-  const rootDir = runUnderBazel ?
-      'polygerrit-ui/app/_pg_with_tests_out/' : localDirName + '/';
-  const testFilesLocationPattern =
-      `${rootDir}**/!(template_test_srcs)/`;
+  let root = config.root;
+  if (!root) {
+    console.warn(`--root argument not set. Falling back to __dirname.`)
+    root = path.resolve(__dirname) + '/';
+  }
   // Use --test-files to specify pattern for a test files.
   // It can be just a file name, without a path:
   // --test-files async-foreach-behavior_test.js
   // If you specify --test-files without pattern, it gets true value
-  // In this case we ill run all tests (usefull for package.json "debugtest"
+  // In this case we will run all tests (usefull for package.json "debugtest"
   // script)
-  const testFilesPattern = (typeof config.testFiles == 'string') ?
-      testFilesLocationPattern + config.testFiles :
-      testFilesLocationPattern + '*_test.js';
+  // We will convert a .ts argument to .js and fill in .js if no extension is
+  // given.
+  let filePattern;
+  if (typeof config.testFiles === "string") {
+    if (config.testFiles.endsWith('.ts')) {
+      filePattern = config.testFiles.substr(0, config.testFiles.lastIndexOf(".")) + ".js";
+    } else if (config.testFiles.endsWith('.js')) {
+      filePattern = config.testFiles;
+    } else {
+      filePattern = config.testFiles + '.js';
+    }
+  } else {
+    filePattern = '*_test.js';
+  }
+  const testFilesPattern = root + '**/' + filePattern;
+
+  console.info(`Karma test file pattern: ${testFilesPattern}`)
   // Special patch for grep parameters (see details in the grep-patch-karam.js)
   const additionalFiles = runUnderBazel ? [] : ['polygerrit-ui/grep-patch-karma.js'];
   config.set({
diff --git a/polygerrit-ui/karma_test.sh b/polygerrit-ui/karma_test.sh
index 5fab442..940b969 100755
--- a/polygerrit-ui/karma_test.sh
+++ b/polygerrit-ui/karma_test.sh
@@ -1,4 +1,6 @@
 #!/bin/bash
 
 set -euo pipefail
-./$1 start $2 --single-run
+./$1 start $2 \
+  --root 'polygerrit-ui/app/_pg_with_tests_out/**/' \
+  --test-files '*_test.js'
diff --git a/polygerrit-ui/package.json b/polygerrit-ui/package.json
index 3aa0c92..793703e 100644
--- a/polygerrit-ui/package.json
+++ b/polygerrit-ui/package.json
@@ -14,7 +14,7 @@
     "@polymer/test-fixture": "^4.0.2",
     "accessibility-developer-tools": "^2.12.0",
     "chai": "^4.3.4",
-    "karma": "^4.4.1",
+    "karma": "^6.3.4",
     "karma-chrome-launcher": "^3.1.0",
     "karma-mocha": "^2.0.1",
     "karma-mocha-reporter": "^2.2.5",
diff --git a/polygerrit-ui/run-server.sh b/polygerrit-ui/run-server.sh
index 64bca68..62e1453 100755
--- a/polygerrit-ui/run-server.sh
+++ b/polygerrit-ui/run-server.sh
@@ -13,8 +13,14 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+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
+
 set -eu
 SCRIPTNAME=$(mktemp)
 trap "{ rm -f $SCRIPTNAME; }" EXIT
-bazel run --script_path="$SCRIPTNAME" //polygerrit-ui:devserver
+${bazel_bin} run --script_path="$SCRIPTNAME" //polygerrit-ui:devserver
 "$SCRIPTNAME" "$@"
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index 2734f58..2a433fb 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -41,12 +41,11 @@
 )
 
 var (
-	plugins               = flag.String("plugins", "", "comma seperated plugin paths to serve")
-	port                  = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
-	host                  = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
-	scheme                = flag.String("scheme", "https", "URL scheme")
-	cdnPattern            = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
-	bundledPluginsPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_assets/[0-9.]*")
+	plugins    = flag.String("plugins", "", "comma seperated plugin paths to serve")
+	port       = flag.String("port", "localhost:8081", "address to serve HTTP requests on")
+	host       = flag.String("host", "gerrit-review.googlesource.com", "Host to proxy requests to")
+	scheme     = flag.String("scheme", "https", "URL scheme")
+	cdnPattern = regexp.MustCompile("https://cdn.googlesource.com/polygerrit_ui/[0-9.]*")
 )
 
 func main() {
@@ -121,8 +120,8 @@
 
 func addDevHeaders(writer http.ResponseWriter) {
 	writer.Header().Set("Access-Control-Allow-Origin", "*")
+	writer.Header().Set("Access-Control-Allow-Headers", "cache-control,x-test-origin")
 	writer.Header().Set("Cache-Control", "public, max-age=10, must-revalidate")
-
 }
 
 func handleSrcRequest(compiledSrcPath string, dirListingMux *http.ServeMux, writer http.ResponseWriter, originalRequest *http.Request) {
@@ -185,10 +184,10 @@
 		//   '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.*|export.* from )['"](.*?)(\.(m?)js)?['"];$`)
+		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^;\s]*?)(\.(m?)js)?['"];`)
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'$2.${4}js';"))
 
-		moduleImportRegexp = regexp.MustCompile(`(?m)^(import.*|export.* from )['"]([^/.].*)['"];$`)
+		moduleImportRegexp = regexp.MustCompile(`(import[^'";]*|export[^'";]*from ?)['"]([^/.;\s][^;\s]*)['"];`)
 		data = moduleImportRegexp.ReplaceAll(data, []byte("$1'/node_modules/$2';"))
 
 		// The es module version of rxjs can be found in the _esm2015/ directory.
@@ -199,9 +198,16 @@
 		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';"))
+		// 'lit.js' has to be resolved as 'lit/index.js'.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)lit.js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}lit/index.js';"))
+		// Some lit imports 'a.js' have to be resolved as 'a/a.js'.
+		moduleImportRegexp = regexp.MustCompile(`((import|export)[^'";]*'/node_modules/(@lit/)?)(lit-element|lit-html|reactive-element).js';`)
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}${4}/${4}.js';"))
+
+		// 'immer' imports and exports have to be resolved to 'immer/dist/immer.esm.js'.
+		moduleImportRegexp = regexp.MustCompile("(?m)^((import|export).*'/node_modules/)immer.js';$")
+		data = moduleImportRegexp.ReplaceAll(data, []byte("${1}/immer/dist/immer.esm.js';"))
 
 		if strings.HasSuffix(normalizedContentPath, "/node_modules/page/page.js") {
 			// Can't import page.js directly, because this is undefined.
@@ -394,9 +400,6 @@
 	// contains window.INITIAL_DATA=...
 	// Here we rely on the fact that the <script> snippet that we want to append to is the first one.
 	if len(*plugins) > 0 {
-		// If the host page contains a reference to a plugin bundle that would be preloaded, then remove it.
-		replaced = bundledPluginsPattern.ReplaceAllString(replaced, "")
-
 		insertionPoint := strings.Index(replaced, "</script>")
 		builder := new(strings.Builder)
 		builder.WriteString(
diff --git a/polygerrit-ui/yarn.lock b/polygerrit-ui/yarn.lock
index 271b295..7c7ef45 100644
--- a/polygerrit-ui/yarn.lock
+++ b/polygerrit-ui/yarn.lock
@@ -2,32 +2,32 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658"
-  integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==
+"@babel/code-frame@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
+  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
   dependencies:
-    "@babel/highlight" "^7.12.13"
+    "@babel/highlight" "^7.14.5"
 
-"@babel/compat-data@^7.13.0", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.8":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.12.tgz#a8a5ccac19c200f9dd49624cac6e19d7be1236a1"
-  integrity sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ==
+"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.14.7", "@babel/compat-data@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.15.0.tgz#2dbaf8b85334796cafbb0f5793a90a2fc010b176"
+  integrity sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==
 
 "@babel/core@^7.11.1":
-  version "7.13.14"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.14.tgz#8e46ebbaca460a63497c797e574038ab04ae6d06"
-  integrity sha512-wZso/vyF4ki0l0znlgM4inxbdrUvCb+cVz8grxDq+6C9k6qbqoIJteQOKicaKjCipU3ISV+XedCqpL2RJJVehA==
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.0.tgz#749e57c68778b73ad8082775561f67f5196aafa8"
+  integrity sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==
   dependencies:
-    "@babel/code-frame" "^7.12.13"
-    "@babel/generator" "^7.13.9"
-    "@babel/helper-compilation-targets" "^7.13.13"
-    "@babel/helper-module-transforms" "^7.13.14"
-    "@babel/helpers" "^7.13.10"
-    "@babel/parser" "^7.13.13"
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.13"
-    "@babel/types" "^7.13.14"
+    "@babel/code-frame" "^7.14.5"
+    "@babel/generator" "^7.15.0"
+    "@babel/helper-compilation-targets" "^7.15.0"
+    "@babel/helper-module-transforms" "^7.15.0"
+    "@babel/helpers" "^7.14.8"
+    "@babel/parser" "^7.15.0"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
     gensync "^1.0.0-beta.2"
@@ -35,63 +35,64 @@
     semver "^6.3.0"
     source-map "^0.5.0"
 
-"@babel/generator@^7.13.9", "@babel/generator@^7.4.0":
-  version "7.13.9"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.9.tgz#3a7aa96f9efb8e2be42d38d80e2ceb4c64d8de39"
-  integrity sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==
+"@babel/generator@^7.15.0", "@babel/generator@^7.4.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15"
+  integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==
   dependencies:
-    "@babel/types" "^7.13.0"
+    "@babel/types" "^7.15.0"
     jsesc "^2.5.1"
     source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz#0f58e86dfc4bb3b1fcd7db806570e177d439b6ab"
-  integrity sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==
+"@babel/helper-annotate-as-pure@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
+  integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
   dependencies:
-    "@babel/types" "^7.12.13"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz#6bc20361c88b0a74d05137a65cac8d3cbf6f61fc"
-  integrity sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191"
+  integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.12.13"
-    "@babel/types" "^7.12.13"
+    "@babel/helper-explode-assignable-expression" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.10", "@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz#2b2972a0926474853f41e4adbc69338f520600e5"
-  integrity sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==
+"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.15.0.tgz#973df8cbd025515f3ff25db0c05efc704fa79818"
+  integrity sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==
   dependencies:
-    "@babel/compat-data" "^7.13.12"
-    "@babel/helper-validator-option" "^7.12.17"
-    browserslist "^4.14.5"
+    "@babel/compat-data" "^7.15.0"
+    "@babel/helper-validator-option" "^7.14.5"
+    browserslist "^4.16.6"
     semver "^6.3.0"
 
-"@babel/helper-create-class-features-plugin@^7.13.0":
-  version "7.13.11"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.11.tgz#30d30a005bca2c953f5653fc25091a492177f4f6"
-  integrity sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw==
+"@babel/helper-create-class-features-plugin@^7.14.5":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.15.0.tgz#c9a137a4d137b2d0e2c649acf536d7ba1a76c0f7"
+  integrity sha512-MdmDXgvTIi4heDVX/e9EFfeGpugqm9fobBVg/iioE8kueXrOHdRDe36FAY7SnE9xXLVeYCoJR/gdrBEIHRC83Q==
   dependencies:
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-member-expression-to-functions" "^7.13.0"
-    "@babel/helper-optimise-call-expression" "^7.12.13"
-    "@babel/helper-replace-supers" "^7.13.0"
-    "@babel/helper-split-export-declaration" "^7.12.13"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-member-expression-to-functions" "^7.15.0"
+    "@babel/helper-optimise-call-expression" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.15.0"
+    "@babel/helper-split-export-declaration" "^7.14.5"
 
-"@babel/helper-create-regexp-features-plugin@^7.12.13":
-  version "7.12.17"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.17.tgz#a2ac87e9e319269ac655b8d4415e94d38d663cb7"
-  integrity sha512-p2VGmBu9oefLZ2nQpgnEnG0ZlRPvL8gAGvPUMQwUdaE8k49rOMuZpOwdQoy5qJf6K8jL3bcAMhVUlHAjIgJHUg==
+"@babel/helper-create-regexp-features-plugin@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4"
+  integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.12.13"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
     regexpu-core "^4.7.1"
 
-"@babel/helper-define-polyfill-provider@^0.1.5":
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.5.tgz#3c2f91b7971b9fc11fe779c945c014065dea340e"
-  integrity sha512-nXuzCSwlJ/WKr8qxzW816gwyT6VZgiJG17zR40fou70yfAcqjoNyTLl/DQ+FExw5Hx5KNqshmN8Ldl/r2N7cTg==
+"@babel/helper-define-polyfill-provider@^0.2.2":
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.3.tgz#0525edec5094653a282688d34d846e4c75e9c0b6"
+  integrity sha512-RH3QDAfRMzj7+0Nqu5oqgO5q9mFtQEVvCRsi8qCEfzLR9p2BHfn5FzhSB2oj1fF7I2+DcTORkYaQ6aTR9Cofew==
   dependencies:
     "@babel/helper-compilation-targets" "^7.13.0"
     "@babel/helper-module-imports" "^7.12.13"
@@ -102,277 +103,295 @@
     resolve "^1.14.2"
     semver "^6.1.2"
 
-"@babel/helper-explode-assignable-expression@^7.12.13":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.13.0.tgz#17b5c59ff473d9f956f40ef570cf3a76ca12657f"
-  integrity sha512-qS0peLTDP8kOisG1blKbaoBg/o9OSa1qoumMjTK5pM+KDTtpxpsiubnCGP34vK8BXGcb2M9eigwgvoJryrzwWA==
+"@babel/helper-explode-assignable-expression@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645"
+  integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==
   dependencies:
-    "@babel/types" "^7.13.0"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-function-name@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a"
-  integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==
+"@babel/helper-function-name@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4"
+  integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==
   dependencies:
-    "@babel/helper-get-function-arity" "^7.12.13"
-    "@babel/template" "^7.12.13"
-    "@babel/types" "^7.12.13"
+    "@babel/helper-get-function-arity" "^7.14.5"
+    "@babel/template" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-get-function-arity@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583"
-  integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==
+"@babel/helper-get-function-arity@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
+  integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==
   dependencies:
-    "@babel/types" "^7.12.13"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-hoist-variables@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.13.0.tgz#5d5882e855b5c5eda91e0cadc26c6e7a2c8593d8"
-  integrity sha512-0kBzvXiIKfsCA0y6cFEIJf4OdzfpRuNk4+YTeHZpGGc666SATFKTz6sRncwFnQk7/ugJ4dSrCj6iJuvW4Qwr2g==
+"@babel/helper-hoist-variables@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
+  integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==
   dependencies:
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.0"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-member-expression-to-functions@^7.13.0", "@babel/helper-member-expression-to-functions@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz#dfe368f26d426a07299d8d6513821768216e6d72"
-  integrity sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==
+"@babel/helper-member-expression-to-functions@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.0.tgz#0ddaf5299c8179f27f37327936553e9bba60990b"
+  integrity sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==
   dependencies:
-    "@babel/types" "^7.13.12"
+    "@babel/types" "^7.15.0"
 
-"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977"
-  integrity sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==
+"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
+  integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
   dependencies:
-    "@babel/types" "^7.13.12"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-module-transforms@^7.13.0", "@babel/helper-module-transforms@^7.13.14":
-  version "7.13.14"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.13.14.tgz#e600652ba48ccb1641775413cb32cfa4e8b495ef"
-  integrity sha512-QuU/OJ0iAOSIatyVZmfqB0lbkVP0kDRiKj34xy+QNsnVZi/PA6BoSoreeqnxxa9EHFAIL0R9XOaAR/G9WlIy5g==
+"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.15.0.tgz#679275581ea056373eddbe360e1419ef23783b08"
+  integrity sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==
   dependencies:
-    "@babel/helper-module-imports" "^7.13.12"
-    "@babel/helper-replace-supers" "^7.13.12"
-    "@babel/helper-simple-access" "^7.13.12"
-    "@babel/helper-split-export-declaration" "^7.12.13"
-    "@babel/helper-validator-identifier" "^7.12.11"
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.13"
-    "@babel/types" "^7.13.14"
+    "@babel/helper-module-imports" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.15.0"
+    "@babel/helper-simple-access" "^7.14.8"
+    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.14.9"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
 
-"@babel/helper-optimise-call-expression@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz#5c02d171b4c8615b1e7163f888c1c81c30a2aaea"
-  integrity sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==
+"@babel/helper-optimise-call-expression@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c"
+  integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==
   dependencies:
-    "@babel/types" "^7.12.13"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af"
-  integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==
+"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
+  integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
 
-"@babel/helper-remap-async-to-generator@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.13.0.tgz#376a760d9f7b4b2077a9dd05aa9c3927cadb2209"
-  integrity sha512-pUQpFBE9JvC9lrQbpX0TmeNIy5s7GnZjna2lhhcHC7DzgBs6fWn722Y5cfwgrtrqc7NAJwMvOa0mKhq6XaE4jg==
+"@babel/helper-remap-async-to-generator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6"
+  integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.12.13"
-    "@babel/helper-wrap-function" "^7.13.0"
-    "@babel/types" "^7.13.0"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    "@babel/helper-wrap-function" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-replace-supers@^7.12.13", "@babel/helper-replace-supers@^7.13.0", "@babel/helper-replace-supers@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz#6442f4c1ad912502481a564a7386de0c77ff3804"
-  integrity sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==
+"@babel/helper-replace-supers@^7.14.5", "@babel/helper-replace-supers@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.0.tgz#ace07708f5bf746bf2e6ba99572cce79b5d4e7f4"
+  integrity sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==
   dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.13.12"
-    "@babel/helper-optimise-call-expression" "^7.12.13"
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.12"
+    "@babel/helper-member-expression-to-functions" "^7.15.0"
+    "@babel/helper-optimise-call-expression" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
 
-"@babel/helper-simple-access@^7.12.13", "@babel/helper-simple-access@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz#dd6c538afb61819d205a012c31792a39c7a5eaf6"
-  integrity sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==
+"@babel/helper-simple-access@^7.14.8":
+  version "7.14.8"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
+  integrity sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==
   dependencies:
-    "@babel/types" "^7.13.12"
+    "@babel/types" "^7.14.8"
 
-"@babel/helper-skip-transparent-expression-wrappers@^7.12.1":
-  version "7.12.1"
-  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf"
-  integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA==
+"@babel/helper-skip-transparent-expression-wrappers@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4"
+  integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==
   dependencies:
-    "@babel/types" "^7.12.1"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-split-export-declaration@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05"
-  integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==
+"@babel/helper-split-export-declaration@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a"
+  integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==
   dependencies:
-    "@babel/types" "^7.12.13"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-validator-identifier@^7.12.11":
-  version "7.12.11"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed"
-  integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
+"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9":
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
+  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
 
-"@babel/helper-validator-option@^7.12.17":
-  version "7.12.17"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831"
-  integrity sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw==
+"@babel/helper-validator-option@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3"
+  integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==
 
-"@babel/helper-wrap-function@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.13.0.tgz#bdb5c66fda8526ec235ab894ad53a1235c79fcc4"
-  integrity sha512-1UX9F7K3BS42fI6qd2A4BjKzgGjToscyZTdp1DjknHLCIvpgne6918io+aL5LXFcER/8QWiwpoY902pVEqgTXA==
+"@babel/helper-wrap-function@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff"
+  integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==
   dependencies:
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.0"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helpers@^7.13.10":
-  version "7.13.10"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.13.10.tgz#fd8e2ba7488533cdeac45cc158e9ebca5e3c7df8"
-  integrity sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==
+"@babel/helpers@^7.14.8":
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.15.3.tgz#c96838b752b95dcd525b4e741ed40bb1dc2a1357"
+  integrity sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==
   dependencies:
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.0"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
 
-"@babel/highlight@^7.12.13":
-  version "7.13.10"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1"
-  integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg==
+"@babel/highlight@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
+  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.12.11"
+    "@babel/helper-validator-identifier" "^7.14.5"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.1.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.13", "@babel/parser@^7.4.3":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.13.tgz#42f03862f4aed50461e543270916b47dd501f0df"
-  integrity sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==
+"@babel/parser@^7.1.0", "@babel/parser@^7.14.5", "@babel/parser@^7.15.0", "@babel/parser@^7.4.3":
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862"
+  integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==
 
-"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.13.12.tgz#a3484d84d0b549f3fc916b99ee4783f26fabad2a"
-  integrity sha512-d0u3zWKcoZf379fOeJdr1a5WPDny4aOFZ6hlfKivgK0LY7ZxNfoaHL2fWwdGtHyVvra38FC+HVYkO+byfSA8AQ==
+"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.14.5.tgz#4b467302e1548ed3b1be43beae2cc9cf45e0bb7e"
+  integrity sha512-ZoJS2XCKPBfTmL122iP6NM9dOg+d4lc9fFk3zxc8iDjvt8Pk4+TlsHSKhIPf6X+L5ORCdBzqMZDjL/WHj7WknQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
-    "@babel/plugin-proposal-optional-chaining" "^7.13.12"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
+    "@babel/plugin-proposal-optional-chaining" "^7.14.5"
 
-"@babel/plugin-proposal-async-generator-functions@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.8.tgz#87aacb574b3bc4b5603f6fe41458d72a5a2ec4b1"
-  integrity sha512-rPBnhj+WgoSmgq+4gQUtXx/vOcU+UYtjy1AA/aeD61Hwj410fwYyqfUcRP3lR8ucgliVJL/G7sXcNUecC75IXA==
+"@babel/plugin-proposal-async-generator-functions@^7.14.9":
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.9.tgz#7028dc4fa21dc199bbacf98b39bab1267d0eaf9a"
+  integrity sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-remap-async-to-generator" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-remap-async-to-generator" "^7.14.5"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
 
-"@babel/plugin-proposal-class-properties@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.13.0.tgz#146376000b94efd001e57a40a88a525afaab9f37"
-  integrity sha512-KnTDjFNC1g+45ka0myZNvSBFLhNCLN+GeGYLDEA8Oq7MZ6yMgfLoIRh86GRT0FjtJhZw8JyUskP9uvj5pHM9Zg==
+"@babel/plugin-proposal-class-properties@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.14.5.tgz#40d1ee140c5b1e31a350f4f5eed945096559b42e"
+  integrity sha512-q/PLpv5Ko4dVc1LYMpCY7RVAAO4uk55qPwrIuJ5QJ8c6cVuAmhu7I/49JOppXL6gXf7ZHzpRVEUZdYoPLM04Gg==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-create-class-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.13.8.tgz#876a1f6966e1dec332e8c9451afda3bebcdf2e1d"
-  integrity sha512-ONWKj0H6+wIRCkZi9zSbZtE/r73uOhMVHh256ys0UzfM7I3d4n+spZNWjOnJv2gzopumP2Wxi186vI8N0Y2JyQ==
+"@babel/plugin-proposal-class-static-block@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.14.5.tgz#158e9e10d449c3849ef3ecde94a03d9f1841b681"
+  integrity sha512-KBAH5ksEnYHCegqseI5N9skTdxgJdmDoAOc0uXa+4QMYKeZD0w5IARh4FMlTNtaHhbB8v+KzMdTgxMMzsIy6Yg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-create-class-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/plugin-syntax-class-static-block" "^7.14.5"
+
+"@babel/plugin-proposal-dynamic-import@^7.10.4", "@babel/plugin-proposal-dynamic-import@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.14.5.tgz#0c6617df461c0c1f8fff3b47cd59772360101d2c"
+  integrity sha512-ExjiNYc3HDN5PXJx+bwC50GIx/KKanX2HiggnIUAYedbARdImiCU4RhhHfdf0Kd7JNXGpsBBBCOm+bBVy3Gb0g==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
 
-"@babel/plugin-proposal-export-namespace-from@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.12.13.tgz#393be47a4acd03fa2af6e3cde9b06e33de1b446d"
-  integrity sha512-INAgtFo4OnLN3Y/j0VwAgw3HDXcDtX+C/erMvWzuV9v71r7urb6iyMXu7eM9IgLr1ElLlOkaHjJ0SbCmdOQ3Iw==
+"@babel/plugin-proposal-export-namespace-from@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.14.5.tgz#dbad244310ce6ccd083072167d8cea83a52faf76"
+  integrity sha512-g5POA32bXPMmSBu5Dx/iZGLGnKmKPc5AiY7qfZgurzrCYgIztDlHFbznSNCoQuv57YQLnQfaDi7dxCtLDIdXdA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
 
-"@babel/plugin-proposal-json-strings@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.13.8.tgz#bf1fb362547075afda3634ed31571c5901afef7b"
-  integrity sha512-w4zOPKUFPX1mgvTmL/fcEqy34hrQ1CRcGxdphBc6snDnnqJ47EZDIyop6IwXzAC8G916hsIuXB2ZMBCExC5k7Q==
+"@babel/plugin-proposal-json-strings@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.14.5.tgz#38de60db362e83a3d8c944ac858ddf9f0c2239eb"
+  integrity sha512-NSq2fczJYKVRIsUJyNxrVUMhB27zb7N7pOFGQOhBKJrChbGcgEAqyZrmZswkPk18VMurEeJAaICbfm57vUeTbQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
 
-"@babel/plugin-proposal-logical-assignment-operators@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.13.8.tgz#93fa78d63857c40ce3c8c3315220fd00bfbb4e1a"
-  integrity sha512-aul6znYB4N4HGweImqKn59Su9RS8lbUIqxtXTOcAGtNIDczoEFv+l1EhmX8rUBp3G1jMjKJm8m0jXVp63ZpS4A==
+"@babel/plugin-proposal-logical-assignment-operators@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.14.5.tgz#6e6229c2a99b02ab2915f82571e0cc646a40c738"
+  integrity sha512-YGn2AvZAo9TwyhlLvCCWxD90Xq8xJ4aSgaX3G5D/8DW94L8aaT+dS5cSP+Z06+rCJERGSr9GxMBZ601xoc2taw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4"
 
-"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.13.8.tgz#3730a31dafd3c10d8ccd10648ed80a2ac5472ef3"
-  integrity sha512-iePlDPBn//UhxExyS9KyeYU7RM9WScAG+D3Hhno0PLJebAEpDZMocbDe64eqynhNAnwz/vZoL/q/QB2T1OH39A==
+"@babel/plugin-proposal-nullish-coalescing-operator@^7.10.4", "@babel/plugin-proposal-nullish-coalescing-operator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.14.5.tgz#ee38589ce00e2cc59b299ec3ea406fcd3a0fdaf6"
+  integrity sha512-gun/SOnMqjSb98Nkaq2rTKMwervfdAoz6NphdY0vTfuzMfryj+tDGb2n6UkDKwez+Y8PZDhE3D143v6Gepp4Hg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3"
 
-"@babel/plugin-proposal-numeric-separator@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.12.13.tgz#bd9da3188e787b5120b4f9d465a8261ce67ed1db"
-  integrity sha512-O1jFia9R8BUCl3ZGB7eitaAPu62TXJRHn7rh+ojNERCFyqRwJMTmhz+tJ+k0CwI6CLjX/ee4qW74FSqlq9I35w==
+"@babel/plugin-proposal-numeric-separator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.14.5.tgz#83631bf33d9a51df184c2102a069ac0c58c05f18"
+  integrity sha512-yiclALKe0vyZRZE0pS6RXgjUOt87GWv6FYa5zqj15PvhOGFO69R5DusPlgK/1K5dVnCtegTiWu9UaBSrLLJJBg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-numeric-separator" "^7.10.4"
 
-"@babel/plugin-proposal-object-rest-spread@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.13.8.tgz#5d210a4d727d6ce3b18f9de82cc99a3964eed60a"
-  integrity sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g==
+"@babel/plugin-proposal-object-rest-spread@^7.14.7":
+  version "7.14.7"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363"
+  integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==
   dependencies:
-    "@babel/compat-data" "^7.13.8"
-    "@babel/helper-compilation-targets" "^7.13.8"
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/compat-data" "^7.14.7"
+    "@babel/helper-compilation-targets" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.13.0"
+    "@babel/plugin-transform-parameters" "^7.14.5"
 
-"@babel/plugin-proposal-optional-catch-binding@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.13.8.tgz#3ad6bd5901506ea996fc31bdcf3ccfa2bed71107"
-  integrity sha512-0wS/4DUF1CuTmGo+NiaHfHcVSeSLj5S3e6RivPTg/2k3wOv3jO35tZ6/ZWsQhQMvdgI7CwphjQa/ccarLymHVA==
+"@babel/plugin-proposal-optional-catch-binding@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.14.5.tgz#939dd6eddeff3a67fdf7b3f044b5347262598c3c"
+  integrity sha512-3Oyiixm0ur7bzO5ybNcZFlmVsygSIQgdOa7cTfOYCMY+wEPAYhZAJxi3mixKFCTCKUhQXuCTtQ1MzrpL3WT8ZQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
 
-"@babel/plugin-proposal-optional-chaining@^7.11.0", "@babel/plugin-proposal-optional-chaining@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.13.12.tgz#ba9feb601d422e0adea6760c2bd6bbb7bfec4866"
-  integrity sha512-fcEdKOkIB7Tf4IxrgEVeFC4zeJSTr78no9wTdBuZZbqF64kzllU0ybo2zrzm7gUQfxGhBgq4E39oRs8Zx/RMYQ==
+"@babel/plugin-proposal-optional-chaining@^7.11.0", "@babel/plugin-proposal-optional-chaining@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.14.5.tgz#fa83651e60a360e3f13797eef00b8d519695b603"
+  integrity sha512-ycz+VOzo2UbWNI1rQXxIuMOzrDdHGrI23fRiz/Si2R4kv2XZQ1BK8ccdHwehMKBlcH/joGW/tzrUmo67gbJHlQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
 
-"@babel/plugin-proposal-private-methods@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.13.0.tgz#04bd4c6d40f6e6bbfa2f57e2d8094bad900ef787"
-  integrity sha512-MXyyKQd9inhx1kDYPkFRVOBXQ20ES8Pto3T7UZ92xj2mY0EVD8oAVzeyYuVfy/mxAdTSIayOvg+aVzcHV2bn6Q==
+"@babel/plugin-proposal-private-methods@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.14.5.tgz#37446495996b2945f30f5be5b60d5e2aa4f5792d"
+  integrity sha512-838DkdUA1u+QTCplatfq4B7+1lnDa/+QMI89x5WZHBcnNv+47N8QEj2k9I2MUU9xIv8XJ4XvPCviM/Dj7Uwt9g==
   dependencies:
-    "@babel/helper-create-class-features-plugin" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-create-class-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-proposal-unicode-property-regex@^7.12.13", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.12.13.tgz#bebde51339be829c17aaaaced18641deb62b39ba"
-  integrity sha512-XyJmZidNfofEkqFV5VC/bLabGmO5QzenPO/YOfGuEbgU+2sSwMmio3YLb4WtBgcmmdwZHyVyv8on77IUjQ5Gvg==
+"@babel/plugin-proposal-private-property-in-object@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.14.5.tgz#9f65a4d0493a940b4c01f8aa9d3f1894a587f636"
+  integrity sha512-62EyfyA3WA0mZiF2e2IV9mc9Ghwxcg8YTu8BS4Wss4Y3PY725OmS9M0qLORbJwLqFtGh+jiE4wAmocK2CTUK2Q==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    "@babel/helper-create-class-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
+
+"@babel/plugin-proposal-unicode-property-regex@^7.14.5", "@babel/plugin-proposal-unicode-property-regex@^7.4.4":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.14.5.tgz#0f95ee0e757a5d647f378daa0eca7e93faa8bbe8"
+  integrity sha512-6axIeOU5LnY471KenAB9vI8I5j7NQ2d652hIYwVyRfgaZT5UpiqFKCuVXCDMSrU+3VFafnu2c5m3lrWIlr6A5Q==
+  dependencies:
+    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-syntax-async-generators@^7.8.4":
   version "7.8.4"
@@ -388,6 +407,13 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.12.13"
 
+"@babel/plugin-syntax-class-static-block@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406"
+  integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==
+  dependencies:
+    "@babel/helper-plugin-utils" "^7.14.5"
+
 "@babel/plugin-syntax-dynamic-import@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
@@ -458,286 +484,296 @@
   dependencies:
     "@babel/helper-plugin-utils" "^7.8.0"
 
-"@babel/plugin-syntax-top-level-await@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.12.13.tgz#c5f0fa6e249f5b739727f923540cf7a806130178"
-  integrity sha512-A81F9pDwyS7yM//KwbCSDqy3Uj4NMIurtplxphWxoYtNPov7cJsDkAFNNyVlIZ3jwGycVsurZ+LtOA8gZ376iQ==
+"@babel/plugin-syntax-private-property-in-object@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad"
+  integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-arrow-functions@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.13.0.tgz#10a59bebad52d637a027afa692e8d5ceff5e3dae"
-  integrity sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg==
+"@babel/plugin-syntax-top-level-await@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c"
+  integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-async-to-generator@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.13.0.tgz#8e112bf6771b82bf1e974e5e26806c5c99aa516f"
-  integrity sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg==
+"@babel/plugin-transform-arrow-functions@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a"
+  integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==
   dependencies:
-    "@babel/helper-module-imports" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-remap-async-to-generator" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-block-scoped-functions@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.13.tgz#a9bf1836f2a39b4eb6cf09967739de29ea4bf4c4"
-  integrity sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg==
+"@babel/plugin-transform-async-to-generator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67"
+  integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-module-imports" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-remap-async-to-generator" "^7.14.5"
 
-"@babel/plugin-transform-block-scoping@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.13.tgz#f36e55076d06f41dfd78557ea039c1b581642e61"
-  integrity sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ==
+"@babel/plugin-transform-block-scoped-functions@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4"
+  integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-classes@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.13.0.tgz#0265155075c42918bf4d3a4053134176ad9b533b"
-  integrity sha512-9BtHCPUARyVH1oXGcSJD3YpsqRLROJx5ZNP6tN5vnk17N0SVf9WCtf8Nuh1CFmgByKKAIMstitKduoCmsaDK5g==
+"@babel/plugin-transform-block-scoping@^7.14.5":
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.15.3.tgz#94c81a6e2fc230bcce6ef537ac96a1e4d2b3afaf"
+  integrity sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.12.13"
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-optimise-call-expression" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-replace-supers" "^7.13.0"
-    "@babel/helper-split-export-declaration" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
+
+"@babel/plugin-transform-classes@^7.14.9":
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.9.tgz#2a391ffb1e5292710b00f2e2c210e1435e7d449f"
+  integrity sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==
+  dependencies:
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-optimise-call-expression" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.14.5"
+    "@babel/helper-split-export-declaration" "^7.14.5"
     globals "^11.1.0"
 
-"@babel/plugin-transform-computed-properties@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.13.0.tgz#845c6e8b9bb55376b1fa0b92ef0bdc8ea06644ed"
-  integrity sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg==
+"@babel/plugin-transform-computed-properties@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f"
+  integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-destructuring@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.13.0.tgz#c5dce270014d4e1ebb1d806116694c12b7028963"
-  integrity sha512-zym5em7tePoNT9s964c0/KU3JPPnuq7VhIxPRefJ4/s82cD+q1mgKfuGRDMCPL0HTyKz4dISuQlCusfgCJ86HA==
+"@babel/plugin-transform-destructuring@^7.14.7":
+  version "7.14.7"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576"
+  integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-dotall-regex@^7.12.13", "@babel/plugin-transform-dotall-regex@^7.4.4":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.12.13.tgz#3f1601cc29905bfcb67f53910f197aeafebb25ad"
-  integrity sha512-foDrozE65ZFdUC2OfgeOCrEPTxdB3yjqxpXh8CH+ipd9CHd4s/iq81kcUpyH8ACGNEPdFqbtzfgzbT/ZGlbDeQ==
+"@babel/plugin-transform-dotall-regex@^7.14.5", "@babel/plugin-transform-dotall-regex@^7.4.4":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.14.5.tgz#2f6bf76e46bdf8043b4e7e16cf24532629ba0c7a"
+  integrity sha512-loGlnBdj02MDsFaHhAIJzh7euK89lBrGIdM9EAtHFo6xKygCUGuuWe07o1oZVk287amtW1n0808sQM99aZt3gw==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-duplicate-keys@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.13.tgz#6f06b87a8b803fd928e54b81c258f0a0033904de"
-  integrity sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ==
+"@babel/plugin-transform-duplicate-keys@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954"
+  integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-exponentiation-operator@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.13.tgz#4d52390b9a273e651e4aba6aee49ef40e80cd0a1"
-  integrity sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA==
+"@babel/plugin-transform-exponentiation-operator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493"
+  integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-for-of@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.13.0.tgz#c799f881a8091ac26b54867a845c3e97d2696062"
-  integrity sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg==
+"@babel/plugin-transform-for-of@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb"
+  integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-function-name@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.13.tgz#bb024452f9aaed861d374c8e7a24252ce3a50051"
-  integrity sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ==
+"@babel/plugin-transform-function-name@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2"
+  integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==
   dependencies:
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-literals@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.13.tgz#2ca45bafe4a820197cf315794a4d26560fe4bdb9"
-  integrity sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ==
+"@babel/plugin-transform-literals@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78"
+  integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-member-expression-literals@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.12.13.tgz#5ffa66cd59b9e191314c9f1f803b938e8c081e40"
-  integrity sha512-kxLkOsg8yir4YeEPHLuO2tXP9R/gTjpuTOjshqSpELUN3ZAg2jfDnKUvzzJxObun38sw3wm4Uu69sX/zA7iRvg==
+"@babel/plugin-transform-member-expression-literals@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.14.5.tgz#b39cd5212a2bf235a617d320ec2b48bcc091b8a7"
+  integrity sha512-WkNXxH1VXVTKarWFqmso83xl+2V3Eo28YY5utIkbsmXoItO8Q3aZxN4BTS2k0hz9dGUloHK26mJMyQEYfkn/+Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-modules-amd@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.13.0.tgz#19f511d60e3d8753cc5a6d4e775d3a5184866cc3"
-  integrity sha512-EKy/E2NHhY/6Vw5d1k3rgoobftcNUmp9fGjb9XZwQLtTctsRBOTRO7RHHxfIky1ogMN5BxN7p9uMA3SzPfotMQ==
+"@babel/plugin-transform-modules-amd@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7"
+  integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==
   dependencies:
-    "@babel/helper-module-transforms" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-module-transforms" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-commonjs@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.13.8.tgz#7b01ad7c2dcf2275b06fa1781e00d13d420b3e1b"
-  integrity sha512-9QiOx4MEGglfYZ4XOnU79OHr6vIWUakIj9b4mioN8eQIoEh+pf5p/zEB36JpDFWA12nNMiRf7bfoRvl9Rn79Bw==
+"@babel/plugin-transform-modules-commonjs@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.15.0.tgz#3305896e5835f953b5cdb363acd9e8c2219a5281"
+  integrity sha512-3H/R9s8cXcOGE8kgMlmjYYC9nqr5ELiPkJn4q0mypBrjhYQoc+5/Maq69vV4xRPWnkzZuwJPf5rArxpB/35Cig==
   dependencies:
-    "@babel/helper-module-transforms" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-simple-access" "^7.12.13"
+    "@babel/helper-module-transforms" "^7.15.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-simple-access" "^7.14.8"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-systemjs@^7.13.8":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.13.8.tgz#6d066ee2bff3c7b3d60bf28dec169ad993831ae3"
-  integrity sha512-hwqctPYjhM6cWvVIlOIe27jCIBgHCsdH2xCJVAYQm7V5yTMoilbVMi9f6wKg0rpQAOn6ZG4AOyvCqFF/hUh6+A==
+"@babel/plugin-transform-modules-systemjs@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.14.5.tgz#c75342ef8b30dcde4295d3401aae24e65638ed29"
+  integrity sha512-mNMQdvBEE5DcMQaL5LbzXFMANrQjd2W7FPzg34Y4yEz7dBgdaC+9B84dSO+/1Wba98zoDbInctCDo4JGxz1VYA==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.13.0"
-    "@babel/helper-module-transforms" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-validator-identifier" "^7.12.11"
+    "@babel/helper-hoist-variables" "^7.14.5"
+    "@babel/helper-module-transforms" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.14.5"
     babel-plugin-dynamic-import-node "^2.3.3"
 
-"@babel/plugin-transform-modules-umd@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.13.0.tgz#8a3d96a97d199705b9fd021580082af81c06e70b"
-  integrity sha512-D/ILzAh6uyvkWjKKyFE/W0FzWwasv6vPTSqPcjxFqn6QpX3u8DjRVliq4F2BamO2Wee/om06Vyy+vPkNrd4wxw==
+"@babel/plugin-transform-modules-umd@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.14.5.tgz#fb662dfee697cce274a7cda525190a79096aa6e0"
+  integrity sha512-RfPGoagSngC06LsGUYyM9QWSXZ8MysEjDJTAea1lqRjNECE3y0qIJF/qbvJxc4oA4s99HumIMdXOrd+TdKaAAA==
   dependencies:
-    "@babel/helper-module-transforms" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-module-transforms" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-named-capturing-groups-regex@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.12.13.tgz#2213725a5f5bbbe364b50c3ba5998c9599c5c9d9"
-  integrity sha512-Xsm8P2hr5hAxyYblrfACXpQKdQbx4m2df9/ZZSQ8MAhsadw06+jW7s9zsSw6he+mJZXRlVMyEnVktJo4zjk1WA==
+"@babel/plugin-transform-named-capturing-groups-regex@^7.14.9":
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.14.9.tgz#c68f5c5d12d2ebaba3762e57c2c4f6347a46e7b2"
+  integrity sha512-l666wCVYO75mlAtGFfyFwnWmIXQm3kSH0C3IRnJqWcZbWkoihyAdDhFm2ZWaxWTqvBvhVFfJjMRQ0ez4oN1yYA==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
+    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
 
-"@babel/plugin-transform-new-target@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.12.13.tgz#e22d8c3af24b150dd528cbd6e685e799bf1c351c"
-  integrity sha512-/KY2hbLxrG5GTQ9zzZSc3xWiOy379pIETEhbtzwZcw9rvuaVV4Fqy7BYGYOWZnaoXIQYbbJ0ziXLa/sKcGCYEQ==
+"@babel/plugin-transform-new-target@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.14.5.tgz#31bdae8b925dc84076ebfcd2a9940143aed7dbf8"
+  integrity sha512-Nx054zovz6IIRWEB49RDRuXGI4Gy0GMgqG0cII9L3MxqgXz/+rgII+RU58qpo4g7tNEx1jG7rRVH4ihZoP4esQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-object-super@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.13.tgz#b4416a2d63b8f7be314f3d349bd55a9c1b5171f7"
-  integrity sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ==
+"@babel/plugin-transform-object-super@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45"
+  integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-    "@babel/helper-replace-supers" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.14.5"
 
-"@babel/plugin-transform-parameters@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.13.0.tgz#8fa7603e3097f9c0b7ca1a4821bc2fb52e9e5007"
-  integrity sha512-Jt8k/h/mIwE2JFEOb3lURoY5C85ETcYPnbuAJ96zRBzh1XHtQZfs62ChZ6EP22QlC8c7Xqr9q+e1SU5qttwwjw==
+"@babel/plugin-transform-parameters@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3"
+  integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-property-literals@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.12.13.tgz#4e6a9e37864d8f1b3bc0e2dce7bf8857db8b1a81"
-  integrity sha512-nqVigwVan+lR+g8Fj8Exl0UQX2kymtjcWfMOYM1vTYEKujeyv2SkMgazf2qNcK7l4SDiKyTA/nHCPqL4e2zo1A==
+"@babel/plugin-transform-property-literals@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.14.5.tgz#0ddbaa1f83db3606f1cdf4846fa1dfb473458b34"
+  integrity sha512-r1uilDthkgXW8Z1vJz2dKYLV1tuw2xsbrp3MrZmD99Wh9vsfKoob+JTgri5VUb/JqyKRXotlOtwgu4stIYCmnw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-regenerator@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.13.tgz#b628bcc9c85260ac1aeb05b45bde25210194a2f5"
-  integrity sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA==
+"@babel/plugin-transform-regenerator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f"
+  integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==
   dependencies:
     regenerator-transform "^0.14.2"
 
-"@babel/plugin-transform-reserved-words@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.12.13.tgz#7d9988d4f06e0fe697ea1d9803188aa18b472695"
-  integrity sha512-xhUPzDXxZN1QfiOy/I5tyye+TRz6lA7z6xaT4CLOjPRMVg1ldRf0LHw0TDBpYL4vG78556WuHdyO9oi5UmzZBg==
+"@babel/plugin-transform-reserved-words@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.14.5.tgz#c44589b661cfdbef8d4300dcc7469dffa92f8304"
+  integrity sha512-cv4F2rv1nD4qdexOGsRQXJrOcyb5CrgjUH9PKrrtyhSDBNWGxd0UIitjyJiWagS+EbUGjG++22mGH1Pub8D6Vg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-shorthand-properties@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.13.tgz#db755732b70c539d504c6390d9ce90fe64aff7ad"
-  integrity sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw==
+"@babel/plugin-transform-shorthand-properties@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58"
+  integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-spread@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.13.0.tgz#84887710e273c1815ace7ae459f6f42a5d31d5fd"
-  integrity sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg==
+"@babel/plugin-transform-spread@^7.14.6":
+  version "7.14.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144"
+  integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
 
-"@babel/plugin-transform-sticky-regex@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.13.tgz#760ffd936face73f860ae646fb86ee82f3d06d1f"
-  integrity sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg==
+"@babel/plugin-transform-sticky-regex@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9"
+  integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-template-literals@^7.13.0", "@babel/plugin-transform-template-literals@^7.8.3":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.13.0.tgz#a36049127977ad94438dee7443598d1cefdf409d"
-  integrity sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw==
+"@babel/plugin-transform-template-literals@^7.14.5", "@babel/plugin-transform-template-literals@^7.8.3":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93"
+  integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-typeof-symbol@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.13.tgz#785dd67a1f2ea579d9c2be722de8c84cb85f5a7f"
-  integrity sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ==
+"@babel/plugin-transform-typeof-symbol@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4"
+  integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-unicode-escapes@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.12.13.tgz#840ced3b816d3b5127dd1d12dcedc5dead1a5e74"
-  integrity sha512-0bHEkdwJ/sN/ikBHfSmOXPypN/beiGqjo+o4/5K+vxEFNPRPdImhviPakMKG4x96l85emoa0Z6cDflsdBusZbw==
+"@babel/plugin-transform-unicode-escapes@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.14.5.tgz#9d4bd2a681e3c5d7acf4f57fa9e51175d91d0c6b"
+  integrity sha512-crTo4jATEOjxj7bt9lbYXcBAM3LZaUrbP2uUdxb6WIorLmjNKSpHfIybgY4B8SRpbf8tEVIWH3Vtm7ayCrKocA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/plugin-transform-unicode-regex@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.13.tgz#b52521685804e155b1202e83fc188d34bb70f5ac"
-  integrity sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA==
+"@babel/plugin-transform-unicode-regex@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e"
+  integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
+    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/preset-env@^7.9.0":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.12.tgz#6dff470478290582ac282fb77780eadf32480237"
-  integrity sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA==
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.15.0.tgz#e2165bf16594c9c05e52517a194bf6187d6fe464"
+  integrity sha512-FhEpCNFCcWW3iZLg0L2NPE9UerdtsCR6ZcsGHUX6Om6kbCQeL5QZDqFDmeNHC6/fy6UH3jEge7K4qG5uC9In0Q==
   dependencies:
-    "@babel/compat-data" "^7.13.12"
-    "@babel/helper-compilation-targets" "^7.13.10"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-validator-option" "^7.12.17"
-    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.13.12"
-    "@babel/plugin-proposal-async-generator-functions" "^7.13.8"
-    "@babel/plugin-proposal-class-properties" "^7.13.0"
-    "@babel/plugin-proposal-dynamic-import" "^7.13.8"
-    "@babel/plugin-proposal-export-namespace-from" "^7.12.13"
-    "@babel/plugin-proposal-json-strings" "^7.13.8"
-    "@babel/plugin-proposal-logical-assignment-operators" "^7.13.8"
-    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.13.8"
-    "@babel/plugin-proposal-numeric-separator" "^7.12.13"
-    "@babel/plugin-proposal-object-rest-spread" "^7.13.8"
-    "@babel/plugin-proposal-optional-catch-binding" "^7.13.8"
-    "@babel/plugin-proposal-optional-chaining" "^7.13.12"
-    "@babel/plugin-proposal-private-methods" "^7.13.0"
-    "@babel/plugin-proposal-unicode-property-regex" "^7.12.13"
+    "@babel/compat-data" "^7.15.0"
+    "@babel/helper-compilation-targets" "^7.15.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-validator-option" "^7.14.5"
+    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.14.5"
+    "@babel/plugin-proposal-async-generator-functions" "^7.14.9"
+    "@babel/plugin-proposal-class-properties" "^7.14.5"
+    "@babel/plugin-proposal-class-static-block" "^7.14.5"
+    "@babel/plugin-proposal-dynamic-import" "^7.14.5"
+    "@babel/plugin-proposal-export-namespace-from" "^7.14.5"
+    "@babel/plugin-proposal-json-strings" "^7.14.5"
+    "@babel/plugin-proposal-logical-assignment-operators" "^7.14.5"
+    "@babel/plugin-proposal-nullish-coalescing-operator" "^7.14.5"
+    "@babel/plugin-proposal-numeric-separator" "^7.14.5"
+    "@babel/plugin-proposal-object-rest-spread" "^7.14.7"
+    "@babel/plugin-proposal-optional-catch-binding" "^7.14.5"
+    "@babel/plugin-proposal-optional-chaining" "^7.14.5"
+    "@babel/plugin-proposal-private-methods" "^7.14.5"
+    "@babel/plugin-proposal-private-property-in-object" "^7.14.5"
+    "@babel/plugin-proposal-unicode-property-regex" "^7.14.5"
     "@babel/plugin-syntax-async-generators" "^7.8.4"
     "@babel/plugin-syntax-class-properties" "^7.12.13"
+    "@babel/plugin-syntax-class-static-block" "^7.14.5"
     "@babel/plugin-syntax-dynamic-import" "^7.8.3"
     "@babel/plugin-syntax-export-namespace-from" "^7.8.3"
     "@babel/plugin-syntax-json-strings" "^7.8.3"
@@ -747,45 +783,46 @@
     "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
     "@babel/plugin-syntax-optional-catch-binding" "^7.8.3"
     "@babel/plugin-syntax-optional-chaining" "^7.8.3"
-    "@babel/plugin-syntax-top-level-await" "^7.12.13"
-    "@babel/plugin-transform-arrow-functions" "^7.13.0"
-    "@babel/plugin-transform-async-to-generator" "^7.13.0"
-    "@babel/plugin-transform-block-scoped-functions" "^7.12.13"
-    "@babel/plugin-transform-block-scoping" "^7.12.13"
-    "@babel/plugin-transform-classes" "^7.13.0"
-    "@babel/plugin-transform-computed-properties" "^7.13.0"
-    "@babel/plugin-transform-destructuring" "^7.13.0"
-    "@babel/plugin-transform-dotall-regex" "^7.12.13"
-    "@babel/plugin-transform-duplicate-keys" "^7.12.13"
-    "@babel/plugin-transform-exponentiation-operator" "^7.12.13"
-    "@babel/plugin-transform-for-of" "^7.13.0"
-    "@babel/plugin-transform-function-name" "^7.12.13"
-    "@babel/plugin-transform-literals" "^7.12.13"
-    "@babel/plugin-transform-member-expression-literals" "^7.12.13"
-    "@babel/plugin-transform-modules-amd" "^7.13.0"
-    "@babel/plugin-transform-modules-commonjs" "^7.13.8"
-    "@babel/plugin-transform-modules-systemjs" "^7.13.8"
-    "@babel/plugin-transform-modules-umd" "^7.13.0"
-    "@babel/plugin-transform-named-capturing-groups-regex" "^7.12.13"
-    "@babel/plugin-transform-new-target" "^7.12.13"
-    "@babel/plugin-transform-object-super" "^7.12.13"
-    "@babel/plugin-transform-parameters" "^7.13.0"
-    "@babel/plugin-transform-property-literals" "^7.12.13"
-    "@babel/plugin-transform-regenerator" "^7.12.13"
-    "@babel/plugin-transform-reserved-words" "^7.12.13"
-    "@babel/plugin-transform-shorthand-properties" "^7.12.13"
-    "@babel/plugin-transform-spread" "^7.13.0"
-    "@babel/plugin-transform-sticky-regex" "^7.12.13"
-    "@babel/plugin-transform-template-literals" "^7.13.0"
-    "@babel/plugin-transform-typeof-symbol" "^7.12.13"
-    "@babel/plugin-transform-unicode-escapes" "^7.12.13"
-    "@babel/plugin-transform-unicode-regex" "^7.12.13"
+    "@babel/plugin-syntax-private-property-in-object" "^7.14.5"
+    "@babel/plugin-syntax-top-level-await" "^7.14.5"
+    "@babel/plugin-transform-arrow-functions" "^7.14.5"
+    "@babel/plugin-transform-async-to-generator" "^7.14.5"
+    "@babel/plugin-transform-block-scoped-functions" "^7.14.5"
+    "@babel/plugin-transform-block-scoping" "^7.14.5"
+    "@babel/plugin-transform-classes" "^7.14.9"
+    "@babel/plugin-transform-computed-properties" "^7.14.5"
+    "@babel/plugin-transform-destructuring" "^7.14.7"
+    "@babel/plugin-transform-dotall-regex" "^7.14.5"
+    "@babel/plugin-transform-duplicate-keys" "^7.14.5"
+    "@babel/plugin-transform-exponentiation-operator" "^7.14.5"
+    "@babel/plugin-transform-for-of" "^7.14.5"
+    "@babel/plugin-transform-function-name" "^7.14.5"
+    "@babel/plugin-transform-literals" "^7.14.5"
+    "@babel/plugin-transform-member-expression-literals" "^7.14.5"
+    "@babel/plugin-transform-modules-amd" "^7.14.5"
+    "@babel/plugin-transform-modules-commonjs" "^7.15.0"
+    "@babel/plugin-transform-modules-systemjs" "^7.14.5"
+    "@babel/plugin-transform-modules-umd" "^7.14.5"
+    "@babel/plugin-transform-named-capturing-groups-regex" "^7.14.9"
+    "@babel/plugin-transform-new-target" "^7.14.5"
+    "@babel/plugin-transform-object-super" "^7.14.5"
+    "@babel/plugin-transform-parameters" "^7.14.5"
+    "@babel/plugin-transform-property-literals" "^7.14.5"
+    "@babel/plugin-transform-regenerator" "^7.14.5"
+    "@babel/plugin-transform-reserved-words" "^7.14.5"
+    "@babel/plugin-transform-shorthand-properties" "^7.14.5"
+    "@babel/plugin-transform-spread" "^7.14.6"
+    "@babel/plugin-transform-sticky-regex" "^7.14.5"
+    "@babel/plugin-transform-template-literals" "^7.14.5"
+    "@babel/plugin-transform-typeof-symbol" "^7.14.5"
+    "@babel/plugin-transform-unicode-escapes" "^7.14.5"
+    "@babel/plugin-transform-unicode-regex" "^7.14.5"
     "@babel/preset-modules" "^0.1.4"
-    "@babel/types" "^7.13.12"
-    babel-plugin-polyfill-corejs2 "^0.1.4"
-    babel-plugin-polyfill-corejs3 "^0.1.3"
-    babel-plugin-polyfill-regenerator "^0.1.2"
-    core-js-compat "^3.9.0"
+    "@babel/types" "^7.15.0"
+    babel-plugin-polyfill-corejs2 "^0.2.2"
+    babel-plugin-polyfill-corejs3 "^0.2.2"
+    babel-plugin-polyfill-regenerator "^0.2.2"
+    core-js-compat "^3.16.0"
     semver "^6.3.0"
 
 "@babel/preset-modules@^0.1.4":
@@ -800,42 +837,42 @@
     esutils "^2.0.2"
 
 "@babel/runtime@^7.8.4":
-  version "7.13.10"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d"
-  integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
+  integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/template@^7.12.13", "@babel/template@^7.4.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327"
-  integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==
+"@babel/template@^7.14.5", "@babel/template@^7.4.0":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
+  integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==
   dependencies:
-    "@babel/code-frame" "^7.12.13"
-    "@babel/parser" "^7.12.13"
-    "@babel/types" "^7.12.13"
+    "@babel/code-frame" "^7.14.5"
+    "@babel/parser" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.4.3":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.13.tgz#39aa9c21aab69f74d948a486dd28a2dbdbf5114d"
-  integrity sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg==
+"@babel/traverse@^7.13.0", "@babel/traverse@^7.14.5", "@babel/traverse@^7.15.0", "@babel/traverse@^7.4.3":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98"
+  integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==
   dependencies:
-    "@babel/code-frame" "^7.12.13"
-    "@babel/generator" "^7.13.9"
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-split-export-declaration" "^7.12.13"
-    "@babel/parser" "^7.13.13"
-    "@babel/types" "^7.13.13"
+    "@babel/code-frame" "^7.14.5"
+    "@babel/generator" "^7.15.0"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-hoist-variables" "^7.14.5"
+    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/parser" "^7.15.0"
+    "@babel/types" "^7.15.0"
     debug "^4.1.0"
     globals "^11.1.0"
 
-"@babel/types@^7.0.0", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.13", "@babel/types@^7.13.14", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
-  version "7.13.14"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.14.tgz#c35a4abb15c7cd45a2746d78ab328e362cbace0d"
-  integrity sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==
+"@babel/types@^7.0.0", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.15.0", "@babel/types@^7.3.0", "@babel/types@^7.4.0", "@babel/types@^7.4.4":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
+  integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
   dependencies:
-    "@babel/helper-validator-identifier" "^7.12.11"
-    lodash "^4.17.19"
+    "@babel/helper-validator-identifier" "^7.14.9"
     to-fast-properties "^2.0.0"
 
 "@koa/cors@^3.1.0":
@@ -933,30 +970,23 @@
     picomatch "^2.2.2"
 
 "@sinonjs/commons@^1.6.0", "@sinonjs/commons@^1.7.0", "@sinonjs/commons@^1.8.1":
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.2.tgz#858f5c4b48d80778fde4b9d541f27edc0d56488b"
-  integrity sha512-sruwd86RJHdsVf/AtBoijDmUqJp3B6hF/DGC23C+JaegnDHaZyewCjoVGTdg3J0uz3Zs7NnIT05OBOmML72lQw==
+  version "1.8.3"
+  resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.3.tgz#3802ddd21a50a949b6721ddd72da36e67e7f1b2d"
+  integrity sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==
   dependencies:
     type-detect "4.0.8"
 
-"@sinonjs/fake-timers@^6.0.0", "@sinonjs/fake-timers@^6.0.1":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz#293674fccb3262ac782c7aadfdeca86b10c75c40"
-  integrity sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==
+"@sinonjs/fake-timers@^7.0.4", "@sinonjs/fake-timers@^7.1.0":
+  version "7.1.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.1.2.tgz#2524eae70c4910edccf99b2f4e6efc5894aff7b5"
+  integrity sha512-iQADsW4LBMISqZ6Ci1dupJL9pprqwcVFTcOsEmQOEhW+KLCVn/Y4Jrvg2k19fIHCp+iFprriYPTdRcQR8NbUPg==
   dependencies:
     "@sinonjs/commons" "^1.7.0"
 
-"@sinonjs/fake-timers@^7.0.4":
-  version "7.0.5"
-  resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-7.0.5.tgz#558a7f8145a01366c44b3dcbdd7172c05c461564"
-  integrity sha512-fUt6b15bjV/VW93UP5opNXJxdwZSbK1EdiwnhN7XrQrcpaOhMJpZ/CjwFpM3THpxwA+YviBUJKSuEqKlCK5alw==
-  dependencies:
-    "@sinonjs/commons" "^1.7.0"
-
-"@sinonjs/samsam@^5.3.1":
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-5.3.1.tgz#375a45fe6ed4e92fca2fb920e007c48232a6507f"
-  integrity sha512-1Hc0b1TtyfBu8ixF/tpfSHTVWKwCBLY4QJbkgnE7HcwyvT2xArDxb4K7dMgqRm3szI+LJbzmW/s4xxEhv6hwDg==
+"@sinonjs/samsam@^6.0.1":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-6.0.2.tgz#a0117d823260f282c04bff5f8704bdc2ac6910bb"
+  integrity sha512-jxPRPp9n93ci7b8hMfJOFDPRLFYadN6FSpeROFTR4UNF4i5b+EK6m4QXPO46BDhFgRy1JuS87zAnFOzCUwMJcQ==
   dependencies:
     "@sinonjs/commons" "^1.6.0"
     lodash.get "^4.4.2"
@@ -975,9 +1005,9 @@
     "@types/node" "*"
 
 "@types/babel__core@^7.1.3":
-  version "7.1.14"
-  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402"
-  integrity sha512-zGZJzzBUVDo/eV6KgbE0f0ZI7dInEYvo12Rb70uNQDshC3SkRMb67ja0GgRHZgAX3Za6rhaWlvbDO8rrGyAb1g==
+  version "7.1.15"
+  resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024"
+  integrity sha512-bxlMKPDbY8x5h6HBwVzEOk2C8fb6SLfYQ5Jw3uBYuYF1lfWk/kbLd81la82vrIkBb0l+JdmrZaDikPrNxpS/Ew==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
@@ -986,39 +1016,39 @@
     "@types/babel__traverse" "*"
 
 "@types/babel__generator@*":
-  version "7.6.2"
-  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.2.tgz#f3d71178e187858f7c45e30380f8f1b7415a12d8"
-  integrity sha512-MdSJnBjl+bdwkLskZ3NGFp9YcXGx5ggLpQQPqtgakVhsWK0hTtNYhjpZLlWQTviGTvF8at+Bvli3jV7faPdgeQ==
+  version "7.6.3"
+  resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.3.tgz#f456b4b2ce79137f768aa130d2423d2f0ccfaba5"
+  integrity sha512-/GWCmzJWqV7diQW54smJZzWbSFf4QYtF71WCKhcx6Ru/tFyQIY2eiiITcCAeuPbNSvT9YCGkVMqqvSk2Z0mXiA==
   dependencies:
     "@babel/types" "^7.0.0"
 
 "@types/babel__template@*":
-  version "7.4.0"
-  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.0.tgz#0c888dd70b3ee9eebb6e4f200e809da0076262be"
-  integrity sha512-NTPErx4/FiPCGScH7foPyr+/1Dkzkni+rHiYHHoTjvwou7AQzJkNeD60A9CXRy+ZEN2B1bggmkTMCDb+Mv5k+A==
+  version "7.4.1"
+  resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.1.tgz#3d1a48fd9d6c0edfd56f2ff578daed48f36c8969"
+  integrity sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==
   dependencies:
     "@babel/parser" "^7.1.0"
     "@babel/types" "^7.0.0"
 
 "@types/babel__traverse@*":
-  version "7.11.1"
-  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.11.1.tgz#654f6c4f67568e24c23b367e947098c6206fa639"
-  integrity sha512-Vs0hm0vPahPMYi9tDjtP66llufgO3ST16WXaSTtDGEl9cewAl3AibmxWw6TINOqHPT9z0uABKAYjT9jNSg4npw==
+  version "7.14.2"
+  resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.14.2.tgz#ffcd470bbb3f8bf30481678fb5502278ca833a43"
+  integrity sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==
   dependencies:
     "@babel/types" "^7.3.0"
 
 "@types/body-parser@*":
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
-  integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.1.tgz#0c0174c42a7d017b818303d4b5d969cb0b75929c"
+  integrity sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
 "@types/browserslist-useragent@^3.0.0":
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.2.tgz#e4d9a5b3949f7291c0a253e3b9e092e66673114c"
-  integrity sha512-Y2McxEf2m89AgMYgp/E33pxH0DKYHpCHhSSBlPTATnEVatWmHMyWRQpdlOK+BrwcFK62+A+P3mu0s1Owkas9zw==
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/@types/browserslist-useragent/-/browserslist-useragent-3.0.4.tgz#385067529bf5a59d845c98d4c56d9e8223bbf34a"
+  integrity sha512-S/AhrluMHi8EcuxxCtTDBGr8u+XvwUfLvZdARuIS2LFZ/lHoeaeJJYCozD68GKH6wm52FbIHq4WWPF/Ec6a9qA==
 
 "@types/browserslist@^4.8.0":
   version "4.15.0"
@@ -1028,47 +1058,62 @@
     browserslist "*"
 
 "@types/caniuse-api@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.1.tgz#94c0073a2c305d0476ce9199cd6ef3fd7d2c5f66"
-  integrity sha512-VcjPciJLx86btwWypSo6vRzZSOC6asS3/SGgn7r7Xk7jAWNyMoxCy+IQzI2vuW2Bvs3iytyOEwsjLJKmHXBvmA==
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/@types/caniuse-api/-/caniuse-api-3.0.2.tgz#684ba0c284b2a58346abf0000bd0a735ad072d75"
+  integrity sha512-YfCDMn7R59n7GFFfwjPAM0zLJQy4UvveC32rOJBmTqJJY8uSRqM4Dc7IJj8V9unA48Qy4nj5Bj3jD6Q8VZ1Seg==
 
 "@types/chai@^4.2.16":
-  version "4.2.16"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.16.tgz#f09cc36e18d28274f942e7201147cce34d97e8c8"
-  integrity sha512-vI5iOAsez9+roLS3M3+Xx7w+WRuDtSmF8bQkrbcIJ2sC1PcDgVoA0WGpa+bIrJ+y8zqY2oi//fUctkxtIcXJCw==
+  version "4.2.21"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.21.tgz#9f35a5643129df132cf3b5c1ec64046ea1af0650"
+  integrity sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==
 
 "@types/command-line-args@^5.0.0":
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.0.0.tgz#484e704d20dbb8754a8f091eee45cdd22bcff28c"
-  integrity sha512-4eOPXyn5DmP64MCMF8ePDvdlvlzt2a+F8ZaVjqmh2yFCpGjc1kI3kGnCFYX9SCsGTjQcWIyVZ86IHCEyjy/MNg==
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/@types/command-line-args/-/command-line-args-5.2.0.tgz#adbb77980a1cc376bb208e3f4142e907410430f6"
+  integrity sha512-UuKzKpJJ/Ief6ufIaIzr3A/0XnluX7RvFgwkV89Yzvm77wCh1kFaFmqN8XEnGcN62EuHdedQjEMb8mYxFLGPyA==
 
 "@types/command-line-usage@^5.0.1":
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.1.tgz#99424950da567ba67b6b65caee57ff03c4e751ec"
-  integrity sha512-/xUgezxxYePeXhg5S04hUjxG9JZi+rJTs1+4NwpYPfSaS7BeDa6tVJkH6lN9Cb6rl8d24Fi2uX0s0Ngg2JT6gg==
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/@types/command-line-usage/-/command-line-usage-5.0.2.tgz#ba5e3f6ae5a2009d466679cc431b50635bf1a064"
+  integrity sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==
+
+"@types/component-emitter@^1.2.10":
+  version "1.2.10"
+  resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.10.tgz#ef5b1589b9f16544642e473db5ea5639107ef3ea"
+  integrity sha512-bsjleuRKWmGqajMerkzox19aGbscQX5rmmvvXl3wlIp5gMG1HgkiwPxsN5p070fBDKTNSPgojVbuY1+HWMbFhg==
 
 "@types/connect@*":
-  version "3.4.34"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901"
-  integrity sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
   dependencies:
     "@types/node" "*"
 
 "@types/content-disposition@*":
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.3.tgz#0aa116701955c2faa0717fc69cd1596095e49d96"
-  integrity sha512-P1bffQfhD3O4LW0ioENXUhZ9OIa0Zn+P7M+pWgkCKaT53wVLSq0mrKksCID/FGHpFhRSxRGhgrQmfhRuzwtKdg==
+  version "0.5.4"
+  resolved "https://registry.yarnpkg.com/@types/content-disposition/-/content-disposition-0.5.4.tgz#de48cf01c79c9f1560bcfd8ae43217ab028657f8"
+  integrity sha512-0mPF08jn9zYI0n0Q/Pnz7C4kThdSt+6LD4amsrYDDpgBfrVWa3TcCOxKX1zkGgYniGagRv8heN2cbh+CAn+uuQ==
+
+"@types/cookie@^0.4.0":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
+  integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
 
 "@types/cookies@*":
-  version "0.7.6"
-  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.6.tgz#71212c5391a976d3bae57d4b09fac20fc6bda504"
-  integrity sha512-FK4U5Qyn7/Sc5ih233OuHO0qAkOpEcD/eG6584yEiLKizTFRny86qHLe/rej3HFQrkBuUjF4whFliAdODbVN/w==
+  version "0.7.7"
+  resolved "https://registry.yarnpkg.com/@types/cookies/-/cookies-0.7.7.tgz#7a92453d1d16389c05a5301eef566f34946cfd81"
+  integrity sha512-h7BcvPUogWbKCzBR2lY4oqaZbO3jXZksexYJVFvkrFeLgbZjQkU4x8pRq6eg2MHXQhY0McQdqmmsxRWlVAHooA==
   dependencies:
     "@types/connect" "*"
     "@types/express" "*"
     "@types/keygrip" "*"
     "@types/node" "*"
 
+"@types/cors@^2.8.8":
+  version "2.8.12"
+  resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
+  integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
+
 "@types/debounce@^1.2.0":
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192"
@@ -1080,25 +1125,25 @@
   integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==
 
 "@types/etag@*":
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.0.tgz#37f0b1f3ea46da7ae319bbedb607e375b4c99f7e"
-  integrity sha512-EdSN0x+Y0/lBv7YAb8IU4Jgm6DWM+Bqtz7o5qozl96fzaqdqbdfHS5qjdpFeIv7xQ8jSLyjMMNShgYtMajEHyQ==
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/@types/etag/-/etag-1.8.1.tgz#593ca8ddb43acb3db049bd0955fd64d281ab58b9"
+  integrity sha512-bsKkeSqN7HYyYntFRAmzcwx/dKW4Wa+KVMTInANlI72PWLQmOpZu96j0OqHZGArW4VQwCmJPteQlXaUDeOB0WQ==
   dependencies:
     "@types/node" "*"
 
 "@types/express-serve-static-core@^4.17.18":
-  version "4.17.19"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d"
-  integrity sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA==
+  version "4.17.24"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07"
+  integrity sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==
   dependencies:
     "@types/node" "*"
     "@types/qs" "*"
     "@types/range-parser" "*"
 
 "@types/express@*":
-  version "4.17.11"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.11.tgz#debe3caa6f8e5fcda96b47bd54e2f40c4ee59545"
-  integrity sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==
+  version "4.17.13"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
+  integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
   dependencies:
     "@types/body-parser" "*"
     "@types/express-serve-static-core" "^4.17.18"
@@ -1106,14 +1151,14 @@
     "@types/serve-static" "*"
 
 "@types/http-assert@*":
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.1.tgz#d775e93630c2469c2f980fc27e3143240335db3b"
-  integrity sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ==
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.2.tgz#a7fb59a7ca366e141789a084555a633801b9af3b"
+  integrity sha512-Ddzuzv/bB2prZnJKlS1sEYhaeT50wfJjhcTTTQLjEsEZJlk3XB4Xohieyq+P4VXIzg7lrQ1Spd/PfRnBpQsdqA==
 
 "@types/http-errors@*":
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.0.tgz#682477dbbbd07cd032731cb3b0e7eaee3d026b69"
-  integrity sha512-2aoSC4UUbHDj2uCsCxcG/vRMXey/m17bC7UwitVm5hn22nI8O8Y9iDpA76Orc+DWkQ4zZrOKEshCqR/jSuXAHA==
+  version "1.8.1"
+  resolved "https://registry.yarnpkg.com/@types/http-errors/-/http-errors-1.8.1.tgz#e81ad28a60bee0328c6d2384e029aec626f1ae67"
+  integrity sha512-e+2rjEwK6KDaNOm5Aa9wNGgyS9oSZU/4pfSMMPYNOfjvFI0WVXm29+ITRFr6aKDvvKo7uU1jV68MW4ScsfDi7Q==
 
 "@types/keygrip@*":
   version "1.0.2"
@@ -1144,24 +1189,24 @@
     "@types/koa" "*"
 
 "@types/koa-send@*":
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.2.tgz#978f8267ad116d12ac6a18fecd8f34c5657e09ad"
-  integrity sha512-rfqKIv9bFds39Jxvsp8o3YJLnEQVPVriYA14AuO2OY65IHh/4UX4U/iMs5L0wATpcRmm1bbe0BNk23TRwx3VQQ==
+  version "4.1.3"
+  resolved "https://registry.yarnpkg.com/@types/koa-send/-/koa-send-4.1.3.tgz#17193c6472ae9e5d1b99ae8086949cc4fd69179d"
+  integrity sha512-daaTqPZlgjIJycSTNjKpHYuKhXYP30atFc1pBcy6HHqB9+vcymDgYTguPdx9tO4HMOqNyz6bz/zqpxt5eLR+VA==
   dependencies:
     "@types/koa" "*"
 
 "@types/koa-static@^4.0.1":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/koa-static/-/koa-static-4.0.1.tgz#b740d80a549b0a0a7a3b38918daecde88a7a50ec"
-  integrity sha512-SSpct5fEcAeRkBHa3RiwCIRfDHcD1cZRhwRF///ZfvRt8KhoqRrhK6wpDlYPk/vWHVFE9hPGqh68bhzsHkir4w==
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/@types/koa-static/-/koa-static-4.0.2.tgz#a199d2d64d2930755eb3ea370aeaf2cb6f501d67"
+  integrity sha512-ns/zHg+K6XVPMuohjpOlpkR1WLa4VJ9czgUP9bxkCDn0JZBtUWbD/wKDZzPGDclkQK1bpAEScufCHOy8cbfL0w==
   dependencies:
     "@types/koa" "*"
     "@types/koa-send" "*"
 
 "@types/koa@*", "@types/koa@^2.0.48":
-  version "2.13.1"
-  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.1.tgz#e29877a6b5ad3744ab1024f6ec75b8cbf6ec45db"
-  integrity sha512-Qbno7FWom9nNqu0yHZ6A0+RWt4mrYBhw3wpBAQ3+IuzGcLlfeYkzZrnMq5wsxulN2np8M4KKeUpTodsOsSad5Q==
+  version "2.13.4"
+  resolved "https://registry.yarnpkg.com/@types/koa/-/koa-2.13.4.tgz#10620b3f24a8027ef5cbae88b393d1b31205726b"
+  integrity sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw==
   dependencies:
     "@types/accepts" "*"
     "@types/content-disposition" "*"
@@ -1173,21 +1218,21 @@
     "@types/node" "*"
 
 "@types/koa__cors@^3.0.1":
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.2.tgz#578917ffca964e98f5e9849996ae1eeda7c15704"
-  integrity sha512-gBetQR0DJ9JTG1YQoW33BADHCrDPJGiJUKUUcEPJwW1A2unzpIMhorEpXB6eMaaXTaqHLemcGnq3RmH9XaryRQ==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/koa__cors/-/koa__cors-3.0.3.tgz#49d75813b443ba3d4da28ea6cf6244b7e99a3b23"
+  integrity sha512-74Xb4hJOPGKlrQ4PRBk1A/p0gfLpgbnpT0o67OMVbwyeMXvlBN+ZCRztAAmkKZs+8hKbgMutUlZVbA52Hr/0IA==
   dependencies:
     "@types/koa" "*"
 
 "@types/lodash@^4.14.168":
-  version "4.14.168"
-  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
-  integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==
+  version "4.14.172"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.172.tgz#aad774c28e7bfd7a67de25408e03ee5a8c3d028a"
+  integrity sha512-/BHF5HAx3em7/KkzVKm3LrsD6HZAXuXO1AJZQ3cRRBZj4oHZDviWPYu0aEplAqDFNHZPW6d3G7KN+ONcCCC7pw==
 
 "@types/lru-cache@^5.1.0":
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.0.tgz#57f228f2b80c046b4a1bd5cac031f81f207f4f03"
-  integrity sha512-RaE0B+14ToE4l6UqdarKPnXwVDuigfFv+5j9Dze/Nqr23yyuqdNvzcZi3xB+3Agvi5R4EOgAksfv3lXX4vBt9w==
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/@types/lru-cache/-/lru-cache-5.1.1.tgz#c48c2e27b65d2a153b19bfc1a317e30872e01eef"
+  integrity sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==
 
 "@types/mime-types@^2.1.0":
   version "2.1.0"
@@ -1200,19 +1245,19 @@
   integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
 
 "@types/minimatch@^3.0.3":
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21"
-  integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
+  integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
 "@types/mocha@^8.2.2":
-  version "8.2.2"
-  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.2.tgz#91daa226eb8c2ff261e6a8cbf8c7304641e095e0"
-  integrity sha512-Lwh0lzzqT5Pqh6z61P3c3P5nm6fzQK/MMHl9UKeneAeInVflBSz1O2EkX6gM6xfJd7FBXBY5purtLx7fUiZ7Hw==
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-8.2.3.tgz#bbeb55fbc73f28ea6de601fbfa4613f58d785323"
+  integrity sha512-ekGvFhFgrc2zYQoX4JeZPmVzZxw6Dtllga7iGHzfbYIYkAMUx/sAFP2GdFpLff+vdHXu5fl7WX9AT+TtqYcsyw==
 
-"@types/node@*":
-  version "14.14.37"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e"
-  integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
+"@types/node@*", "@types/node@>=10.0.0":
+  version "16.6.1"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.6.1.tgz#aee62c7b966f55fc66c7b6dfa1d58db2a616da61"
+  integrity sha512-Sr7BhXEAer9xyGuCN3Ek9eg9xPviCF2gfu9kTfuU2HkTVAMYSDeX40fvpmo72n5nansg3nsBjuQBrsS28r+NUw==
 
 "@types/path-is-inside@^1.0.0":
   version "1.0.0"
@@ -1220,14 +1265,14 @@
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
 "@types/qs@*":
-  version "6.9.6"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1"
-  integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
 
 "@types/range-parser@*":
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
-  integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
 "@types/resolve@0.0.8":
   version "0.0.8"
@@ -1237,19 +1282,19 @@
     "@types/node" "*"
 
 "@types/serve-static@*":
-  version "1.13.9"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e"
-  integrity sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==
+  version "1.13.10"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
+  integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
   dependencies:
     "@types/mime" "^1"
     "@types/node" "*"
 
 "@types/sinon@^10.0.0":
-  version "10.0.0"
-  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.0.tgz#eecc3847af03d45ffe53d55aaaaf6ecb28b5e584"
-  integrity sha512-jDZ55oCKxqlDmoTBBbBBEx+N8ZraUVhggMZ9T5t+6/Dh8/4NiOjSUfpLrPiEwxQDlAe3wpAkoXhWvE6LibtsMQ==
+  version "10.0.2"
+  resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-10.0.2.tgz#f360d2f189c0fd433d14aeb97b9d705d7e4cc0e4"
+  integrity sha512-BHn8Bpkapj8Wdfxvh2jWIUoaYB/9/XhsL0oOvBfRagJtKlSl9NWPcFOz2lRukI9szwGxFtYZCTejJSqsGDbdmw==
   dependencies:
-    "@sinonjs/fake-timers" "^7.0.4"
+    "@sinonjs/fake-timers" "^7.1.0"
 
 "@types/whatwg-url@^6.4.0":
   version "6.4.0"
@@ -1264,19 +1309,19 @@
   integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==
 
 "@webcomponents/shadycss@^1.10.2", "@webcomponents/shadycss@^1.9.1":
-  version "1.10.2"
-  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.10.2.tgz#40e03cab6dc5e12f199949ba2b79e02f183d1e7b"
-  integrity sha512-9Iseu8bRtecb0klvv+WXZOVZatsRkbaH7M97Z+f+Pt909R4lDfgUODAnra23DOZTpeMTAkVpf4m/FZztN7Ox1A==
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/shadycss/-/shadycss-1.11.0.tgz#73e289996c002d8be694cd3be0e83c46ad25e7e0"
+  integrity sha512-L5O/+UPum8erOleNjKq6k58GVl3fNsEQdSOyh0EUhNmi7tHUyRuCJy1uqJiWydWcLARE5IPsMoPYMZmUGrz1JA==
 
 "@webcomponents/webcomponentsjs@^2.4.0", "@webcomponents/webcomponentsjs@^2.5.0":
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.5.0.tgz#61b27785a6ad5bfd68fa018201fe418b118cb38d"
-  integrity sha512-C0l51MWQZ9kLzcxOZtniOMohpIFdCLZum7/TEHv3XWFc1Fvt5HCpbSX84x8ltka/JuNKcuiDnxXFkiB2gaePcg==
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-2.6.0.tgz#7d1674c40bddf0c6dd974c44ffd34512fe7274ff"
+  integrity sha512-Moog+Smx3ORTbWwuPqoclr+uvfLnciVd6wdCaVscHPrxbmQ/IJKm3wbB7hpzJtXWjAq2l/6QMlO85aZiOdtv5Q==
 
 abortcontroller-polyfill@^1.4.0:
-  version "1.7.1"
-  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.1.tgz#27084bac87d78a7224c8ee78135d05df430c2d2f"
-  integrity sha512-yml9NiDEH4M4p0G4AcPkg8AAa4mF3nfYF28VQxaokpO67j9H7gWgmsVWJ/f1Rn+PzsnDYvzJzWIQzCqDKRvWlA==
+  version "1.7.3"
+  resolved "https://registry.yarnpkg.com/abortcontroller-polyfill/-/abortcontroller-polyfill-1.7.3.tgz#1b5b487bd6436b5b764fd52a612509702c3144b5"
+  integrity sha512-zetDJxd89y3X99Kvo4qFx8GKlt6GsvN3UcRZHwU6iFA/0KiOmhkTVhe8oRoTBiTVPZu09x3vCra47+w8Yz1+2Q==
 
 accepts@^1.3.5, accepts@~1.3.4:
   version "1.3.7"
@@ -1291,11 +1336,6 @@
   resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
   integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
 
-after@0.8.2:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
-  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
-
 ajv@^6.12.3:
   version "6.12.6"
   resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
@@ -1345,7 +1385,7 @@
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
   integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
 
-anymatch@~3.1.1:
+anymatch@~3.1.1, anymatch@~3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716"
   integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==
@@ -1358,20 +1398,15 @@
   resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
   integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
 
-array-back@^3.0.1:
+array-back@^3.0.1, array-back@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
 
 array-back@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.1.tgz#9b80312935a52062e1a233a9c7abeb5481b30e90"
-  integrity sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg==
-
-arraybuffer.slice@~0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
-  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e"
+  integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg==
 
 arrify@^2.0.1:
   version "2.0.1"
@@ -1395,11 +1430,6 @@
   resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b"
   integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==
 
-async-limiter@~1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
-  integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
-
 async@^2.6.2:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
@@ -1439,49 +1469,44 @@
     istanbul-lib-instrument "^3.3.0"
     test-exclude "^5.2.3"
 
-babel-plugin-polyfill-corejs2@^0.1.4:
-  version "0.1.10"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.1.10.tgz#a2c5c245f56c0cac3dbddbf0726a46b24f0f81d1"
-  integrity sha512-DO95wD4g0A8KRaHKi0D51NdGXzvpqVLnLu5BTvDlpqUEpTmeEtypgC1xqesORaWmiUOQI14UHKlzNd9iZ2G3ZA==
+babel-plugin-polyfill-corejs2@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.2.tgz#e9124785e6fd94f94b618a7954e5693053bf5327"
+  integrity sha512-kISrENsJ0z5dNPq5eRvcctITNHYXWOA4DUZRFYCz3jYCcvTb/A546LIddmoGNMVYg2U38OyFeNosQwI9ENTqIQ==
   dependencies:
-    "@babel/compat-data" "^7.13.0"
-    "@babel/helper-define-polyfill-provider" "^0.1.5"
+    "@babel/compat-data" "^7.13.11"
+    "@babel/helper-define-polyfill-provider" "^0.2.2"
     semver "^6.1.1"
 
-babel-plugin-polyfill-corejs3@^0.1.3:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.7.tgz#80449d9d6f2274912e05d9e182b54816904befd0"
-  integrity sha512-u+gbS9bbPhZWEeyy1oR/YaaSpod/KDT07arZHb80aTpl8H5ZBq+uN1nN9/xtX7jQyfLdPfoqI4Rue/MQSWJquw==
+babel-plugin-polyfill-corejs3@^0.2.2:
+  version "0.2.4"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.4.tgz#68cb81316b0e8d9d721a92e0009ec6ecd4cd2ca9"
+  integrity sha512-z3HnJE5TY/j4EFEa/qpQMSbcUJZ5JQi+3UFjXzn6pQCmIKc5Ug5j98SuYyH+m4xQnvKlMDIW4plLfgyVnd0IcQ==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.1.5"
-    core-js-compat "^3.8.1"
+    "@babel/helper-define-polyfill-provider" "^0.2.2"
+    core-js-compat "^3.14.0"
 
-babel-plugin-polyfill-regenerator@^0.1.2:
-  version "0.1.6"
-  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.1.6.tgz#0fe06a026fe0faa628ccc8ba3302da0a6ce02f3f"
-  integrity sha512-OUrYG9iKPKz8NxswXbRAdSwF0GhRdIEMTloQATJi4bDuFqrXaXcCUT/VGNrr8pBcjMh1RxZ7Xt9cytVJTJfvMg==
+babel-plugin-polyfill-regenerator@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.2.tgz#b310c8d642acada348c1fa3b3e6ce0e851bee077"
+  integrity sha512-Goy5ghsc21HgPDFtzRkSirpZVW35meGoTmTOb2bxqdl60ghub4xOidgNTHaZfQ2FaxQsKmwvXtOAkcIS4SMBWg==
   dependencies:
-    "@babel/helper-define-polyfill-provider" "^0.1.5"
-
-backo2@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
-  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
+    "@babel/helper-define-polyfill-provider" "^0.2.2"
 
 balanced-match@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@0.1.5:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
-  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+base64-arraybuffer@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
+  integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
 
-base64id@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/base64id/-/base64id-1.0.0.tgz#47688cb99bb6804f0e06d3e763b1c32e57d8e6b6"
-  integrity sha1-R2iMuZu2gE8OBtPnY7HDLlfY5rY=
+base64id@2.0.0, base64id@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
 
 bcrypt-pbkdf@^1.0.0:
   version "1.0.2"
@@ -1490,29 +1515,12 @@
   dependencies:
     tweetnacl "^0.14.3"
 
-better-assert@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
-  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
-  dependencies:
-    callsite "1.0.0"
-
 binary-extensions@^2.0.0:
   version "2.2.0"
   resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
   integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
 
-blob@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
-  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
-
-bluebird@^3.3.0:
-  version "3.7.2"
-  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
-  integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
-
-body-parser@^1.16.1:
+body-parser@^1.19.0:
   version "1.19.0"
   resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
   integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
@@ -1557,39 +1565,21 @@
     semver "^7.3.2"
     useragent "^2.3.0"
 
-browserslist@*, browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.14.5, browserslist@^4.16.0, browserslist@^4.16.3, browserslist@^4.9.1:
-  version "4.16.3"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717"
-  integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==
+browserslist@*, browserslist@^4.0.0, browserslist@^4.12.0, browserslist@^4.16.0, browserslist@^4.16.6, browserslist@^4.16.7, browserslist@^4.9.1:
+  version "4.16.7"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.7.tgz#108b0d1ef33c4af1b587c54f390e7041178e4335"
+  integrity sha512-7I4qVwqZltJ7j37wObBe3SoTz+nS8APaNcrBOlgoirb6/HbEU2XxW/LpUDTCngM6iauwFqmRTuOMfyKnFGY5JA==
   dependencies:
-    caniuse-lite "^1.0.30001181"
-    colorette "^1.2.1"
-    electron-to-chromium "^1.3.649"
+    caniuse-lite "^1.0.30001248"
+    colorette "^1.2.2"
+    electron-to-chromium "^1.3.793"
     escalade "^3.1.1"
-    node-releases "^1.1.70"
-
-buffer-alloc-unsafe@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
-  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
-
-buffer-alloc@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
-  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
-  dependencies:
-    buffer-alloc-unsafe "^1.1.0"
-    buffer-fill "^1.0.0"
-
-buffer-fill@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
-  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
+    node-releases "^1.1.73"
 
 buffer-from@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
-  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+  integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
 builtin-modules@^3.1.0:
   version "3.2.0"
@@ -1617,11 +1607,6 @@
     function-bind "^1.1.1"
     get-intrinsic "^1.0.2"
 
-callsite@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
-  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
-
 camel-case@^4.1.1:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a"
@@ -1650,10 +1635,10 @@
     lodash.memoize "^4.1.2"
     lodash.uniq "^4.5.0"
 
-caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001181:
-  version "1.0.30001207"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001207.tgz#364d47d35a3007e528f69adb6fecb07c2bb2cc50"
-  integrity sha512-UPQZdmAsyp2qfCTiMU/zqGSWOYaY9F9LL61V8f+8MrubsaDGpaHD9HRV/EWZGULZn0Hxu48SKzI5DgFwTvHuYw==
+caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001033, caniuse-lite@^1.0.30001248:
+  version "1.0.30001251"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001251.tgz#6853a606ec50893115db660f82c094d18f096d85"
+  integrity sha512-HOe1r+9VkU4TFmnU70z+r7OLmtR+/chB1rdcJUeQlAinjEeb0cKL20tlAtOagNZhbrtLnCvV19B4FmF1rgzl6A==
 
 caseless@~0.12.0:
   version "0.12.0"
@@ -1682,9 +1667,9 @@
     supports-color "^5.3.0"
 
 chalk@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
-  integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
   dependencies:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
@@ -1694,7 +1679,7 @@
   resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82"
   integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=
 
-chokidar@3.5.1, chokidar@^3.0.0, chokidar@^3.4.3:
+chokidar@3.5.1:
   version "3.5.1"
   resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
   integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
@@ -1709,6 +1694,21 @@
   optionalDependencies:
     fsevents "~2.3.1"
 
+chokidar@^3.0.0, chokidar@^3.4.3, chokidar@^3.5.1:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75"
+  integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
 clean-css@^4.2.3:
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
@@ -1759,12 +1759,12 @@
   resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
-colorette@^1.2.1:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
-  integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
+colorette@^1.2.2:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af"
+  integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==
 
-colors@^1.1.0:
+colors@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
   integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
@@ -1777,11 +1777,11 @@
     delayed-stream "~1.0.0"
 
 command-line-args@^5.0.2:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.1.1.tgz#88e793e5bb3ceb30754a86863f0401ac92fd369a"
-  integrity sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.0.tgz#087b02748272169741f1fd7c785b295df079b9be"
+  integrity sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==
   dependencies:
-    array-back "^3.0.1"
+    array-back "^3.1.0"
     find-replace "^3.0.0"
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
@@ -1806,20 +1806,10 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"
   integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==
 
-component-bind@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
-  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
-
-component-emitter@1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
-  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
-
-component-inherit@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
-  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
+component-emitter@~1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
+  integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
 
 compressible@^2.0.0:
   version "2.0.18"
@@ -1833,7 +1823,7 @@
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
-connect@^3.6.0:
+connect@^3.7.0:
   version "3.7.0"
   resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8"
   integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==
@@ -1856,16 +1846,16 @@
   integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
 
 convert-source-map@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
-  integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
+  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
   dependencies:
     safe-buffer "~5.1.1"
 
-cookie@0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
-  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
+cookie@~0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
+  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
 
 cookies@~0.8.0:
   version "0.8.0"
@@ -1876,16 +1866,16 @@
     keygrip "~1.1.0"
 
 core-js-bundle@^3.6.0, core-js-bundle@^3.8.1:
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.10.0.tgz#8559ce25d36f8094bcafdf7d1a24d50c706dcbe5"
-  integrity sha512-Rh+60FxNOd23Fm35vK/P3U1usjte2VNKBBW9bGZym5cV2Cc9pgviMQjSEOe8llIZAZjbr5NuL4GoeBK8DM3ewg==
+  version "3.16.1"
+  resolved "https://registry.yarnpkg.com/core-js-bundle/-/core-js-bundle-3.16.1.tgz#410c73317f7154dc4aac0674556b7003a7f4c47f"
+  integrity sha512-pPavAOLKXD2YXNBhS3jq4WMGJPeqgo4W9WZ7GebxXTZY/jvnD5ID+J3nUOCS7UXwCNsQKbbUg1+hp/4rmvzNeg==
 
-core-js-compat@^3.8.1, core-js-compat@^3.9.0:
-  version "3.10.0"
-  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.10.0.tgz#3600dc72869673c110215ee7a005a8609dea0fe1"
-  integrity sha512-9yVewub2MXNYyGvuLnMHcN1k9RkvB7/ofktpeKTIaASyB88YYqGzUnu0ywMMhJrDHOMiTjSHWGzR+i7Wb9Z1kQ==
+core-js-compat@^3.14.0, core-js-compat@^3.16.0:
+  version "3.16.1"
+  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.16.1.tgz#c44b7caa2dcb94b673a98f27eee1c8312f55bc2d"
+  integrity sha512-NHXQXvRbd4nxp9TEmooTJLUf94ySUG6+DSsscBpTftN1lQLQ4LjnWvc7AoIo4UjDsFF3hB8Uh5LLCRRdaiT5MQ==
   dependencies:
-    browserslist "^4.16.3"
+    browserslist "^4.16.7"
     semver "7.0.0"
 
 core-util-is@1.0.2:
@@ -1893,6 +1883,14 @@
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
   integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
 
+cors@~2.8.5:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
+  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
+  dependencies:
+    object-assign "^4"
+    vary "^1"
+
 custom-event@~1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
@@ -1905,11 +1903,16 @@
   dependencies:
     assert-plus "^1.0.0"
 
-date-format@^2.0.0:
+date-format@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/date-format/-/date-format-2.1.0.tgz#31d5b5ea211cf5fd764cd38baf9d033df7e125cf"
   integrity sha512-bYQuGLeFxhkxNOF3rcMtiZxvCBAquGzZm6oWA1oZ0g2THUzivaRhv8uOhdr19LmoobSOLoIAxeUK2RdbM8IFTA==
 
+date-format@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-3.0.0.tgz#eb8780365c7d2b1511078fb491e6479780f3ad95"
+  integrity sha512-eyTcpKOcamdhWJXj56DpQMo1ylSQpcGtGKXcU0Tb97+K56/CF5amAqqqNj0+KvA0iw2ynxtHWFsPDSClCxe48w==
+
 debounce@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
@@ -1922,20 +1925,27 @@
   dependencies:
     ms "2.0.0"
 
-debug@4.3.1, debug@^4.1.0, debug@^4.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, debug@^3.1.1, debug@^3.2.6:
+debug@^3.1.0, debug@^3.1.1:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
   integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
+debug@^4.1.0, debug@^4.1.1, debug@~4.3.1:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
+  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
+  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"
@@ -2017,7 +2027,7 @@
   resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
   integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
 
-dom-serialize@^2.2.0:
+dom-serialize@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
   integrity sha1-ViromZ9Evl6jB29UGdzVnrQ6yVs=
@@ -2053,10 +2063,10 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-electron-to-chromium@^1.3.649:
-  version "1.3.709"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.709.tgz#d7be0b5686a2fdfe8bad898faa3a428d04d8f656"
-  integrity sha512-LolItk2/ikSGQ7SN8UkuKVNMBZp3RG7Itgaxj1npsHRzQobj9JjMneZOZfLhtwlYBe5fCJ75k+cVCiDFUs23oA==
+electron-to-chromium@^1.3.793:
+  version "1.3.806"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.806.tgz#21502100f11aead6c501d1cd7f2504f16c936642"
+  integrity sha512-AH/otJLAAecgyrYp0XK1DPiGVWcOgwPeJBOLeuFQ5l//vhQhwC9u6d+GijClqJAmsHG4XDue81ndSQPohUu0xA==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
@@ -2075,45 +2085,25 @@
   dependencies:
     once "^1.4.0"
 
-engine.io-client@~3.2.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.2.1.tgz#6f54c0475de487158a1a7c77d10178708b6add36"
-  integrity sha512-y5AbkytWeM4jQr7m/koQLc5AxpRKC1hEVUb/s1FUAWEJq5AzJJ4NLvzuKPuxtDi5Mq755WuDvZ6Iv2rXj4PTzw==
+engine.io-parser@~4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-4.0.2.tgz#e41d0b3fb66f7bf4a3671d2038a154024edb501e"
+  integrity sha512-sHfEQv6nmtJrq6TKuIz5kyEKH/qSdK56H/A+7DnAuUPWosnIZAS2NHNcPLmyjtY3cGS/MqJdZbUjW97JU72iYg==
   dependencies:
-    component-emitter "1.2.1"
-    component-inherit "0.0.3"
-    debug "~3.1.0"
-    engine.io-parser "~2.1.1"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    parseqs "0.0.5"
-    parseuri "0.0.5"
-    ws "~3.3.1"
-    xmlhttprequest-ssl "~1.5.4"
-    yeast "0.1.2"
+    base64-arraybuffer "0.1.4"
 
-engine.io-parser@~2.1.0, engine.io-parser@~2.1.1:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.1.3.tgz#757ab970fbf2dfb32c7b74b033216d5739ef79a6"
-  integrity sha512-6HXPre2O4Houl7c4g7Ic/XzPnHBvaEmN90vtRO9uLmwtRqQmTOw0QMevL1TOfL2Cpu1VzsaTmMotQgMdkzGkVA==
-  dependencies:
-    after "0.8.2"
-    arraybuffer.slice "~0.0.7"
-    base64-arraybuffer "0.1.5"
-    blob "0.0.5"
-    has-binary2 "~1.0.2"
-
-engine.io@~3.2.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.2.1.tgz#b60281c35484a70ee0351ea0ebff83ec8c9522a2"
-  integrity sha512-+VlKzHzMhaU+GsCIg4AoXF1UdDFjHHwMmMKqMJNDNLlUlejz58FCy4LBqB2YVJskHGYl06BatYWKP2TVdVXE5w==
+engine.io@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-4.1.1.tgz#9a8f8a5ac5a5ea316183c489bf7f5b6cf91ace5b"
+  integrity sha512-t2E9wLlssQjGw0nluF6aYyfX8LwYU8Jj0xct+pAhfWfv/YrBn6TSNtEYsgxHIfaMqfrLx07czcMg9bMN6di+3w==
   dependencies:
     accepts "~1.3.4"
-    base64id "1.0.0"
-    cookie "0.3.1"
-    debug "~3.1.0"
-    engine.io-parser "~2.1.0"
-    ws "~3.3.1"
+    base64id "2.0.0"
+    cookie "~0.4.1"
+    cors "~2.8.5"
+    debug "~4.3.1"
+    engine.io-parser "~4.0.0"
+    ws "~7.4.2"
 
 ent@~2.2.0:
   version "2.2.0"
@@ -2320,15 +2310,15 @@
   resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
   integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
 
-flatted@^2.0.0:
+flatted@^2.0.1:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138"
   integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA==
 
 follow-redirects@^1.0.0:
-  version "1.13.3"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"
-  integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
+  integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
 
 forever-agent@~0.6.1:
   version "0.6.1"
@@ -2349,12 +2339,12 @@
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
   integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
 
-fs-extra@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
-  integrity sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==
+fs-extra@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+  integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
   dependencies:
-    graceful-fs "^4.1.2"
+    graceful-fs "^4.2.0"
     jsonfile "^4.0.0"
     universalify "^0.1.0"
 
@@ -2363,7 +2353,7 @@
   resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
-fsevents@~2.3.1:
+fsevents@~2.3.1, fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
   integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
@@ -2411,14 +2401,14 @@
   dependencies:
     assert-plus "^1.0.0"
 
-glob-parent@~5.1.0:
+glob-parent@~5.1.0, glob-parent@~5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   dependencies:
     is-glob "^4.0.1"
 
-glob@7.1.6, glob@^7.1.1, glob@^7.1.3:
+glob@7.1.6:
   version "7.1.6"
   resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6"
   integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
@@ -2430,15 +2420,27 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
+glob@^7.1.3, glob@^7.1.7:
+  version "7.1.7"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
+  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
 globals@^11.1.0:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
   integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
 
-graceful-fs@^4.1.2, graceful-fs@^4.1.6:
-  version "4.2.6"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee"
-  integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==
+graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.6:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
+  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
 
 growl@1.10.5:
   version "1.10.5"
@@ -2458,18 +2460,6 @@
     ajv "^6.12.3"
     har-schema "^2.0.0"
 
-has-binary2@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
-  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
-  dependencies:
-    isarray "2.0.1"
-
-has-cors@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
-  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
-
 has-flag@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
@@ -2480,11 +2470,18 @@
   resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-symbols@^1.0.1:
+has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
+has-tostringtag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
+  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
+  dependencies:
+    has-symbols "^1.0.2"
+
 has@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
@@ -2498,9 +2495,9 @@
   integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
 
 hosted-git-info@^2.1.4:
-  version "2.8.8"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
-  integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==
+  version "2.8.9"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
+  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 html-minifier-terser@^5.1.1:
   version "5.1.1"
@@ -2566,7 +2563,7 @@
     statuses ">= 1.5.0 < 2"
     toidentifier "1.0.0"
 
-http-proxy@^1.13.0:
+http-proxy@^1.18.1:
   version "1.18.1"
   resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
   integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
@@ -2591,11 +2588,6 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-indexof@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
-
 inflight@^1.0.4:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@@ -2632,16 +2624,16 @@
     binary-extensions "^2.0.0"
 
 is-core-module@^2.2.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==
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.5.0.tgz#f754843617c70bfd29b7bd87327400cda5c18491"
+  integrity sha512-TXCMSDsEHMEEZ6eCA8rwRDbLu55MRGmrctljsBX/2v1d9/GzqHOxW5c5oPSgrUt2vBFXebu9rGqckXGPWOlYpg==
   dependencies:
     has "^1.0.3"
 
 is-docker@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.0.tgz#b037c8815281edaad6c2562648a5f5f18839d5f7"
-  integrity sha512-K4GwB4i/HzhAzwP/XSlspzRdFTI9N8OxJOyOU7Y5Rz+p+WBokXWVWblaJeBkggthmoSV0OoGTH5thJNvplpkvQ==
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa"
+  integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==
 
 is-extglob@^2.1.1:
   version "2.1.1"
@@ -2659,9 +2651,11 @@
   integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
 
 is-generator-function@^1.0.7:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.8.tgz#dfb5c2b120e02b0a8d9d2c6806cd5621aa922f7b"
-  integrity sha512-2Omr/twNtufVZFr1GhxjOMFPAj2sjc/dKaIqBhvo4qciXfJmITGH6ZGd8eZYNHza8t1y0e01AuqRhJwfWp26WQ==
+  version "1.0.10"
+  resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.0.10.tgz#f1558baf1ac17e0deea7c0415c438351ff2b3c72"
+  integrity sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==
+  dependencies:
+    has-tostringtag "^1.0.0"
 
 is-glob@^4.0.1, is-glob@~4.0.1:
   version "4.0.1"
@@ -2686,9 +2680,9 @@
   integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
 
 is-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
-  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
+  integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
 
 is-typedarray@~1.0.0:
   version "1.0.0"
@@ -2707,22 +2701,10 @@
   resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
   integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
 
-isarray@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
-  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
-
-isbinaryfile@^3.0.0:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
-  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
-  dependencies:
-    buffer-alloc "^1.2.0"
-
-isbinaryfile@^4.0.2:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b"
-  integrity sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==
+isbinaryfile@^4.0.2, isbinaryfile@^4.0.8:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
+  integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
 
 isexe@^2.0.0:
   version "2.0.0"
@@ -2851,37 +2833,34 @@
   dependencies:
     minimist "^1.2.3"
 
-karma@^4.4.1:
-  version "4.4.1"
-  resolved "https://registry.yarnpkg.com/karma/-/karma-4.4.1.tgz#6d9aaab037a31136dc074002620ee11e8c2e32ab"
-  integrity sha512-L5SIaXEYqzrh6b1wqYC42tNsFMx2PWuxky84pK9coK09MvmL7mxii3G3bZBh/0rvD27lqDd0le9jyhzvwif73A==
+karma@^6.3.4:
+  version "6.3.4"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.3.4.tgz#359899d3aab3d6b918ea0f57046fd2a6b68565e6"
+  integrity sha512-hbhRogUYIulfkBTZT7xoPrCYhRBnBoqbbL4fszWD0ReFGUxU+LYBr3dwKdAluaDQ/ynT9/7C+Lf7pPNW4gSx4Q==
   dependencies:
-    bluebird "^3.3.0"
-    body-parser "^1.16.1"
+    body-parser "^1.19.0"
     braces "^3.0.2"
-    chokidar "^3.0.0"
-    colors "^1.1.0"
-    connect "^3.6.0"
+    chokidar "^3.5.1"
+    colors "^1.4.0"
+    connect "^3.7.0"
     di "^0.0.1"
-    dom-serialize "^2.2.0"
-    flatted "^2.0.0"
-    glob "^7.1.1"
-    graceful-fs "^4.1.2"
-    http-proxy "^1.13.0"
-    isbinaryfile "^3.0.0"
-    lodash "^4.17.14"
-    log4js "^4.0.0"
-    mime "^2.3.1"
-    minimatch "^3.0.2"
-    optimist "^0.6.1"
-    qjobs "^1.1.4"
-    range-parser "^1.2.0"
-    rimraf "^2.6.0"
-    safe-buffer "^5.0.1"
-    socket.io "2.1.1"
+    dom-serialize "^2.2.1"
+    glob "^7.1.7"
+    graceful-fs "^4.2.6"
+    http-proxy "^1.18.1"
+    isbinaryfile "^4.0.8"
+    lodash "^4.17.21"
+    log4js "^6.3.0"
+    mime "^2.5.2"
+    minimatch "^3.0.4"
+    qjobs "^1.2.0"
+    range-parser "^1.2.1"
+    rimraf "^3.0.2"
+    socket.io "^3.1.0"
     source-map "^0.6.1"
-    tmp "0.0.33"
-    useragent "2.3.0"
+    tmp "^0.2.1"
+    ua-parser-js "^0.7.28"
+    yargs "^16.1.1"
 
 keygrip@~1.1.0:
   version "1.1.0"
@@ -3034,7 +3013,7 @@
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.21:
+lodash@^4.17.14, lodash@^4.17.21:
   version "4.17.21"
   resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -3053,16 +3032,16 @@
   dependencies:
     chalk "^2.0.1"
 
-log4js@^4.0.0:
-  version "4.5.1"
-  resolved "https://registry.yarnpkg.com/log4js/-/log4js-4.5.1.tgz#e543625e97d9e6f3e6e7c9fc196dd6ab2cae30b5"
-  integrity sha512-EEEgFcE9bLgaYUKuozyFfytQM2wDHtXn4tAN41pkaxpNjAykv11GVdeI4tHtmPWW4Xrgh9R/2d7XYghDVjbKKw==
+log4js@^6.3.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.3.0.tgz#10dfafbb434351a3e30277a00b9879446f715bcb"
+  integrity sha512-Mc8jNuSFImQUIateBFwdOQcmC6Q5maU0VVvdC2R6XMb66/VnT+7WS4D/0EeNMZu1YODmJe5NIn2XftCzEocUgw==
   dependencies:
-    date-format "^2.0.0"
+    date-format "^3.0.0"
     debug "^4.1.1"
-    flatted "^2.0.0"
+    flatted "^2.0.1"
     rfdc "^1.1.4"
-    streamroller "^1.0.6"
+    streamroller "^2.2.4"
 
 lower-case@^2.0.2:
   version "2.0.2"
@@ -3098,24 +3077,24 @@
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
   integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
 
-mime-db@1.47.0, "mime-db@>= 1.43.0 < 2":
-  version "1.47.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.47.0.tgz#8cb313e59965d3c05cfbf898915a267af46a335c"
-  integrity sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==
+mime-db@1.49.0, "mime-db@>= 1.43.0 < 2":
+  version "1.49.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
+  integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==
 
 mime-types@^2.1.12, mime-types@^2.1.18, mime-types@^2.1.27, mime-types@~2.1.19, mime-types@~2.1.24:
-  version "2.1.30"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.30.tgz#6e7be8b4c479825f85ed6326695db73f9305d62d"
-  integrity sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==
+  version "2.1.32"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5"
+  integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==
   dependencies:
-    mime-db "1.47.0"
+    mime-db "1.49.0"
 
-mime@^2.3.1:
+mime@^2.5.2:
   version "2.5.2"
   resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
   integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
 
-minimatch@3.0.4, minimatch@^3.0.2, minimatch@^3.0.4:
+minimatch@3.0.4, minimatch@^3.0.4:
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
@@ -3127,11 +3106,6 @@
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
-minimist@~0.0.1:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
-  integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
-
 mkdirp@^0.5.5:
   version "0.5.5"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
@@ -3204,13 +3178,13 @@
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
   integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
 
-nise@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/nise/-/nise-4.1.0.tgz#8fb75a26e90b99202fa1e63f448f58efbcdedaf6"
-  integrity sha512-eQMEmGN/8arp0xsvGoQ+B1qvSkR73B1nWSCh7nOt5neMCtwcQVYQGdzQMhcNscktTsWB54xnlSQFzOAPJD8nXA==
+nise@^5.0.1:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.0.tgz#713ef3ed138252daef20ec035ab62b7a28be645c"
+  integrity sha512-W5WlHu+wvo3PaKLsJJkgPup2LrsXCcm7AWwyNZkUnn5rwPkuPBi3Iwk5SQtN0mv+K65k7nKKjwNQ30wg3wLAQQ==
   dependencies:
     "@sinonjs/commons" "^1.7.0"
-    "@sinonjs/fake-timers" "^6.0.0"
+    "@sinonjs/fake-timers" "^7.0.4"
     "@sinonjs/text-encoding" "^0.7.1"
     just-extend "^4.0.2"
     path-to-regexp "^1.7.0"
@@ -3228,10 +3202,10 @@
   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
   integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
 
-node-releases@^1.1.70:
-  version "1.1.71"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
-  integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==
+node-releases@^1.1.73:
+  version "1.1.74"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.74.tgz#e5866488080ebaa70a93b91144ccde06f3c3463e"
+  integrity sha512-caJBVempXZPepZoZAPCWRTNxYQ+xtG/KAi4ozTA5A+nJ7IU+kLQCbqaUjb5Rwy14M9upBWiQ4NutcmW04LJSRw==
 
 normalize-package-data@^2.3.2:
   version "2.5.0"
@@ -3253,16 +3227,11 @@
   resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
   integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
 
-object-assign@^4.0.1:
+object-assign@^4, object-assign@^4.0.1:
   version "4.1.1"
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
 
-object-component@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
-  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
-
 object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
@@ -3305,14 +3274,6 @@
     is-docker "^2.0.0"
     is-wsl "^2.1.1"
 
-optimist@^0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
-  integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY=
-  dependencies:
-    minimist "~0.0.1"
-    wordwrap "~0.0.2"
-
 os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
@@ -3372,20 +3333,6 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
-parseqs@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
-  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
-  dependencies:
-    better-assert "~1.0.0"
-
-parseuri@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
-  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
-  dependencies:
-    better-assert "~1.0.0"
-
 parseurl@^1.3.2, parseurl@~1.3.3:
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
@@ -3420,9 +3367,9 @@
   integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
 
 path-parse@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
-  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
 path-to-regexp@^1.7.0:
   version "1.8.0"
@@ -3449,9 +3396,9 @@
   integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=
 
 picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
-  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
+  integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
 
 pify@^3.0.0:
   version "3.0.0"
@@ -3511,7 +3458,7 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
-qjobs@^1.1.4:
+qjobs@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
   integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==
@@ -3533,7 +3480,7 @@
   dependencies:
     safe-buffer "^5.1.0"
 
-range-parser@^1.2.0:
+range-parser@^1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
   integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
@@ -3572,6 +3519,13 @@
   dependencies:
     picomatch "^2.2.1"
 
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
 reduce-flatten@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
@@ -3590,9 +3544,9 @@
   integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
 
 regenerator-runtime@^0.13.3, regenerator-runtime@^0.13.4, regenerator-runtime@^0.13.7:
-  version "0.13.7"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
-  integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
+  version "0.13.9"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
+  integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
 
 regenerator-transform@^0.14.2:
   version "0.14.5"
@@ -3697,14 +3651,7 @@
   resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.0.tgz#d0b7c441ab2720d05dc4cf26e01c89631d9da08b"
   integrity sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==
 
-rimraf@^2.6.0:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
-  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
-  dependencies:
-    glob "^7.1.3"
-
-rimraf@^3.0.2:
+rimraf@^3.0.0, rimraf@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
   integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
@@ -3712,13 +3659,13 @@
     glob "^7.1.3"
 
 rollup@^2.7.2:
-  version "2.44.0"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.44.0.tgz#8da324d1c4fd12beef9ae6e12f4068265b6d95eb"
-  integrity sha512-rGSF4pLwvuaH/x4nAS+zP6UNn5YUDWf/TeEU5IoXSZKBbKRNTCI3qMnYXKZgrC0D2KzS2baiOZt1OlqhMu5rnQ==
+  version "2.56.2"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.2.tgz#a045ff3f6af53ee009b5f5016ca3da0329e5470f"
+  integrity sha512-s8H00ZsRi29M2/lGdm1u8DJpJ9ML8SUOpVVBd33XNeEeL3NVaTiUcSBHzBdF3eAyR0l7VSpsuoVUGrRHq7aPwQ==
   optionalDependencies:
-    fsevents "~2.3.1"
+    fsevents "~2.3.2"
 
-safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+safe-buffer@5.1.2, safe-buffer@~5.1.1:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
@@ -3783,62 +3730,45 @@
   integrity sha512-Dqfl70x6JiwYDujd33ZTbtCK0t52E7+H2swdWQNSTzfsolSa6LJHnTpN4T9OpJJEq4bxuzHRLFO9RBcy/UfrMQ==
 
 sinon@^10.0.0:
-  version "10.0.0"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-10.0.0.tgz#52279f97e35646ff73d23207d0307977c9b81430"
-  integrity sha512-XAn5DxtGVJBlBWYrcYKEhWCz7FLwZGdyvANRyK06419hyEpdT0dMc5A8Vcxg5SCGHc40CsqoKsc1bt1CbJPfNw==
+  version "10.0.1"
+  resolved "https://registry.yarnpkg.com/sinon/-/sinon-10.0.1.tgz#0d1a13ecb86f658d15984f84273e57745b1f4c57"
+  integrity sha512-1rf86mvW4Mt7JitEIgmNaLXaWnrWd/UrVKZZlL+kbeOujXVf9fmC4kQEQ/YeHoiIA23PLNngYWK+dngIx/AumA==
   dependencies:
     "@sinonjs/commons" "^1.8.1"
-    "@sinonjs/fake-timers" "^6.0.1"
-    "@sinonjs/samsam" "^5.3.1"
+    "@sinonjs/fake-timers" "^7.0.4"
+    "@sinonjs/samsam" "^6.0.1"
     diff "^4.0.2"
-    nise "^4.1.0"
+    nise "^5.0.1"
     supports-color "^7.1.0"
 
-socket.io-adapter@~1.1.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
-  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
+socket.io-adapter@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.1.0.tgz#edc5dc36602f2985918d631c1399215e97a1b527"
+  integrity sha512-+vDov/aTsLjViYTwS9fPy5pEtTkrbEKsw2M+oVSoFGw6OD1IpvlV1VPhUzNbofCQ8oyMbdYJqDtGdmHQK6TdPg==
 
-socket.io-client@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.1.1.tgz#dcb38103436ab4578ddb026638ae2f21b623671f"
-  integrity sha512-jxnFyhAuFxYfjqIgduQlhzqTcOEQSn+OHKVfAxWaNWa7ecP7xSNk2Dx/3UEsDcY7NcFafxvNvKPmmO7HTwTxGQ==
+socket.io-parser@~4.0.3:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.0.4.tgz#9ea21b0d61508d18196ef04a2c6b9ab630f4c2b0"
+  integrity sha512-t+b0SS+IxG7Rxzda2EVvyBZbvFPBCjJoyHuE0P//7OAsN23GItzDRdWa6ALxZI/8R5ygK7jAR6t028/z+7295g==
   dependencies:
-    backo2 "1.0.2"
-    base64-arraybuffer "0.1.5"
-    component-bind "1.0.0"
-    component-emitter "1.2.1"
-    debug "~3.1.0"
-    engine.io-client "~3.2.0"
-    has-binary2 "~1.0.2"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    object-component "0.0.3"
-    parseqs "0.0.5"
-    parseuri "0.0.5"
-    socket.io-parser "~3.2.0"
-    to-array "0.1.4"
+    "@types/component-emitter" "^1.2.10"
+    component-emitter "~1.3.0"
+    debug "~4.3.1"
 
-socket.io-parser@~3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.2.0.tgz#e7c6228b6aa1f814e6148aea325b51aa9499e077"
-  integrity sha512-FYiBx7rc/KORMJlgsXysflWx/RIvtqZbyGLlHZvjfmPTPeuD/I8MaW7cfFrj5tRltICJdgwflhfZ3NVVbVLFQA==
+socket.io@^3.1.0:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-3.1.2.tgz#06e27caa1c4fc9617547acfbb5da9bc1747da39a"
+  integrity sha512-JubKZnTQ4Z8G4IZWtaAZSiRP3I/inpy8c/Bsx2jrwGrTbKeVU5xd6qkKMHpChYeM3dWZSO0QACiGK+obhBNwYw==
   dependencies:
-    component-emitter "1.2.1"
-    debug "~3.1.0"
-    isarray "2.0.1"
-
-socket.io@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.1.1.tgz#a069c5feabee3e6b214a75b40ce0652e1cfb9980"
-  integrity sha512-rORqq9c+7W0DAK3cleWNSyfv/qKXV99hV4tZe+gGLfBECw3XEhBy7x85F3wypA9688LKjtwO9pX9L33/xQI8yA==
-  dependencies:
-    debug "~3.1.0"
-    engine.io "~3.2.0"
-    has-binary2 "~1.0.2"
-    socket.io-adapter "~1.1.0"
-    socket.io-client "2.1.1"
-    socket.io-parser "~3.2.0"
+    "@types/cookie" "^0.4.0"
+    "@types/cors" "^2.8.8"
+    "@types/node" ">=10.0.0"
+    accepts "~1.3.4"
+    base64id "~2.0.0"
+    debug "~4.3.1"
+    engine.io "~4.1.0"
+    socket.io-adapter "~2.1.0"
+    socket.io-parser "~4.0.3"
 
 source-map-support@^0.5.19, source-map-support@~0.5.12:
   version "0.5.19"
@@ -3880,9 +3810,9 @@
     spdx-license-ids "^3.0.0"
 
 spdx-license-ids@^3.0.0:
-  version "3.0.7"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65"
-  integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==
+  version "3.0.10"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
+  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
 
 sshpk@^1.7.0:
   version "1.16.1"
@@ -3904,16 +3834,14 @@
   resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
   integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
 
-streamroller@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-1.0.6.tgz#8167d8496ed9f19f05ee4b158d9611321b8cacd9"
-  integrity sha512-3QC47Mhv3/aZNFpDDVO44qQb9gwB9QggMEE0sQmkTAwBVYdBRWISdsywlkfm5II1Q5y/pmrHflti/IgmIzdDBg==
+streamroller@^2.2.4:
+  version "2.2.4"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-2.2.4.tgz#c198ced42db94086a6193608187ce80a5f2b0e53"
+  integrity sha512-OG79qm3AujAM9ImoqgWEY1xG4HX+Lw+yY6qZj9R1K2mhF5bEmQ849wvrb+4vt4jLMLzwXttJlQbOdPOQVRv7DQ==
   dependencies:
-    async "^2.6.2"
-    date-format "^2.0.0"
-    debug "^3.2.6"
-    fs-extra "^7.0.1"
-    lodash "^4.17.14"
+    date-format "^2.1.0"
+    debug "^4.1.1"
+    fs-extra "^8.1.0"
 
 "string-width@^1.0.2 || 2":
   version "2.1.1"
@@ -3985,9 +3913,9 @@
     has-flag "^4.0.0"
 
 systemjs@^6.3.1, systemjs@^6.8.3:
-  version "6.8.3"
-  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.8.3.tgz#67e27f49242e9d81c2b652b204ae54e8bfcc75a3"
-  integrity sha512-UcTY+FEA1B7e+bpJk1TI+a9Na6LG7wFEqW7ED16cLqLuQfI/9Ri0rsXm3tKlIgNoHyLHZycjdAOijzNbzelgwA==
+  version "6.10.2"
+  resolved "https://registry.yarnpkg.com/systemjs/-/systemjs-6.10.2.tgz#c9870217bddf9cfd25d12d4fcd1989541ef1207c"
+  integrity sha512-PwaC0Z6Y1E6gFekY2u38EC5+5w2M65jYVrD1aAcOptpHVhCwPIwPFJvYJyryQKUyeuQ5bKKI3PBHWNjdE9aizg==
 
 table-layout@^1.0.1:
   version "1.0.2"
@@ -4032,17 +3960,19 @@
   dependencies:
     any-promise "^1.0.0"
 
-tmp@0.0.33, tmp@0.0.x:
+tmp@0.0.x:
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
   integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
   dependencies:
     os-tmpdir "~1.0.2"
 
-to-array@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
-  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
+tmp@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14"
+  integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==
+  dependencies:
+    rimraf "^3.0.0"
 
 to-fast-properties@^2.0.0:
   version "2.0.0"
@@ -4082,9 +4012,9 @@
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
 tslib@^2.0.3:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
-  integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
+  integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw==
 
 tsscmp@1.0.6:
   version "1.0.6"
@@ -4126,10 +4056,10 @@
   resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
   integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 
-ultron@~1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c"
-  integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og==
+ua-parser-js@^0.7.28:
+  version "0.7.28"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
+  integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
 
 unicode-canonical-property-names-ecmascript@^1.0.4:
   version "1.0.4"
@@ -4171,7 +4101,7 @@
   dependencies:
     punycode "^2.1.0"
 
-useragent@2.3.0, useragent@^2.3.0:
+useragent@^2.3.0:
   version "2.3.0"
   resolved "https://registry.yarnpkg.com/useragent/-/useragent-2.3.0.tgz#217f943ad540cb2128658ab23fc960f6a88c9972"
   integrity sha512-4AoH4pxuSvHCjqLO04sU6U/uE65BYza8l/KKBS0b0hnUPWi+cQ2BpeTEwejCSx9SPV5/U03nniDTrWx5NrmKdw==
@@ -4202,7 +4132,7 @@
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
-vary@^1.1.2:
+vary@^1, vary@^1.1.2:
   version "1.1.2"
   resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
   integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
@@ -4261,11 +4191,6 @@
   dependencies:
     string-width "^1.0.2 || 2"
 
-wordwrap@~0.0.2:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
-  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
-
 wordwrapjs@^4.0.0:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.1.tgz#d9790bccfb110a0fc7836b5ebce0937b37a8b98f"
@@ -4293,19 +4218,10 @@
   resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-ws@~3.3.1:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-3.3.3.tgz#f1cf84fe2d5e901ebce94efaece785f187a228f2"
-  integrity sha512-nnWLa/NwZSt4KQJu51MYlCcSQ5g7INpOrOMt4XV8j4dqTXdmlUmSHQ8/oLC069ckre0fRsgfvsKwbTdtKLCDkA==
-  dependencies:
-    async-limiter "~1.0.0"
-    safe-buffer "~5.1.0"
-    ultron "~1.1.0"
-
-xmlhttprequest-ssl@~1.5.4:
-  version "1.5.5"
-  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
-  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+ws@~7.4.2:
+  version "7.4.6"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
+  integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
 
 y18n@^5.0.5:
   version "5.0.8"
@@ -4333,9 +4249,9 @@
   integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==
 
 yargs-parser@^20.2.2:
-  version "20.2.7"
-  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.7.tgz#61df85c113edfb5a7a4e36eb8aa60ef423cbc90a"
-  integrity sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==
+  version "20.2.9"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
+  integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
 
 yargs-unparser@2.0.0:
   version "2.0.0"
@@ -4347,7 +4263,7 @@
     flat "^5.0.2"
     is-plain-obj "^2.1.0"
 
-yargs@16.2.0:
+yargs@16.2.0, yargs@^16.1.1:
   version "16.2.0"
   resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
   integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
@@ -4360,11 +4276,6 @@
     y18n "^5.0.5"
     yargs-parser "^20.2.2"
 
-yeast@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
-  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
-
 ylru@^1.2.0:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/ylru/-/ylru-1.2.1.tgz#f576b63341547989c1de7ba288760923b27fe84f"
diff --git a/proto/cache.proto b/proto/cache.proto
index 292a225..16e5e95 100644
--- a/proto/cache.proto
+++ b/proto/cache.proto
@@ -78,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: 27
+// Next ID: 28
 message ChangeNotesStateProto {
   // Effectively required, even though the corresponding ChangeNotesState field
   // is optional, since the field is only absent when NoteDb is disabled, in
@@ -226,6 +226,8 @@
   // Epoch millis.
   int64 merged_on_millis = 25;
   bool has_merged_on = 26;
+
+  repeated SubmitRequirementResultProto submit_requirement_result = 27;
 }
 
 // Serialized form of com.google.gerrit.server.query.change.ConflictKey
@@ -266,13 +268,14 @@
 // com.google.gerrit.server.account.externalids.AllExternalIds.
 // Next ID: 2
 message AllExternalIdsProto {
-  // Next ID: 6
+  // Next ID: 7
   message ExternalIdProto {
     string key = 1;
     int32 accountId = 2;
     string email = 3;
     string password = 4;
     bytes blobId = 5;
+    bool isCaseInsensitive = 6;
   }
   repeated ExternalIdProto external_id = 1;
 }
@@ -445,7 +448,7 @@
 }
 
 // Serialized form of com.google.gerrit.common.data.LabelType.
-// Next ID: 19
+// Next ID: 21
 message LabelTypeProto {
   string name = 1;
   string function = 2; // ENUM as String
@@ -466,6 +469,43 @@
   bool can_override = 17;
   repeated string ref_patterns = 18;
   bool copy_all_scores_if_list_of_files_did_not_change = 19;
+  string copy_condition = 20;
+}
+
+// Serialized form of com.google.gerrit.entities.SubmitRequirement.
+// Next ID: 7
+message SubmitRequirementProto {
+  string name = 1;
+  string description = 2;
+  string applicability_expression = 3;
+  string submittability_expression = 4;
+  string override_expression = 5;
+  bool allow_override_in_child_projects = 6;
+}
+
+// Serialized form of com.google.gerrit.entities.SubmitRequirementResult.
+// Next ID: 7
+message SubmitRequirementResultProto {
+  SubmitRequirementProto submit_requirement = 1;
+  SubmitRequirementExpressionResultProto applicability_expression_result = 2;
+  SubmitRequirementExpressionResultProto submittability_expression_result = 3;
+  SubmitRequirementExpressionResultProto override_expression_result = 4;
+
+  // Patchset commit ID at which the submit requirements are evaluated.
+  bytes commit = 5;
+
+  // Whether this result was created from a legacy submit record.
+  bool legacy = 6;
+}
+
+// Serialized form of com.google.gerrit.entities.SubmitRequirementExpressionResult.
+// Next ID: 6
+message SubmitRequirementExpressionResultProto {
+  string expression = 1;
+  string status = 2; // enum as string
+  string error_message = 3;
+  repeated string passing_atoms = 4;
+  repeated string failing_atoms = 5;
 }
 
 // Serialized form of com.google.gerrit.server.project.ConfiguredMimeTypes.
@@ -496,7 +536,7 @@
 }
 
 // Serialized form of com.google.gerrit.entities.CachedProjectConfigProto.
-// Next ID: 19
+// Next ID: 20
 message CachedProjectConfigProto {
   ProjectProto project = 1;
   repeated GroupReferenceProto group_list = 2;
@@ -516,6 +556,7 @@
   map<string, ExtensionPanelSectionProto> extension_panels = 16;
   map<string, string> plugin_configs = 17;
   map<string, string> project_level_configs = 18;
+  repeated SubmitRequirementProto submit_requirement_sections = 19;
 
   // Next ID: 2
   message ExtensionPanelSectionProto {
@@ -594,7 +635,7 @@
 
 // Serialized form of a collection of
 // com.google.gerrit.server.patch.gitfilediff.GitFileDiffCacheImpl.Key
-// Next ID: 8
+// Next ID: 9
 message GitFileDiffKeyProto {
   string project = 1;
   bytes a_tree = 2;
@@ -603,10 +644,11 @@
   int32 rename_score = 5;
   string diff_algorithm = 6; // ENUM as string
   string whitepsace = 7; // ENUM as string
+  bool useTimeout = 8;
 }
 
 // Serialized form of com.google.gerrit.server.patch.gitfilediff.GitFileDiff
-// Next ID: 11
+// Next ID: 12
 message GitFileDiffProto {
   message Edit {
     int32 begin_a = 1;
@@ -624,11 +666,12 @@
   string new_mode = 8; // ENUM as string
   string change_type = 9; // ENUM as string
   string patch_type = 10; // ENUM as string
+  bool negative = 11;
 }
 
 // Serialized form of
 // com.google.gerrit.server.patch.fileDiff.FileDiffCacheKey
-// Next ID: 8
+// Next ID: 9
 message FileDiffKeyProto {
   string project = 1;
   bytes old_commit = 2;
@@ -637,11 +680,12 @@
   int32 rename_score = 5;
   string diff_algorithm = 6;
   string whitespace = 7;
+  bool useTimeout = 8;
 }
 
 // Serialized form of
 // com.google.gerrit.server.patch.filediff.FileDiffOutput
-// Next ID: 12
+// Next ID: 13
 message FileDiffOutputProto {
   // Next ID: 5
   message Edit {
@@ -672,4 +716,20 @@
   bytes old_commit = 9;
   bytes new_commit = 10;
   ComparisonType comparison_type = 11;
+  bool negative = 12;
+}
+
+// Serialized form of com.google.gerrit.server.approval.ApprovalCacheImpl.Key.
+// Next ID: 5
+message PatchSetApprovalsKeyProto {
+  string project = 1;
+  int32 change_id = 2;
+  int32 patch_set_id = 3;
+  bytes id = 4;
+}
+
+// Repeated version of PatchSetApprovalProto
+// Next ID: 2
+message AllPatchSetApprovalsProto {
+  repeated devtools.gerritcodereview.PatchSetApproval approval = 1;
 }
diff --git a/proto/entities.proto b/proto/entities.proto
index 84c7fbd..de8f647 100644
--- a/proto/entities.proto
+++ b/proto/entities.proto
@@ -35,7 +35,6 @@
 message Change {
   required Change_Id change_id = 1;
   optional Change_Key change_key = 2;
-  optional int32 row_version = 3;
   optional fixed64 created_on = 4;
   optional fixed64 last_updated_on = 5;
   optional Account_Id owner_account_id = 7;
@@ -54,6 +53,7 @@
   optional PatchSet_Id cherry_pick_of = 24;
 
   // Deleted fields, should not be reused:
+  reserved 3;    // row_version
   reserved 6;    // sortkey
   reserved 9;    // open
   reserved 11;   // nbrPatchSets
@@ -133,6 +133,7 @@
   optional string tag = 6;
   optional Account_Id real_account_id = 7;
   optional bool post_submit = 8;
+  optional bool copied = 9;
 
   // Deleted fields, should not be reused:
   reserved 4;  // changeOpen
diff --git a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
index 11717fb..9428c2f 100644
--- a/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
+++ b/resources/com/google/gerrit/httpd/raw/PolyGerritIndexHtml.soy
@@ -168,7 +168,7 @@
   <link rel="stylesheet" href="{$staticResourcePath}/styles/main.css">{\n}
 
   <body unresolved>{\n}
-  <gr-app id="app"></gr-app>{\n}
+  <gr-app id="pg-app"></gr-app>{\n}
 
   // Load gr-app.js after <gr-app ...> tag because gr-router expects that
   // <gr-app ...> already exists in the document when script is executed.
diff --git a/resources/com/google/gerrit/server/change/ChangeMessages.properties b/resources/com/google/gerrit/server/change/ChangeMessages.properties
index 3763d8e..c78f5a3 100644
--- a/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -7,6 +7,7 @@
 reviewerInvalid = {0} is not a valid user identifier
 reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
+groupRemovalIsNotAllowed = Groups can't be removed from reviewers, so can't remove {0}.
 groupIsNotAllowed = The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
 groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/resources/com/google/gerrit/server/config/CapabilityConstants.properties b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
index ba590ee..1a355eb 100644
--- a/resources/com/google/gerrit/server/config/CapabilityConstants.properties
+++ b/resources/com/google/gerrit/server/config/CapabilityConstants.properties
@@ -15,9 +15,9 @@
 runAs = Run As
 runGC = Run Garbage Collection
 streamEvents = Stream Events
+viewAccess = View Access
 viewAllAccounts = View All Accounts
 viewCaches = View Caches
 viewConnections = View Connections
 viewPlugins = View Plugins
 viewQueue = View Queue
-viewAccess = View Access
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeader.soy b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
index 5dfe671..3d0edab 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeader.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeader.soy
@@ -20,10 +20,10 @@
   {@param attentionSet: ?}
   {if $attentionSet}
     Attention is currently required from:{sp}
-    {for $attentionSetUser in $attentionSet}
+    {for $attentionSetUser, $index in $attentionSet}
       {$attentionSetUser}
       // add commas or dot.
-      {if isLast($attentionSetUser)}.
+      {if $index == length($attentionSet) - 1}.
       {else},{sp}
       {/if}
     {/for}
diff --git a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
index 191737f..0d8da38 100644
--- a/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
+++ b/resources/com/google/gerrit/server/mail/ChangeHeaderHtml.soy
@@ -21,10 +21,10 @@
   {@param attentionSet: ?}
   {if $attentionSet}
     <p> Attention is currently required from:{sp}
-    {for $attentionSetUser in $attentionSet}
+    {for $attentionSetUser, $index in $attentionSet}
       {$attentionSetUser}
       //add commas or dot.
-      {if isLast($attentionSetUser)}.
+      {if $index == length($attentionSet) - 1}.
       {else},{sp}
       {/if}
     {/for} </p>
diff --git a/resources/com/google/gerrit/server/mail/Comment.soy b/resources/com/google/gerrit/server/mail/Comment.soy
index 4b923e6..4b66401 100644
--- a/resources/com/google/gerrit/server/mail/Comment.soy
+++ b/resources/com/google/gerrit/server/mail/Comment.soy
@@ -48,8 +48,8 @@
         {\n}
       {/if}
 
-      {for $line in $comment.lines}
-        {if isFirst($line)}
+      {for $line, $index in $comment.lines}
+        {if $index == 0}
           {if $comment.startLine != 0}
             {$comment.link}
           {/if}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
index a3ed3ee..ae2a9c4 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewer.soy
@@ -26,8 +26,8 @@
   {@param email: ?}
   {@param fromName: ?}
   {$fromName} has removed{sp}
-  {for $reviewerName in $email.reviewerNames}
-    {if not isFirst($reviewerName)},{sp}{/if}
+  {for $reviewerName, $index in $email.reviewerNames}
+    {if $index > 0},{sp}{/if}
     {$reviewerName}
   {/for}{sp}
   from this change.{sp}
diff --git a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
index 76a9199..fdcbbe7 100644
--- a/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
+++ b/resources/com/google/gerrit/server/mail/DeleteReviewerHtml.soy
@@ -25,9 +25,9 @@
     {$fromName}{sp}
     <strong>
       removed{sp}
-      {for $reviewerName in $email.reviewerNames}
-        {if not isFirst($reviewerName)}
-          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
+      {for $reviewerName, $index in $email.reviewerNames}
+        {if $index > 0}
+          {if $index == (length($email.reviewerNames) - 1)}{sp}and{else},{/if}{sp}
         {/if}
         {$reviewerName}
       {/for}
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
index 1241665..a9e0a44 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejection.soy
@@ -65,6 +65,14 @@
   {call InboundEmailRejectionFooter /}
 {/template}
 
+{template InboundEmailRejection_CHANGE_NOT_FOUND kind="text"}
+  Gerrit Code Review was unable to process your email because the change was not found.
+  {\n}
+  Maybe the project doesn't exist or is not visible? Maybe the change is not visible or got
+  deleted?
+  {call InboundEmailRejectionFooter /}
+{/template}
+
 {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.
diff --git a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
index 6937d13..3444b7f 100644
--- a/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
+++ b/resources/com/google/gerrit/server/mail/InboundEmailRejectionHtml.soy
@@ -82,6 +82,17 @@
   {call InboundEmailRejectionFooterHtml /}
 {/template}
 
+{template InboundEmailRejectionHtml_CHANGE_NOT_FOUND}
+  <p>
+    Gerrit Code Review was unable to process your email because the change was not found.
+  </p>
+  <p>
+    Maybe the project doesn't exist or is not visible? Maybe the change is not visible or got
+    deleted?
+  <p>
+  {call InboundEmailRejectionFooterHtml /}
+{/template}
+
 {template InboundEmailRejectionHtml_COMMENT_REJECTED}
   <p>
     Gerrit Code Review rejected one or more comments because they did not pass validation, or
diff --git a/resources/com/google/gerrit/server/mail/Merged.soy b/resources/com/google/gerrit/server/mail/Merged.soy
index 998610c..b8a19fc 100644
--- a/resources/com/google/gerrit/server/mail/Merged.soy
+++ b/resources/com/google/gerrit/server/mail/Merged.soy
@@ -26,18 +26,22 @@
   {@param email: ?}
   {@param fromName: ?}
   {$fromName} has submitted this change.
+
   {if $email.changeUrl} ( {$email.changeUrl} ){/if}{\n}
   {\n}
+
+  {if $email.stickyApprovalDiff} ( {$email.stickyApprovalDiff} ){/if}
+
   Change subject: {$change.subject}{\n}
   ......................................................................{\n}
   {\n}
   {$email.changeDetail}
   {$email.approvals}
+  {\n}
   {if $email.includeDiff}
     {\n}
     {\n}
     {$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 1f75a04..ac4afb3 100644
--- a/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -32,16 +32,23 @@
     </p>
   {/if}
 
+  {if $email.stickyApprovalDiffHtml}
+    {call mailTemplate.UnifiedDiff}
+      {param diffLines: $email.stickyApprovalDiffHtml /}
+    {/call}
+  {/if}
+
   <div style="white-space:pre-wrap">{$email.approvals}</div>
 
   {call mailTemplate.Pre}
     {param content: $email.changeDetail /}
   {/call}
 
+  {\n}
+
   {if $email.includeDiff}
     {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 0fe8835..c5f34b4 100644
--- a/resources/com/google/gerrit/server/mail/NewChange.soy
+++ b/resources/com/google/gerrit/server/mail/NewChange.soy
@@ -23,23 +23,35 @@
 {template NewChange kind="text"}
   {@param change: ?}
   {@param email: ?}
+  {@param fromName: ?}
   {@param ownerName: ?}
   {@param patchSet: ?}
   {@param projectName: ?}
-  {if $email.reviewerNames}
-    Hello{sp}
-    {for $reviewerName in $email.reviewerNames}
-      {if not isFirst($reviewerName)},{sp}{/if}
-      {$reviewerName}
-    {/for},
+  {if $email.reviewerNames or $email.removedReviewerNames}
+   {if $email.reviewerNames}
+      Hello{sp}
+      {for $reviewerName, $index in $email.reviewerNames}
+        {if $index > 0},{sp}{/if}
+        {$reviewerName}
+      {/for},
 
-    {\n}
-    {\n}
+      {\n}
+      {\n}
 
-    I'd like you to do a code review.
-
+      I'd like you to do a code review.
+      {\n}
+    {/if}
+    {if $email.removedReviewerNames}
+      {$fromName} has removed{sp}
+      {for $reviewerName, $index in $email.removedReviewerNames}
+        {if $index > 0},{sp}{/if}
+        {$reviewerName}
+      {/for}{sp}
+      from this change.{sp}
+      {\n}
+    {/if}
     {if $email.changeUrl}
-      {sp}Please visit
+      Please visit
 
       {\n}
       {\n}
diff --git a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index dbb3d8a..008226f 100644
--- a/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -26,16 +26,35 @@
   {@param patchSet: ?}
   {@param projectName: ?}
   <p>
-    {if $email.reviewerNames}
-      {$fromName} would like{sp}
-      {for $reviewerName in $email.reviewerNames}
-        {if not isFirst($reviewerName)}
-          {if isLast($reviewerName)}{sp}and{else},{/if}{sp}
-        {/if}
-        {$reviewerName}
-      {/for}{sp}
-      to <strong>review</strong> this change
-      {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+    {if $email.reviewerNames or $email.removedReviewerNames}
+      {if $email.reviewerNames}
+        {$fromName} would like{sp}
+        {for $reviewerName, $index in $email.reviewerNames}
+          {if $index > 0}
+            {if $index == length($email.reviewerNames) - 1}{sp}and{else},{/if}{sp}
+          {/if}
+          {$reviewerName}
+        {/for}{sp}
+        to <strong>review</strong> this change
+        {if $fromName != $ownerName}{sp}authored by {$ownerName}{/if}.
+        {\n}
+      {/if}
+      {if $email.removedReviewerNames}
+        <p>
+          {$fromName}{sp}
+          <strong>
+            removed{sp}
+            {for $reviewerName, $index in $email.removedReviewerNames}
+              {if $index > 0}
+                {if $index == length($email.removedReviewerNames) - 1}{sp}and{else},{/if}{sp}
+              {/if}
+              {$reviewerName}
+            {/for}
+          </strong>{sp}
+          from this change.
+        </p>
+        {\n}
+      {/if}
     {else}
       {$ownerName} has uploaded this change for <strong>review</strong>.
     {/if}
diff --git a/resources/com/google/gerrit/server/mime/mime-types.properties b/resources/com/google/gerrit/server/mime/mime-types.properties
index 6ab682c..fd2280c 100644
--- a/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -116,6 +116,7 @@
 jsx = text/jsx
 jsp = application/x-jsp
 kt = text/x-kotlin
+kts = text/x-kotlin
 less = text/x-less
 lhs = text/x-literate-haskell
 lisp = text/x-common-lisp
diff --git a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
index 3a40d22..e1d6f22 100755
--- a/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
+++ b/resources/com/google/gerrit/server/tools/root/hooks/commit-msg
@@ -16,6 +16,8 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+set -u
+
 # avoid [[ which is not POSIX sh.
 if test "$#" != 1 ; then
   echo "$0 requires an argument."
@@ -28,12 +30,17 @@
 fi
 
 # Do not create a change id if requested
-if test "false" = "`git config --bool --get gerrit.createChangeId`" ; then
+if test "false" = "$(git config --bool --get gerrit.createChangeId)" ; then
   exit 0
 fi
 
-# $RANDOM will be undefined if not using bash, so don't use set -u
-random=$( (whoami ; hostname ; date; cat $1 ; echo $RANDOM) | git hash-object --stdin)
+if git rev-parse --verify HEAD >/dev/null 2>&1; then
+  refhash="$(git rev-parse HEAD)"
+else
+  refhash="$(git hash-object -t tree /dev/null)"
+fi
+
+random=$({ git var GIT_COMMITTER_IDENT ; echo "$refhash" ; cat "$1"; } | git hash-object --stdin)
 dest="$1.tmp.${random}"
 
 trap 'rm -f "${dest}"' EXIT
diff --git a/resources/log4j.properties b/resources/log4j.properties
index 39246b3..2898cfc 100644
--- a/resources/log4j.properties
+++ b/resources/log4j.properties
@@ -41,11 +41,5 @@
 log4j.logger.org.openid4java.server.RealmVerifier=ERROR
 log4j.logger.org.openid4java.message.AuthSuccess=ERROR
 
-# Silence non-critical messages from c3p0 (if used).
-#
-log4j.logger.com.mchange.v2.c3p0=WARN
-log4j.logger.com.mchange.v2.resourcepool=WARN
-log4j.logger.com.mchange.v2.sql=WARN
-
 # Silence non-critical messages from apache.http
 log4j.logger.org.apache.http=WARN
diff --git a/tools/BUILD b/tools/BUILD
index 545a206..47a2a2e 100644
--- a/tools/BUILD
+++ b/tools/BUILD
@@ -68,77 +68,398 @@
 # Error Prone errors enabled by default; see ../.bazelrc for how this is
 # enabled. This warnings list is originally based on:
 # https://github.com/bazelbuild/BUILD_file_generator/blob/master/tools/bazel_defs/java.bzl
+# Additionally, items used internally in google is added. Such items have
+# the same or higher verbosity level than in google.
 # However, feel free to add any additional errors. Thus far they have all been pretty useful.
-# TODO(davido): Enable ImmutableAnnotationChecker again when these issues are fixed:
-# https://github.com/google/error-prone/issues/1348
-# https://github.com/bazelbuild/bazel/issues/9378
+# All warnings are commented to avoid noise in the output.
+# Newer versions of error-prone have XepDisableAllWarnings flag which could
+# be used instead of commenting. Bazel should be updated to use a new version
+# of error-prone.
 java_package_configuration(
     name = "error_prone",
     javacopts = [
         "-XepDisableWarningsInGeneratedCode",
+        # The XepDisableWarningsInGeneratedCode disables only warnings, but
+        # not errors. We should manually exclude all files generated by
+        # AutoValue; such files always start $AutoValue_.....
+        # XepExcludedPaths is a regexp. If you need more paths - use | as
+        # separator.
+        "-XepExcludedPaths:.*/\\\\$$AutoValue_.*\\.java",
+        "-Xep:AlmostJavadoc:ERROR",
+        "-Xep:AlwaysThrows:ERROR",
         "-Xep:AmbiguousMethodReference:ERROR",
+        "-Xep:AnnotateFormatMethod:ERROR",
+        "-Xep:ArgumentSelectionDefectChecker:ERROR",
+        "-Xep:ArrayAsKeyOfSetOrMap:ERROR",
+        "-Xep:ArrayEquals:ERROR",
+        "-Xep:ArrayFillIncompatibleType:ERROR",
+        "-Xep:ArrayHashCode:ERROR",
+        "-Xep:ArrayToString:ERROR",
+        "-Xep:ArraysAsListPrimitiveArray:ERROR",
+        "-Xep:AssertEqualsArgumentOrderChecker:ERROR",
+        "-Xep:AssertionFailureIgnored:ERROR",
+        "-Xep:AsyncCallableReturnsNull:ERROR",
+        "-Xep:AsyncFunctionReturnsNull:ERROR",
+        "-Xep:AutoValueConstructorOrderChecker:ERROR",
         "-Xep:AutoValueFinalMethods:ERROR",
+        # "-Xep:AutoValueImmutableFields:WARN",
+        # "-Xep:AutoValueSubclassLeaked:WARN",
         "-Xep:BadAnnotationImplementation:ERROR",
         "-Xep:BadComparable:ERROR",
+        "-Xep:BadImport:ERROR",
+        "-Xep:BadInstanceof:ERROR",
+        "-Xep:BadShiftAmount:ERROR",
+        "-Xep:BanSerializableRead:ERROR",
+        "-Xep:BigDecimalEquals:ERROR",
+        "-Xep:BigDecimalLiteralDouble:ERROR",
         "-Xep:BoxedPrimitiveConstructor:ERROR",
+        "-Xep:BoxedPrimitiveEquality:ERROR",
+        "-Xep:BundleDeserializationCast:ERROR",
+        "-Xep:ByteBufferBackingArray:ERROR",
+        "-Xep:CacheLoaderNull:ERROR",
         "-Xep:CannotMockFinalClass:ERROR",
+        "-Xep:CanonicalDuration:ERROR",
+        # "-Xep:CatchAndPrintStackTrace:WARN",
+        "-Xep:CatchFail:ERROR",
+        "-Xep:ChainedAssertionLosesContext:ERROR",
+        "-Xep:ChainingConstructorIgnoresParameter:ERROR",
+        "-Xep:CharacterGetNumericValue:ERROR",
+        "-Xep:CheckNotNullMultipleTimes:ERROR",
+        "-Xep:CheckReturnValue:ERROR",
         "-Xep:ClassCanBeStatic:ERROR",
+        "-Xep:ClassName:ERROR",
         "-Xep:ClassNewInstance:ERROR",
+        "-Xep:CollectionIncompatibleType:ERROR",
+        "-Xep:CollectionToArraySafeParameter:ERROR",
+        "-Xep:CollectionUndefinedEquality:ERROR",
+        "-Xep:CollectorShouldNotUseState:ERROR",
+        "-Xep:ComparableAndComparator:ERROR",
+        "-Xep:ComparableType:ERROR",
+        "-Xep:CompareToZero:ERROR",
+        "-Xep:ComparingThisWithNull:ERROR",
+        "-Xep:ComparisonOutOfRange:ERROR",
+        "-Xep:CompatibleWithAnnotationMisuse:ERROR",
+        "-Xep:CompileTimeConstant:ERROR",
+        "-Xep:ComplexBooleanConstant:ERROR",
+        "-Xep:ComputeIfAbsentAmbiguousReference:ERROR",
+        "-Xep:ConditionalExpressionNumericPromotion:ERROR",
+        "-Xep:ConstantOverflow:ERROR",
+        "-Xep:DaggerProvidesNull:ERROR",
+        "-Xep:DangerousLiteralNull:ERROR",
+        "-Xep:DateChecker:ERROR",
         "-Xep:DateFormatConstant:ERROR",
+        "-Xep:DeadException:ERROR",
+        "-Xep:DeadThread:ERROR",
         "-Xep:DefaultCharset:ERROR",
+        # "-Xep:DefaultPackage:WARN",
+        "-Xep:DepAnn:ERROR",
+        "-Xep:DeprecatedVariable:ERROR",
+        "-Xep:DiscardedPostfixExpression:ERROR",
+        "-Xep:DoNotCall:ERROR",
+        "-Xep:DoNotCallSuggester:ERROR",
+        "-Xep:DoNotClaimAnnotations:ERROR",
+        "-Xep:DoNotMock:ERROR",
+        "-Xep:DoNotMockAutoValue:ERROR",
+        "-Xep:DoubleBraceInitialization:ERROR",
         "-Xep:DoubleCheckedLocking:ERROR",
-        "-Xep:ElementsCountedInLoop:ERROR",
+        "-Xep:DuplicateMapKeys:ERROR",
+        "-Xep:DurationFrom:ERROR",
+        "-Xep:DurationGetTemporalUnit:ERROR",
+        "-Xep:DurationTemporalUnit:ERROR",
+        "-Xep:DurationToLongTimeUnit:ERROR",
+        "-Xep:EmptyBlockTag:ERROR",
+        # "-Xep:EmptyCatch:WARN",
+        "-Xep:EmptySetMultibindingContributions:ERROR",
+        # "-Xep:EqualsGetClass:WARN",
         "-Xep:EqualsHashCode:ERROR",
         "-Xep:EqualsIncompatibleType:ERROR",
+        "-Xep:EqualsNaN:ERROR",
+        "-Xep:EqualsNull:ERROR",
+        "-Xep:EqualsReference:ERROR",
+        "-Xep:EqualsUnsafeCast:ERROR",
+        "-Xep:EqualsUsingHashCode:ERROR",
+        "-Xep:EqualsWrongThing:ERROR",
+        "-Xep:ErroneousThreadPoolConstructorChecker:ERROR",
+        # "-Xep:EscapedEntity:WARN",
         "-Xep:ExpectedExceptionChecker:ERROR",
+        "-Xep:ExtendingJUnitAssert:ERROR",
+        "-Xep:ExtendsAutoValue:ERROR",
+        "-Xep:FallThrough:ERROR",
         "-Xep:Finally:ERROR",
+        "-Xep:FloatCast:ERROR",
+        "-Xep:FloatingPointAssertionWithinEpsilon:ERROR",
         "-Xep:FloatingPointLiteralPrecision:ERROR",
+        "-Xep:FloggerArgumentToString:ERROR",
+        "-Xep:FloggerFormatString:ERROR",
+        "-Xep:FloggerLogVarargs:ERROR",
+        "-Xep:FloggerSplitLogStatement:ERROR",
+        "-Xep:FloggerStringConcatenation:ERROR",
+        "-Xep:ForOverride:ERROR",
+        "-Xep:FormatString:ERROR",
         "-Xep:FormatStringAnnotation:ERROR",
         "-Xep:FragmentInjection:ERROR",
         "-Xep:FragmentNotInstantiable:ERROR",
+        "-Xep:FromTemporalAccessor:ERROR",
         "-Xep:FunctionalInterfaceClash:ERROR",
-        "-Xep:FutureReturnValueIgnored:ERROR",
+        "-Xep:FunctionalInterfaceMethodChanged:ERROR",
+        # "-Xep:FutureReturnValueIgnored:ERROR", // this check has a bug.
+        "-Xep:FuturesGetCheckedIllegalExceptionType:ERROR",
+        "-Xep:GetClassOnAnnotation:ERROR",
+        "-Xep:GetClassOnClass:ERROR",
         "-Xep:GetClassOnEnum:ERROR",
-        "-Xep:ImmutableAnnotationChecker:OFF",
+        "-Xep:GuardedBy:ERROR",
+        "-Xep:GuiceAssistedInjectScoping:ERROR",
+        "-Xep:GuiceAssistedParameters:ERROR",
+        "-Xep:HashtableContains:ERROR",
+        "-Xep:HidingField:ERROR",
+        "-Xep:IdentityBinaryExpression:ERROR",
+        "-Xep:IdentityHashMapBoxing:ERROR",
+        "-Xep:IdentityHashMapUsage:ERROR",
+        "-Xep:IgnoredPureGetter:ERROR",
+        "-Xep:Immutable:ERROR",
+        "-Xep:ImmutableAnnotationChecker:ERROR",
         "-Xep:ImmutableEnumChecker:ERROR",
+        "-Xep:ImmutableModification:ERROR",
+        "-Xep:Incomparable:ERROR",
+        "-Xep:IncompatibleArgumentType:ERROR",
         "-Xep:IncompatibleModifiers:ERROR",
+        # "-Xep:InconsistentCapitalization:WARN",
+        "-Xep:InconsistentHashCode:ERROR",
+        "-Xep:IncrementInForLoopAndHeader:ERROR",
+        "-Xep:IndexOfChar:ERROR",
+        "-Xep:InexactVarargsConditional:ERROR",
+        "-Xep:InfiniteRecursion:ERROR",
         "-Xep:InjectOnConstructorOfAbstractClass:ERROR",
+        "-Xep:InheritDoc:ERROR",
+        # "-Xep:InlineFormatString:WARN",
+        "-Xep:InlineMeInliner:ERROR",
+        "-Xep:InlineMeSuggester:ERROR",
+        "-Xep:InlineMeValidator:ERROR",
         "-Xep:InputStreamSlowMultibyteRead:ERROR",
+        "-Xep:InsecureCryptoUsage:ERROR",
+        "-Xep:InstanceOfAndCastMatchWrongType:ERROR",
+        "-Xep:InstantTemporalUnit:ERROR",
+        "-Xep:IntLongMath:ERROR",
+        "-Xep:InvalidBlockTag:ERROR",
+        "-Xep:InvalidInlineTag:ERROR",
+        "-Xep:InvalidJavaTimeConstant:ERROR",
+        "-Xep:InvalidLink:ERROR",
+        # "-Xep:InvalidParam:WARN",
+        "-Xep:InvalidPatternSyntax:ERROR",
+        "-Xep:InvalidThrows:ERROR",
+        "-Xep:InvalidThrowsLink:ERROR",
+        "-Xep:InvalidTimeZoneID:ERROR",
+        "-Xep:InvalidZoneId:ERROR",
+        "-Xep:IsInstanceIncompatibleType:ERROR",
+        "-Xep:IsInstanceOfClass:ERROR",
+        "-Xep:IsLoggableTagLength:ERROR",
         "-Xep:IterableAndIterator:ERROR",
+        "-Xep:IterablePathParameter:ERROR",
         "-Xep:JUnit3FloatingPointComparisonWithoutDelta:ERROR",
+        "-Xep:JUnit3TestNotRun:ERROR",
+        "-Xep:JUnit4ClassAnnotationNonStatic:ERROR",
+        "-Xep:JUnit4ClassUsedInJUnit3:ERROR",
+        "-Xep:JUnit4SetUpNotRun:ERROR",
+        "-Xep:JUnit4TearDownNotRun:ERROR",
+        "-Xep:JUnit4TestNotRun:ERROR",
+        "-Xep:JUnit4TestsNotRunWithinEnclosed:ERROR",
         "-Xep:JUnitAmbiguousTestClass:ERROR",
-        "-Xep:LiteralClassName:ERROR",
+        "-Xep:JUnitAssertSameCheck:ERROR",
+        "-Xep:JUnitParameterMethodNotFound:ERROR",
+        "-Xep:JavaDurationGetSecondsGetNano:ERROR",
+        "-Xep:JavaDurationWithNanos:ERROR",
+        "-Xep:JavaDurationWithSeconds:ERROR",
+        "-Xep:JavaInstantGetSecondsGetNano:ERROR",
+        "-Xep:JavaLangClash:ERROR",
+        "-Xep:JavaLocalDateTimeGetNano:ERROR",
+        "-Xep:JavaLocalTimeGetNano:ERROR",
+        "-Xep:JavaPeriodGetDays:ERROR",
+        "-Xep:JavaTimeDefaultTimeZone:ERROR",
+        "-Xep:JavaUtilDate:ERROR",
+        # "-Xep:JdkObsolete:WARN",
+        "-Xep:JodaConstructors:ERROR",
+        "-Xep:JodaDateTimeConstants:ERROR",
+        "-Xep:JodaDurationWithMillis:ERROR",
+        "-Xep:JodaInstantWithMillis:ERROR",
+        "-Xep:JodaNewPeriod:ERROR",
+        "-Xep:JodaPlusMinusLong:ERROR",
+        "-Xep:JodaTimeConverterManager:ERROR",
+        "-Xep:JodaToSelf:ERROR",
+        "-Xep:JodaWithDurationAddedLong:ERROR",
+        "-Xep:LiteByteStringUtf8:ERROR",
+        "-Xep:LiteEnumValueOf:ERROR",
+        "-Xep:LiteProtoToString:ERROR",
+        "-Xep:LocalDateTemporalAmount:ERROR",
+        "-Xep:LockNotBeforeTry:ERROR",
+        "-Xep:LockOnBoxedPrimitive:ERROR",
+        "-Xep:LogicalAssignment:ERROR",
+        "-Xep:LongFloatConversion:ERROR",
+        "-Xep:LongLiteralLowerCaseSuffix:ERROR",
+        "-Xep:LoopConditionChecker:ERROR",
+        "-Xep:LoopOverCharArray:ERROR",
+        "-Xep:LossyPrimitiveCompare:ERROR",
+        "-Xep:MathAbsoluteRandom:ERROR",
+        "-Xep:MathRoundIntLong:ERROR",
+        "-Xep:MemoizeConstantVisitorStateLookups:ERROR",
+        "-Xep:MislabeledAndroidString:ERROR",
         "-Xep:MissingCasesInEnumSwitch:ERROR",
         "-Xep:MissingFail:ERROR",
         "-Xep:MissingOverride:ERROR",
+        "-Xep:MissingSummary:ERROR",
+        "-Xep:MissingSuperCall:ERROR",
+        "-Xep:MissingTestCall:ERROR",
+        "-Xep:MisusedDayOfYear:ERROR",
+        "-Xep:MisusedWeekYear:ERROR",
+        "-Xep:MixedDescriptors:ERROR",
+        # "-Xep:MixedMutabilityReturnType:WARN",
+        "-Xep:MockitoUsage:ERROR",
+        "-Xep:ModifiedButNotUsed:ERROR",
+        "-Xep:ModifyCollectionInEnhancedForLoop:ERROR",
+        "-Xep:ModifySourceCollectionInStream:ERROR",
+        "-Xep:ModifyingCollectionWithItself:ERROR",
+        "-Xep:MultipleParallelOrSequentialCalls:ERROR",
+        "-Xep:MultipleUnaryOperatorsInMethodCall:ERROR",
+        "-Xep:MustBeClosedChecker:ERROR",
         "-Xep:MutableConstantField:ERROR",
+        # "-Xep:MutablePublicArray:WARN",
+        "-Xep:NCopiesOfChar:ERROR",
         "-Xep:NarrowingCompoundAssignment:ERROR",
+        "-Xep:NestedInstanceOfConditions:ERROR",
         "-Xep:NonAtomicVolatileUpdate:ERROR",
+        "-Xep:NonCanonicalStaticImport:ERROR",
+        # "-Xep:NonCanonicalType:WARN",
+        "-Xep:NonFinalCompileTimeConstant:ERROR",
         "-Xep:NonOverridingEquals:ERROR",
+        "-Xep:NonRuntimeAnnotation:ERROR",
+        "-Xep:NullOptional:ERROR",
+        "-Xep:NullTernary:ERROR",
         "-Xep:NullableConstructor:ERROR",
         "-Xep:NullablePrimitive:ERROR",
+        "-Xep:NullablePrimitiveArray:ERROR",
         "-Xep:NullableVoid:ERROR",
+        "-Xep:ObjectEqualsForPrimitives:ERROR",
         "-Xep:ObjectToString:ERROR",
+        "-Xep:ObjectsHashCodePrimitive:ERROR",
         "-Xep:OperatorPrecedence:ERROR",
+        "-Xep:OptionalEquality:ERROR",
+        "-Xep:OptionalMapToOptional:ERROR",
+        "-Xep:OptionalMapUnusedValue:ERROR",
+        "-Xep:OptionalNotPresent:ERROR",
+        "-Xep:OptionalOfRedundantMethod:ERROR",
+        "-Xep:OrphanedFormatString:ERROR",
+        "-Xep:OutlineNone:ERROR",
+        "-Xep:OverlappingQualifierAndScopeAnnotation:ERROR",
+        "-Xep:OverrideThrowableToString:ERROR",
+        "-Xep:Overrides:ERROR",
         "-Xep:OverridesGuiceInjectableMethod:ERROR",
+        "-Xep:OverridesJavaxInjectableMethod:ERROR",
+        "-Xep:PackageInfo:ERROR",
+        "-Xep:ParameterName:ERROR",
+        "-Xep:ParametersButNotParameterized:ERROR",
+        "-Xep:ParcelableCreator:ERROR",
+        "-Xep:PeriodFrom:ERROR",
+        "-Xep:PeriodGetTemporalUnit:ERROR",
+        "-Xep:PeriodTimeMath:ERROR",
+        "-Xep:PreconditionsCheckNotNullRepeated:ERROR",
         "-Xep:PreconditionsInvalidPlaceholder:ERROR",
-        "-Xep:ProtoFieldPreconditionsCheckNotNull:ERROR",
+        "-Xep:PrimitiveAtomicReference:ERROR",
+        "-Xep:PrivateSecurityContractProtoAccess:ERROR",
+        # "-Xep:ProtectedMembersInFinalClass:WARN",
+        "-Xep:ProtoBuilderReturnValueIgnored:ERROR",
+        "-Xep:ProtoDurationGetSecondsGetNano:ERROR",
+        "-Xep:ProtoFieldNullComparison:ERROR",
+        "-Xep:ProtoRedundantSet:ERROR",
+        "-Xep:ProtoStringFieldReferenceEquality:ERROR",
+        "-Xep:ProtoTimestampGetSecondsGetNano:ERROR",
+        "-Xep:ProtoTruthMixedDescriptors:ERROR",
         "-Xep:ProtocolBufferOrdinal:ERROR",
+        "-Xep:ProvidesMethodOutsideOfModule:ERROR",
+        "-Xep:RandomCast:ERROR",
+        "-Xep:RandomModInteger:ERROR",
+        "-Xep:ReachabilityFenceUsage:ERROR",
+        "-Xep:RectIntersectReturnValueIgnored:ERROR",
         "-Xep:ReferenceEquality:ERROR",
+        "-Xep:RefersToDaggerCodegen:ERROR",
+        "-Xep:RemovedInJDK11:ERROR",
         "-Xep:RequiredModifiers:ERROR",
+        "-Xep:RestrictedApiChecker:ERROR",
+        "-Xep:RethrowReflectiveOperationExceptionAsLinkageError:ERROR",
+        "-Xep:ReturnFromVoid:ERROR",
+        "-Xep:ReturnValueIgnored:ERROR",
+        "-Xep:RxReturnValueIgnored:ERROR",
+        # "-Xep:SameNameButDifferent:WARN",
+        "-Xep:SelfAssignment:ERROR",
+        "-Xep:SelfComparison:ERROR",
+        "-Xep:SelfEquals:ERROR",
         "-Xep:ShortCircuitBoolean:ERROR",
-        "-Xep:SimpleDateFormatConstant:ERROR",
+        "-Xep:ShouldHaveEvenArgs:ERROR",
+        "-Xep:SizeGreaterThanOrEqualsZero:ERROR",
+        "-Xep:StaticAssignmentInConstructor:ERROR",
         "-Xep:StaticGuardedByInstance:ERROR",
+        "-Xep:StaticMockMember:ERROR",
+        "-Xep:StaticQualifiedUsingExpression:ERROR",
+        "-Xep:StreamToString:ERROR",
+        "-Xep:StringBuilderInitWithChar:ERROR",
         "-Xep:StringEquality:ERROR",
+        # "-Xep:StringSplitter:WARN",
+        "-Xep:SubstringOfZero:ERROR",
+        "-Xep:SuppressWarningsDeprecated:ERROR",
+        "-Xep:SwigMemoryLeak:ERROR",
         "-Xep:SynchronizeOnNonFinalField:ERROR",
+        "-Xep:TemporalAccessorGetChronoField:ERROR",
+        "-Xep:TestParametersNotInitialized:ERROR",
+        "-Xep:TheoryButNoTheories:ERROR",
+        # "-Xep:ThreadJoinLoop:WARN",
+        "-Xep:ThreadLocalUsage:ERROR",
+        # "-Xep:ThreadPriorityCheck:WARN",
+        "-Xep:ThreeLetterTimeZoneID:ERROR",
+        "-Xep:ThrowIfUncheckedKnownChecked:ERROR",
+        "-Xep:ThrowNull:ERROR",
+        "-Xep:TimeUnitConversionChecker:ERROR",
+        "-Xep:ToStringReturnsNull:ERROR",
+        "-Xep:TreeToString:ERROR",
+        "-Xep:TruthAssertExpected:ERROR",
         "-Xep:TruthConstantAsserts:ERROR",
+        "-Xep:TruthGetOrDefault:ERROR",
+        "-Xep:TruthIncompatibleType:ERROR",
+        "-Xep:TruthSelfEquals:ERROR",
+        "-Xep:TryFailThrowable:ERROR",
+        "-Xep:TypeEquals:ERROR",
+        "-Xep:TypeNameShadowing:ERROR",
+        "-Xep:TypeParameterQualifier:ERROR",
         "-Xep:TypeParameterShadowing:ERROR",
         "-Xep:TypeParameterUnusedInFormals:ERROR",
         "-Xep:URLEqualsHashCode:ERROR",
+        # "-Xep:UndefinedEquals:WARN",
+        "-Xep:UnescapedEntity:ERROR",
+        "-Xep:UnnecessaryAssignment:ERROR",
+        "-Xep:UnnecessaryCheckNotNull:ERROR",
+        # "-Xep:UnnecessaryLambda:WARN",
+        "-Xep:UnnecessaryMethodInvocationMatcher:ERROR",
+        "-Xep:UnnecessaryMethodReference:ERROR",
+        # "-Xep:UnnecessaryParentheses:WARN",
+        "-Xep:UnnecessaryTypeArgument:ERROR",
+        "-Xep:UnrecognisedJavadocTag:ERROR",
+        "-Xep:UnsafeFinalization:ERROR",
+        "-Xep:UnsafeReflectiveConstructionCast:ERROR",
         "-Xep:UnsynchronizedOverridesSynchronized:ERROR",
+        "-Xep:UnusedAnonymousClass:ERROR",
+        "-Xep:UnusedCollectionModifiedInPlace:ERROR",
         "-Xep:UnusedException:ERROR",
+        "-Xep:UnusedMethod:ERROR",
+        "-Xep:UnusedNestedClass:ERROR",
+        "-Xep:UnusedVariable:ERROR",
+        "-Xep:UseBinds:ERROR",
+        "-Xep:UseCorrectAssertInTests:ERROR",
+        "-Xep:VarTypeName:ERROR",
+        "-Xep:VariableNameSameAsType:ERROR",
         "-Xep:WaitNotInLoop:ERROR",
+        "-Xep:WakelockReleasedDangerously:ERROR",
         "-Xep:WildcardImport:ERROR",
+        "-Xep:WithSignatureDiscouraged:ERROR",
+        "-Xep:WrongOneof:ERROR",
+        "-Xep:XorPower:ERROR",
+        "-Xep:ZoneIdOfZ:ERROR",
     ],
     packages = ["error_prone_packages"],
 )
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index c3186ab..c8d6e4b 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,167 +1,9 @@
 load("@npm//@bazel/rollup:index.bzl", "rollup_bundle")
 load("@npm//@bazel/terser:index.bzl", "terser_minified")
-load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 
-NPMJS = "NPMJS"
-
-GERRIT = "GERRIT:"
-
-def _npm_tarball(name):
-    return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
-
-def _npm_binary_impl(ctx):
-    """rule to download a NPM archive."""
-    name = ctx.name
-    version = NPM_VERSIONS[name]
-    sha1 = NPM_SHA1S[name]
-
-    dir = "%s-%s" % (name, version)
-    filename = "%s.tgz" % dir
-    base = "%s@%s.npm_binary.tgz" % (name, version)
-    dest = ctx.path(base)
-    repository = ctx.attr.repository
-    if repository == GERRIT:
-        url = "https://gerrit-maven.storage.googleapis.com/npm-packages/%s" % filename
-    elif repository == NPMJS:
-        url = "https://registry.npmjs.org/%s/-/%s" % (name, filename)
-    else:
-        fail("repository %s not in {%s,%s}" % (repository, GERRIT, NPMJS))
-
-    python = ctx.which("python3")
-    script = ctx.path(ctx.attr._download_script)
-
-    args = [python, script, "-o", dest, "-u", url, "-v", sha1]
-    out = ctx.execute(args)
-    if out.return_code:
-        fail("failed %s: %s" % (args, out.stderr))
-    ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
-
-npm_binary = repository_rule(
-    attrs = {
-        "repository": attr.string(default = NPMJS),
-        # Label resolves within repo of the .bzl file.
-        "_download_script": attr.label(default = Label("//tools:download_file.py")),
-    },
-    local = True,
-    implementation = _npm_binary_impl,
-)
-
 ComponentInfo = provider()
 
-# for use in repo rules.
-def _run_npm_binary_str(ctx, tarball, args):
-    python_bin = ctx.which("python3")
-    return " ".join([
-        str(python_bin),
-        str(ctx.path(ctx.attr._run_npm)),
-        str(ctx.path(tarball)),
-    ] + args)
-
-def _bower_archive(ctx):
-    """Download a bower package."""
-    download_name = "%s__download_bower.zip" % ctx.name
-    renamed_name = "%s__renamed.zip" % ctx.name
-    version_name = "%s__version.json" % ctx.name
-
-    cmd = [
-        ctx.which("python3"),
-        ctx.path(ctx.attr._download_bower),
-        "-b",
-        "%s" % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
-        "-n",
-        ctx.name,
-        "-p",
-        ctx.attr.package,
-        "-v",
-        ctx.attr.version,
-        "-s",
-        ctx.attr.sha1,
-        "-o",
-        download_name,
-    ]
-
-    out = ctx.execute(cmd)
-    if out.return_code:
-        fail("failed %s: %s" % (cmd, out.stderr))
-
-    _bash(ctx, " && ".join([
-        "TMP=$(mktemp -d || mktemp -d -t bazel-tmp)",
-        "TZ=UTC",
-        "export UTC",
-        "cd $TMP",
-        "mkdir bower_components",
-        "cd bower_components",
-        "unzip %s" % ctx.path(download_name),
-        "cd ..",
-        "find . -exec touch -t 198001010000 '{}' ';'",
-        "zip -Xr %s bower_components" % renamed_name,
-        "cd ..",
-        "rm -rf ${TMP}",
-    ]))
-
-    dep_version = ctx.attr.semver if ctx.attr.semver else ctx.attr.version
-    ctx.file(
-        version_name,
-        '"%s":"%s#%s"' % (ctx.name, ctx.attr.package, dep_version),
-    )
-    ctx.file(
-        "BUILD",
-        "\n".join([
-            "package(default_visibility=['//visibility:public'])",
-            "filegroup(name = 'zipfile', srcs = ['%s'], )" % download_name,
-            "filegroup(name = 'version_json', srcs = ['%s'], visibility=['//visibility:public'])" % version_name,
-        ]),
-        False,
-    )
-
-def _bash(ctx, cmd):
-    cmd_list = ["bash", "-c", cmd]
-    out = ctx.execute(cmd_list)
-    if out.return_code:
-        fail("failed %s: %s" % (cmd_list, out.stderr))
-
-bower_archive = repository_rule(
-    _bower_archive,
-    attrs = {
-        "package": attr.string(mandatory = True),
-        "semver": attr.string(),
-        "sha1": attr.string(mandatory = True),
-        "version": attr.string(mandatory = True),
-        "_bower_archive": attr.label(default = Label("@bower//:%s" % _npm_tarball("bower"))),
-        "_download_bower": attr.label(default = Label("//tools/js:download_bower.py")),
-        "_run_npm": attr.label(default = Label("//tools/js:run_npm_binary.py")),
-    },
-)
-
-def _bower_component_impl(ctx):
-    transitive_zipfiles = depset(
-        direct = [ctx.file.zipfile],
-        transitive = [d[ComponentInfo].transitive_zipfiles for d in ctx.attr.deps],
-    )
-
-    transitive_licenses = depset(
-        direct = [ctx.file.license],
-        transitive = [d[ComponentInfo].transitive_licenses for d in ctx.attr.deps],
-    )
-
-    transitive_versions = depset(
-        direct = ctx.files.version_json,
-        transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps],
-    )
-
-    return [
-        ComponentInfo(
-            transitive_licenses = transitive_licenses,
-            transitive_versions = transitive_versions,
-            transitive_zipfiles = transitive_zipfiles,
-        ),
-    ]
-
-_common_attrs = {
-    "deps": attr.label_list(providers = [ComponentInfo]),
-}
-
 def _js_component(ctx):
     dir = ctx.outputs.zip.path + ".dir"
     name = ctx.outputs.zip.basename
@@ -182,7 +24,7 @@
         inputs = ctx.files.srcs,
         outputs = [ctx.outputs.zip],
         command = cmd,
-        mnemonic = "GenBowerZip",
+        mnemonic = "GenJsComponentZip",
     )
 
     licenses = []
@@ -199,248 +41,15 @@
 
 js_component = rule(
     _js_component,
-    attrs = dict(_common_attrs.items() + {
+    attrs = {
         "srcs": attr.label_list(allow_files = [".js"]),
         "license": attr.label(allow_single_file = True),
-    }.items()),
+    },
     outputs = {
         "zip": "%{name}.zip",
     },
 )
 
-_bower_component = rule(
-    _bower_component_impl,
-    attrs = dict(_common_attrs.items() + {
-        "license": attr.label(allow_single_file = True),
-
-        # If set, define by hand, and don't regenerate this entry in bower2bazel.
-        "seed": attr.bool(default = False),
-        "version_json": attr.label(allow_files = [".json"]),
-        "zipfile": attr.label(allow_single_file = [".zip"]),
-    }.items()),
-)
-
-# TODO(hanwen): make license mandatory.
-def bower_component(name, license = None, **kwargs):
-    prefix = "//lib:LICENSE-"
-    if license and not license.startswith(prefix):
-        license = prefix + license
-    _bower_component(
-        name = name,
-        license = license,
-        zipfile = "@%s//:zipfile" % name,
-        version_json = "@%s//:version_json" % name,
-        **kwargs
-    )
-
-def _bower_component_bundle_impl(ctx):
-    """A bunch of bower components zipped up."""
-    zips = depset()
-    for d in ctx.attr.deps:
-        files = d[ComponentInfo].transitive_zipfiles
-
-        # TODO(davido): Make sure the field always contains a depset
-        if type(files) == "list":
-            files = depset(files)
-        zips = depset(transitive = [zips, files])
-
-    versions = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
-
-    licenses = depset(transitive = [d[ComponentInfo].transitive_versions for d in ctx.attr.deps])
-
-    out_zip = ctx.outputs.zip
-    out_versions = ctx.outputs.version_json
-
-    ctx.actions.run_shell(
-        inputs = zips.to_list(),
-        outputs = [out_zip],
-        command = " && ".join([
-            "p=$PWD",
-            "TZ=UTC",
-            "export TZ",
-            "rm -rf %s.dir" % out_zip.path,
-            "mkdir -p %s.dir/bower_components" % out_zip.path,
-            "cd %s.dir/bower_components" % out_zip.path,
-            "for z in %s; do unzip -q $p/$z ; done" % " ".join(sorted([z.path for z in zips.to_list()])),
-            "cd ..",
-            "find . -exec touch -t 198001010000 '{}' ';'",
-            "zip -Xqr $p/%s bower_components/*" % out_zip.path,
-        ]),
-        mnemonic = "BowerCombine",
-    )
-
-    ctx.actions.run_shell(
-        inputs = versions.to_list(),
-        outputs = [out_versions],
-        mnemonic = "BowerVersions",
-        command = "(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions.to_list()]), out_versions.path),
-    )
-
-    return [
-        ComponentInfo(
-            transitive_licenses = licenses,
-            transitive_versions = versions,
-            transitive_zipfiles = zips,
-        ),
-    ]
-
-bower_component_bundle = rule(
-    _bower_component_bundle_impl,
-    attrs = _common_attrs,
-    outputs = {
-        "version_json": "%{name}-versions.json",
-        "zip": "%{name}.zip",
-    },
-)
-
-def _bundle_impl(ctx):
-    """Groups a set of .html and .js together in a zip file.
-
-    Outputs:
-      NAME-versions.json:
-        a JSON file containing a PKG-NAME => PKG-NAME#VERSION mapping for the
-        transitive dependencies.
-    NAME.zip:
-      a zip file containing the transitive dependencies for this bundle.
-    """
-
-    # intermediate artifact if split is wanted.
-    if ctx.attr.split:
-        bundled = ctx.actions.declare_file(ctx.outputs.html.path + ".bundled.html")
-    else:
-        bundled = ctx.outputs.html
-    destdir = ctx.outputs.html.path + ".dir"
-    zips = [z for d in ctx.attr.deps for z in d[ComponentInfo].transitive_zipfiles.to_list()]
-
-    # We are splitting off the package dir from the app.path such that
-    # we can set the package dir as the root for the bundler, which means
-    # that absolute imports are interpreted relative to that root.
-    pkg_dir = ctx.attr.pkg.lstrip("/")
-    app_path = ctx.file.app.path
-    app_path = app_path[app_path.index(pkg_dir) + len(pkg_dir):]
-
-    hermetic_npm_binary = " ".join([
-        "python3",
-        "$p/" + ctx.file._run_npm.path,
-        "$p/" + ctx.file._bundler_archive.path,
-        "--inline-scripts",
-        "--inline-css",
-        "--sourcemaps",
-        "--strip-comments",
-        "--out-file",
-        "$p/" + bundled.path,
-        "--root",
-        pkg_dir,
-        app_path,
-    ])
-
-    cmd = " && ".join([
-        # unpack dependencies.
-        "export PATH",
-        "p=$PWD",
-        "rm -rf %s" % destdir,
-        "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
-        "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
-            " ".join([z.path for z in zips]),
-            destdir,
-            pkg_dir,
-        ),
-        "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
-        "cd %s" % destdir,
-        hermetic_npm_binary,
-    ])
-
-    # Node/NPM is not (yet) hermeticized, so we have to get the binary
-    # from the environment, and it may be under $HOME, so we can't run
-    # in the sandbox.
-    node_tweaks = dict(
-        execution_requirements = {"local": "1"},
-        use_default_shell_env = True,
-    )
-    ctx.actions.run_shell(
-        mnemonic = "Bundle",
-        inputs = [
-            ctx.file._run_npm,
-            ctx.file.app,
-            ctx.file._bundler_archive,
-        ] + list(zips) + ctx.files.srcs,
-        outputs = [bundled],
-        command = cmd,
-        **node_tweaks
-    )
-
-    if ctx.attr.split:
-        hermetic_npm_command = "export PATH && " + " ".join([
-            "python3",
-            ctx.file._run_npm.path,
-            ctx.file._crisper_archive.path,
-            "--script-in-head=false",
-            "--always-write-script",
-            "--source",
-            bundled.path,
-            "--html",
-            ctx.outputs.html.path,
-            "--js",
-            ctx.outputs.js.path,
-        ])
-
-        ctx.actions.run_shell(
-            mnemonic = "Crisper",
-            inputs = [
-                ctx.file._run_npm,
-                ctx.file.app,
-                ctx.file._crisper_archive,
-                bundled,
-            ],
-            outputs = [ctx.outputs.js, ctx.outputs.html],
-            command = hermetic_npm_command,
-            **node_tweaks
-        )
-
-def _bundle_output_func(name, split):
-    _ignore = [name]  # unused.
-    out = {"html": "%{name}.html"}
-    if split:
-        out["js"] = "%{name}.js"
-    return out
-
-_bundle_rule = rule(
-    _bundle_impl,
-    attrs = {
-        "srcs": attr.label_list(allow_files = [
-            ".js",
-            ".html",
-            ".txt",
-            ".css",
-            ".ico",
-        ]),
-        "app": attr.label(
-            mandatory = True,
-            allow_single_file = True,
-        ),
-        "pkg": attr.string(mandatory = True),
-        "split": attr.bool(default = True),
-        "deps": attr.label_list(providers = [ComponentInfo]),
-        "_bundler_archive": attr.label(
-            default = Label("@polymer-bundler//:%s" % _npm_tarball("polymer-bundler")),
-            allow_single_file = True,
-        ),
-        "_crisper_archive": attr.label(
-            default = Label("@crisper//:%s" % _npm_tarball("crisper")),
-            allow_single_file = True,
-        ),
-        "_run_npm": attr.label(
-            default = Label("//tools/js:run_npm_binary.py"),
-            allow_single_file = True,
-        ),
-    },
-    outputs = _bundle_output_func,
-)
-
-def bundle_assets(*args, **kwargs):
-    """Combine html, js, css files and optionally split into js and html bundles."""
-    _bundle_rule(pkg = native.package_name(), *args, **kwargs)
-
 def polygerrit_plugin(name, app, plugin_name = None):
     """Produces plugin file set with minified javascript.
 
@@ -495,11 +104,14 @@
 
     rollup_bundle(
         name = bundle,
-        srcs = srcs,
+        srcs = srcs + [
+            "@plugins_npm//:node_modules",
+        ],
         entry_point = entry_point,
         format = "iife",
         rollup_bin = "//tools/node_tools:rollup-bin",
         sourcemap = "hidden",
+        config_file = "//plugins:rollup.config.js",
         deps = [
             "@tools_npm//rollup-plugin-node-resolve",
         ],
@@ -530,3 +142,36 @@
             "zip -Drq $$ROOT/$@ -g .",
         ]),
     )
+
+def karma_test(name, srcs, data):
+    """Creates a Karma test target.
+
+    It can be used both for the main Gerrit js bundle, but also for plugins. So
+    it should be extremely easy to add Karma test capabilities for new plugins.
+
+    We are sharing one karma.conf.js file. If you want to customize that, then
+    consider using command line arguments that the config file can process, see
+    the `root` argument for an example.
+
+    Args:
+      name: The name of the test rule.
+      srcs: The shell script to invoke, where you can set command line
+        arguments for Karma and its config.
+      data: The bundle of JavaScript files with the tests included.
+    """
+
+    native.sh_test(
+        name = name,
+        size = "enormous",
+        srcs = srcs,
+        args = [
+            "$(location //polygerrit-ui:karma_bin)",
+            "$(location //polygerrit-ui:karma.conf.js)",
+        ],
+        data = data + [
+            "//polygerrit-ui:karma_bin",
+            "//polygerrit-ui:karma.conf.js",
+        ],
+        # Should not run sandboxed.
+        tags = ["karma", "local", "manual"],
+    )
diff --git a/tools/bzl/license-map.py b/tools/bzl/license-map.py
index 3e4fc92..43b172c 100644
--- a/tools/bzl/license-map.py
+++ b/tools/bzl/license-map.py
@@ -132,7 +132,7 @@
 
 def main():
     xml_data = load_xmls(args.xmls)
-    json_map_data = load_jsons(args.json_maps)
+    json_map_data = load_jsons(args.json_maps) if args.json_maps else []
 
     if args.asciidoctor:
         # We don't want any blank line before "= Gerrit Code Review - Licenses"
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index 61ea4fe..cd9f132 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -9,7 +9,6 @@
 )
 
 TEST_DEPS = [
-    "//javatests/com/google/gerrit/elasticsearch:elasticsearch_test_utils",
     "//javatests/com/google/gerrit/server:server_tests",
 ]
 
diff --git a/tools/js/BUILD b/tools/js/BUILD
index 1a272e2..d696496 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -1 +1 @@
-exports_files(["run_npm_binary.py", "eslint-chdir.js"])
+exports_files(["eslint-chdir.js"])
diff --git a/tools/js/bowerutil.py b/tools/js/bowerutil.py
deleted file mode 100644
index 9fb82af..0000000
--- a/tools/js/bowerutil.py
+++ /dev/null
@@ -1,46 +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.
-
-import os
-
-
-def hash_bower_component(hash_obj, path):
-    """Hash the contents of a bower component directory.
-
-    This is a stable hash of a directory downloaded with `bower install`, minus
-    the .bower.json file, which is autogenerated each time by bower. Used in
-    lieu of hashing a zipfile of the contents, since zipfiles are difficult to
-    hash in a stable manner.
-
-    Args:
-      hash_obj: an open hash object, e.g. hashlib.sha1().
-      path: path to the directory to hash.
-
-    Returns:
-      The passed-in hash_obj.
-    """
-    if not os.path.isdir(path):
-        raise ValueError('Not a directory: %s' % path)
-
-    path = os.path.abspath(path)
-    for root, dirs, files in os.walk(path):
-        dirs.sort()
-        for f in sorted(files):
-            if f == '.bower.json':
-                continue
-            p = os.path.join(root, f)
-            hash_obj.update(p[len(path)+1:].encode("utf-8"))
-            hash_obj.update(open(p, "rb").read())
-
-    return hash_obj
diff --git a/tools/js/download_bower.py b/tools/js/download_bower.py
deleted file mode 100755
index 3bd9be9..0000000
--- a/tools/js/download_bower.py
+++ /dev/null
@@ -1,139 +0,0 @@
-#!/usr/bin/env python3
-# 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.
-
-from __future__ import print_function
-
-import argparse
-import hashlib
-import json
-import os
-import shutil
-import subprocess
-import sys
-
-import bowerutil
-
-CACHE_DIR = os.environ.get(
-    'GERRIT_CACHE_HOME',
-    os.path.expanduser(os.path.join(
-        '~', '.gerritcodereview', 'bazel-cache', 'downloaded-artifacts'
-    ))
-)
-
-
-def bower_cmd(bower, *args):
-    cmd = bower.split(' ')
-    cmd.extend(args)
-    return cmd
-
-
-def bower_info(bower, name, package, version):
-    cmd = bower_cmd(bower, '-l=error', '-j',
-                    'info', '%s#%s' % (package, version))
-    try:
-        p = subprocess.Popen(cmd, stdout=subprocess.PIPE,
-                             stderr=subprocess.PIPE)
-    except:
-        sys.stderr.write("error executing: %s\n" % ' '.join(cmd))
-        raise
-    out, err = p.communicate()
-    if p.returncode:
-        # For python3 support we wrap str around err.
-        sys.stderr.write(str(err))
-        raise OSError('Command failed: %s' % ' '.join(cmd))
-
-    try:
-        info = json.loads(out.decode('utf8'))
-    except ValueError:
-        raise ValueError('invalid JSON from %s:\n%s' % (" ".join(cmd), out))
-    info_name = info.get('name')
-    if info_name != name:
-        raise ValueError(
-            'expected package name %s, got: %s' % (name, info_name))
-    return info
-
-
-def ignore_deps(info):
-    # Tell bower to ignore dependencies so we just download this component.
-    # This is just an optimization, since we only pick out the component we
-    # need, but it's important when downloading sizable dependency trees.
-    #
-    # As of 1.6.5 I don't think ignoredDependencies can be specified on the
-    # command line with --config, so we have to create .bowerrc.
-    deps = info.get('dependencies')
-    if deps:
-        with open(os.path.join('.bowerrc'), 'w') as f:
-            json.dump({'ignoredDependencies': list(deps.keys())}, f)
-
-
-def cache_entry(name, package, version, sha1):
-    if not sha1:
-        sha1 = hashlib.sha1('%s#%s' % (package, version)).hexdigest()
-    return os.path.join(CACHE_DIR, '%s-%s.zip-%s' % (name, version, sha1))
-
-
-def main():
-    parser = argparse.ArgumentParser()
-    parser.add_argument('-n', help='short name of component')
-    parser.add_argument('-b', help='bower command')
-    parser.add_argument('-p', help='full package name of component')
-    parser.add_argument('-v', help='version number')
-    parser.add_argument('-s', help='expected content sha1')
-    parser.add_argument('-o', help='output file location')
-    args = parser.parse_args()
-
-    assert args.p
-    assert args.v
-    assert args.n
-
-    cwd = os.getcwd()
-    outzip = os.path.join(cwd, args.o)
-    cached = cache_entry(args.n, args.p, args.v, args.s)
-
-    if not os.path.exists(cached):
-        info = bower_info(args.b, args.n, args.p, args.v)
-        ignore_deps(info)
-        subprocess.check_call(
-            bower_cmd(
-                args.b, '--quiet', 'install', '%s#%s' % (args.p, args.v)))
-        bc = os.path.join(cwd, 'bower_components')
-        subprocess.check_call(
-            ['zip', '-q', '--exclude', '.bower.json', '-r', cached, args.n],
-            cwd=bc)
-
-        if args.s:
-            path = os.path.join(bc, args.n)
-            sha1 = bowerutil.hash_bower_component(
-                hashlib.sha1(), path).hexdigest()
-            if args.s != sha1:
-                print((
-                    '%s#%s:\n'
-                    'expected %s\n'
-                    'received %s\n') % (args.p, args.v, args.s, sha1),
-                    file=sys.stderr)
-                try:
-                    os.remove(cached)
-                except OSError as err:
-                    if path.exists(cached):
-                        print('error removing %s: %s' % (cached, err),
-                              file=sys.stderr)
-                return 1
-
-    shutil.copyfile(cached, outzip)
-    return 0
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/tools/js/eslint.bzl b/tools/js/eslint.bzl
index b32e2bc..eb4d37a 100644
--- a/tools/js/eslint.bzl
+++ b/tools/js/eslint.bzl
@@ -16,6 +16,34 @@
 
 load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test")
 
+def plugin_eslint():
+    """ Convenience wrapper macro of eslint() for Gerrit js plugins
+
+    Args:
+        name: name of the rule
+    """
+    eslint(
+        name = "lint",
+        srcs = native.glob(["**/*.ts"]),
+        config = ".eslintrc.js",
+        data = [
+            "tsconfig.json",
+            "//plugins:.eslintrc.js",
+            "//plugins:.prettierrc.js",
+            "//plugins:tsconfig-plugins-base.json",
+        ],
+        extensions = [".ts"],
+        ignore = "//plugins:.eslintignore",
+        plugins = [
+            "@npm//eslint-config-google",
+            "@npm//eslint-plugin-html",
+            "@npm//eslint-plugin-import",
+            "@npm//eslint-plugin-jsdoc",
+            "@npm//eslint-plugin-prettier",
+            "@npm//gts",
+        ],
+    )
+
 def eslint(name, plugins, srcs, config, ignore, extensions = [".js"], data = []):
     """ Macro to define eslint rules for files.
 
@@ -87,7 +115,7 @@
             "*_test_require_patch.js",
             "--ignore-pattern",
             "*_test_loader.js",
-            "./", # Relative to the config file location
+            "./",  # Relative to the config file location
         ],
         # Should not run sandboxed.
         tags = [
diff --git a/tools/js/run_npm_binary.py b/tools/js/run_npm_binary.py
deleted file mode 100644
index 31f8a54..0000000
--- a/tools/js/run_npm_binary.py
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env python3
-# 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.
-
-from __future__ import print_function
-
-import atexit
-from distutils import spawn
-import hashlib
-import os
-import shutil
-import subprocess
-import sys
-import tarfile
-import tempfile
-
-
-def extract(path, outdir, bin):
-    if os.path.exists(os.path.join(outdir, bin)):
-        return  # Another process finished extracting, ignore.
-
-    # Use a temp directory adjacent to outdir so shutil.move can use the same
-    # device atomically.
-    tmpdir = tempfile.mkdtemp(dir=os.path.dirname(outdir))
-
-    def cleanup():
-        try:
-            shutil.rmtree(tmpdir)
-        except OSError:
-            pass  # Too late now
-    atexit.register(cleanup)
-
-    def extract_one(mem):
-        dest = os.path.join(outdir, mem.name)
-        tar.extract(mem, path=tmpdir)
-        try:
-            os.makedirs(os.path.dirname(dest))
-        except OSError:
-            pass  # Either exists, or will fail on the next line.
-        shutil.move(os.path.join(tmpdir, mem.name), dest)
-
-    with tarfile.open(path, 'r:gz') as tar:
-        for mem in tar.getmembers():
-            if mem.name != bin:
-                extract_one(mem)
-        # Extract bin last so other processes only short circuit when
-        # extraction is finished.
-        if bin in tar.getnames():
-            extract_one(tar.getmember(bin))
-
-
-def main(args):
-    path = args[0]
-    suffix = '.npm_binary.tgz'
-    tgz = os.path.basename(path)
-
-    parts = tgz[:-len(suffix)].split('@')
-
-    if not tgz.endswith(suffix) or len(parts) != 2:
-        print('usage: %s <path/to/npm_binary>' % sys.argv[0], file=sys.stderr)
-        return 1
-
-    name, _ = parts
-
-    # Avoid importing from gerrit because we don't want to depend on the right
-    # working directory
-    sha1 = hashlib.sha1(open(path, 'rb').read()).hexdigest()
-    outdir = '%s-%s' % (path[:-len(suffix)], sha1)
-    rel_bin = os.path.join('package', 'bin', name)
-    rel_lib_bin = os.path.join('package', 'lib', 'bin', name + '.js')
-    bin = os.path.join(outdir, rel_bin)
-    libbin = os.path.join(outdir, rel_lib_bin)
-    if not os.path.isfile(bin):
-        extract(path, outdir, rel_bin)
-
-    nodejs = spawn.find_executable('nodejs')
-    if nodejs:
-        # Debian installs Node.js as 'nodejs', due to a conflict with another
-        # package.
-        if not os.path.isfile(bin) and os.path.isfile(libbin):
-            subprocess.check_call([nodejs, libbin] + args[1:])
-        else:
-            subprocess.check_call([nodejs, bin] + args[1:])
-    elif not os.path.isfile(bin) and os.path.isfile(libbin):
-        subprocess.check_call([libbin] + args[1:])
-    else:
-        subprocess.check_call([bin] + args[1:])
-
-
-if __name__ == '__main__':
-    sys.exit(main(sys.argv[1:]))
diff --git a/tools/js/template_checker.bzl b/tools/js/template_checker.bzl
new file mode 100644
index 0000000..da77234
--- /dev/null
+++ b/tools/js/template_checker.bzl
@@ -0,0 +1,136 @@
+# 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.
+
+"""This file contains macro to run polymer templates check."""
+
+load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary", "nodejs_test", "npm_package_bin", "params_file")
+load("@rules_pkg//:pkg.bzl", "pkg_tar")
+
+def _get_generated_files(outdir, srcs):
+    result = []
+    for f in srcs:
+        result.append(outdir + "/" + f)
+    return result
+
+def _generate_transformed_templates(name, srcs, tsconfig, deps, out_tsconfig, outdir, dev_run):
+    """Generates typescript code from polymer templates. It uses twinkie package
+    for generation.
+
+    Args:
+      name: rule name
+      srcs: all files in a project project
+      tsconfig: the original typescript project file
+      deps: dependencies
+      out_tsconfig: where to store the generated TS project.
+      outdir: where to store generated .ts files
+      dev_run: if True, the generator uses different file paths in generated
+        import statements. Later, generated files can be copied into workspace
+        for future debugging\\investigation templates issues.
+
+    Returns:
+      The list of generated files
+    """
+    generated_files = _get_generated_files(outdir, srcs)
+
+    # There is a limitation on the command-line length. Put all source files
+    # into a .params file (this is a text file, where each argument is placed
+    # on a new line)
+    params_file(
+        name = name + "_params",
+        out = name + ".params",
+        args = ["$(execpath {})".format(src) for src in srcs],
+        data = srcs,
+    )
+
+    # Arguments for twinkie
+    args = [
+        "$(location //tools/node_tools:twinkie-bin)",
+        "--tsconfig $(location {})".format(tsconfig),
+        "--out-dir $(RULEDIR)/{} ".format(outdir),
+        "--files $(location {})".format(name + ".params"),
+    ]
+    if dev_run:
+        args.append("--dev-run")
+    if out_tsconfig:
+        args.append("--out-ts-config $(location {})".format(out_tsconfig))
+
+    # Execute twinkie.
+    native.genrule(
+        name = name + "_npm_bin",
+        srcs = srcs + deps + [name + ".params"],
+        outs = generated_files + ([out_tsconfig] if out_tsconfig else []),
+        cmd = " ".join(args),
+        tools = ["//tools/node_tools:twinkie-bin"],
+        # Should not run sandboxed.
+        tags = [
+            "local",
+            "manual",
+        ],
+    )
+    return generated_files
+
+def transform_polymer_templates(name, srcs, tsconfig, deps, out_tsconfig):
+    """Transforms polymer templates into typescript code.
+    Additionally, the macro defines name+"_tar" package that contains
+    generated code with slightly different import paths.
+    Note, that polygerrit template tests don't depend on the tar package, so
+    bazel doesn't generate the tar package with the bazel test command.
+    The tar package must be build explicitly with the bazel build command.
+
+    Args:
+      name: rule name
+      srcs: all files in a project project
+      tsconfig: the original typescript project file
+      deps: dependencies
+      out_tsconfig: where to store the generated TS project.
+
+    Returns:
+      list of generated files
+    """
+
+    # Transformed templates for tests
+    generated_files = _generate_transformed_templates(
+        name = name,
+        srcs = srcs,
+        tsconfig = tsconfig,
+        deps = deps,
+        out_tsconfig = out_tsconfig,
+        dev_run = False,
+        outdir = name + "_out",
+    )
+
+    # Transformed templates for developers. Only the tar package depends
+    # on it and it never runs during tests.
+    generated_dev_files = _generate_transformed_templates(
+        name = name + "_dev",
+        srcs = srcs,
+        tsconfig = tsconfig,
+        deps = deps,
+        dev_run = True,
+        outdir = name + "_dev_out",
+        out_tsconfig = None,
+    )
+
+    # Pack all transformed files. Later files can be materialized in the
+    # WORKSPACE/polygerrit-ui/app/tmpl_out dir. The following command do it
+    # automatically
+    # npm run polytest:dev
+    pkg_tar(
+        name = name + "_tar",
+        srcs = generated_dev_files,
+        # Set strip_prefix to keep directory hierarchy in the .tar
+        # https://github.com/bazelbuild/rules_pkg/issues/82
+        strip_prefix = name + "_dev_out",
+    )
+    return generated_files
diff --git a/tools/maven/gerrit-acceptance-framework_pom.xml b/tools/maven/gerrit-acceptance-framework_pom.xml
index 09a491c..83297d6 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.4.5-SNAPSHOT</version>
+  <version>3.5.2-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Acceptance Test Framework</name>
   <description>Framework for Gerrit's acceptance tests</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-extension-api_pom.xml b/tools/maven/gerrit-extension-api_pom.xml
index ff0082e..991dbb4 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.4.5-SNAPSHOT</version>
+  <version>3.5.2-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-plugin-api_pom.xml b/tools/maven/gerrit-plugin-api_pom.xml
index 7c371c7..8bc40d5 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.4.5-SNAPSHOT</version>
+  <version>3.5.2-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/maven/gerrit-war_pom.xml b/tools/maven/gerrit-war_pom.xml
index ce73726..b1ab1a9 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.4.5-SNAPSHOT</version>
+  <version>3.5.2-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
@@ -77,6 +77,9 @@
       <name>Patrick Hiesel</name>
     </developer>
     <developer>
+      <name>Patrick Mulhall</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
@@ -85,6 +88,9 @@
     <developer>
       <name>Tao Zhou</name>
     </developer>
+    <developer>
+      <name>Thomas Dräbing</name>
+    </developer>
   </developers>
 
   <mailingLists>
diff --git a/tools/node_tools/BUILD b/tools/node_tools/BUILD
index 8aeed017..bb1a9fc 100644
--- a/tools/node_tools/BUILD
+++ b/tools/node_tools/BUILD
@@ -34,7 +34,6 @@
     name = "tsc_wrapped-bin",
     # Point bazel to your node_modules to find the entry point
     data = [
-        "@tools_npm//:node_modules",
         "@tools_npm//@bazel/concatjs",
         "@tools_npm//typescript",
     ],
@@ -48,6 +47,19 @@
 nodejs_binary(
     name = "tsc-bin",
     # Point bazel to your node_modules to find the entry point
-    data = ["@tools_npm//:node_modules"],
+    data = ["@tools_npm//typescript"],
     entry_point = "@tools_npm//:node_modules/typescript/lib/tsc.js",
 )
+
+# Wrap twinkie into a twinkie-bin binary.
+nodejs_binary(
+    name = "twinkie-bin",
+    # Point bazel to your node_modules to find the entry point
+    data = ["@npm//:node_modules"],
+    entry_point = "@npm//:node_modules/twinkie/src/app/index.js",
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/tools/node_tools/launchpad.patch b/tools/node_tools/launchpad.patch
new file mode 100644
index 0000000..565494b
--- /dev/null
+++ b/tools/node_tools/launchpad.patch
@@ -0,0 +1,240 @@
+From d430b5d912bebe87529b887f408ee55c82a0e003 Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Fri, 26 Jun 2020 20:16:47 +0200
+Subject: [PATCH 1/7] Update version.js
+
+---
+ lib/local/version.js | 15 ++++++++++++---
+ 1 file changed, 12 insertions(+), 3 deletions(-)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
+index 0110a74..2c02bef 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
+@@ -6,6 +6,15 @@ var plist = require('plist');
+ var utils = require('./utils');
+ var debug = require('debug')('launchpad:local:version');
+ 
++var validPath = function (filename){
++  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
++  if (filter.test(filename)){
++    console.log('\nInvalid characters inside the path to the browser\n');
++    return
++  }
++  return filename;
++}
++
+ module.exports = function(browser) {
+   if (!browser || !browser.path) {
+     return Q(null);
+@@ -18,7 +27,7 @@ module.exports = function(browser) {
+ 
+     debug('Retrieving version for windows executable', command);
+     // Can't use Q.nfcall here unfortunately because of non 0 exit code
+-    exec(command, function(error, stdout) {
++    exec(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
+       var regex = /ProductVersion:\s*(.*)/;
+       // ShowVer.exe returns a non zero status code even if it works
+       if (typeof stdout === 'string' && regex.test(stdout)) {
+@@ -47,8 +56,8 @@ module.exports = function(browser) {
+   }
+ 
+   // Try executing <browser> --version (everything else)
+-  return Q.nfcall(exec, browser.path + ' --version').then(function(stdout) {
+-    debug('Ran ' + browser.path + ' --version', stdout);
++  return Q.nfcall(exec, validPath(browser.path) + ' --version').then(function(stdout) {
++    debug('Ran ' + validPath(browser.path) + ' --version', stdout);
+     var version = utils.getStdout(stdout);
+     if (version) {
+       browser.version = version;
+
+From 09ce4fab2fd53cab893ceaa3b4d7f997af9b41d8 Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Fri, 26 Jun 2020 20:18:35 +0200
+Subject: [PATCH 2/7] Update instance.js
+
+---
+ lib/local/instance.js | 11 +++++++++--
+ 1 file changed, 9 insertions(+), 2 deletions(-)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
+index 484a866..b49990f 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
+@@ -5,8 +5,15 @@ var EventEmitter = require('events').EventEmitter;
+ var debug = require('debug')('launchpad:local:instance');
+ var rimraf = require('rimraf');
+ 
++var safe = function (str) {
++   // Avoid quotes makes impossible escape the `multi command` scenario
++   return str.replace(/['"]+/g, '');
++}
++
+ var getProcessId = function (name, callback) {
+ 
++  name = safe(name);
++
+   var commands = {
+     darwin: "ps -clx | grep '" + name + "$' | awk '{print $2}' | head -1",
+     linux: "ps -ax | grep '" + name + "$' | awk '{print $2}' | head -1",
+@@ -90,11 +97,11 @@ Instance.prototype.stop = function (callback) {
+     } catch (error) {}
+   } else {
+     if (this.options.command.indexOf('open') === 0) {
+-      command = 'osascript -e \'tell application "' + self.options.process + '" to quit\'';
++      command = 'osascript -e \'tell application "' + safe(self.options.process) + '" to quit\'';
+       debug('Executing shutdown AppleScript', command);
+       exec(command);
+     } else if (process.platform === 'win32') {
+-      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd));
++      command = 'taskkill /IM "' + safe(this.options.imageName || path.basename(this.cmd)) + '"';
+       debug('Executing shutdown taskkil', command);
+       exec(command).once('exit', function(data) {
+         self.emit('stop', data);
+
+From d3993fce090ed6ef378c1f0594eff18d125dad1e Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Fri, 26 Jun 2020 20:19:17 +0200
+Subject: [PATCH 3/7] Update version.js
+
+---
+ lib/local/version.js | 1 +
+ 1 file changed, 1 insertion(+)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
+index 2c02bef..5eac082 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
+@@ -6,6 +6,7 @@ var plist = require('plist');
+ var utils = require('./utils');
+ var debug = require('debug')('launchpad:local:version');
+ 
++// Validate paths supplied by the user in order to avoid "arbitrary command execution"
+ var validPath = function (filename){
+   var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
+   if (filter.test(filename)){
+
+From abf3dbcc79e6b338338594ab2dbef834550e8f65 Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Mon, 29 Jun 2020 13:32:50 +0200
+Subject: [PATCH 4/7] Update instance.js
+
+---
+ lib/local/instance.js | 10 +++++++---
+ 1 file changed, 7 insertions(+), 3 deletions(-)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
+index b49990f..9375d1f 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
+@@ -1,6 +1,7 @@
+ var path = require('path');
+ var spawn = require("child_process").spawn;
+ var exec = require("child_process").exec;
++var execFile = require("child_process").execFile;
+ var EventEmitter = require('events').EventEmitter;
+ var debug = require('debug')('launchpad:local:instance');
+ var rimraf = require('rimraf');
+@@ -99,11 +100,14 @@ Instance.prototype.stop = function (callback) {
+     if (this.options.command.indexOf('open') === 0) {
+       command = 'osascript -e \'tell application "' + safe(self.options.process) + '" to quit\'';
+       debug('Executing shutdown AppleScript', command);
+-      exec(command);
++      command = command.split(' ');
++      execFile(command[0], command.slice(1));
+     } else if (process.platform === 'win32') {
+-      command = 'taskkill /IM "' + safe(this.options.imageName || path.basename(this.cmd)) + '"';
++      //Adding `"` wasn't safe/functional on Win systems
++      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd); 
+       debug('Executing shutdown taskkil', command);
+-      exec(command).once('exit', function(data) {
++      command = command.split(' ');
++      execFile(command[0], command.slice(1)).once('exit', function(data) {
+         self.emit('stop', data);
+       });
+     } else {
+
+From 68518b274c9351f799d41ce85f23499ca4a785e9 Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Tue, 30 Jun 2020 00:01:31 +0200
+Subject: [PATCH 5/7] Update instance.js
+
+---
+ lib/local/instance.js | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/instance.js b/tools/node_tools/node_modules/launchpad/lib/g/local/instance.js
+index 9375d1f..f157dd4 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/instance.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/instance.js
+@@ -104,7 +104,7 @@ Instance.prototype.stop = function (callback) {
+       execFile(command[0], command.slice(1));
+     } else if (process.platform === 'win32') {
+       //Adding `"` wasn't safe/functional on Win systems
+-      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd); 
++      command = 'taskkill /IM ' + (this.options.imageName || path.basename(this.cmd)); 
+       debug('Executing shutdown taskkil', command);
+       command = command.split(' ');
+       execFile(command[0], command.slice(1)).once('exit', function(data) {
+
+From e711d07d40d39162ea4bdb1ed344c58f92bfa10b Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Fri, 3 Jul 2020 12:30:31 +0200
+Subject: [PATCH 6/7] Update version.js
+
+---
+ lib/local/version.js | 5 +++--
+ 1 file changed, 3 insertions(+), 2 deletions(-)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
+index 5eac082..d1403a0 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
+@@ -1,5 +1,6 @@
+ var fs = require('fs');
+ var exec = require('child_process').exec;
++var execFile = require('child_process').execFile;
+ var Q = require('q');
+ var path = require('path');
+ var plist = require('plist');
+@@ -8,7 +9,7 @@ var debug = require('debug')('launchpad:local:version');
+ 
+ // Validate paths supplied by the user in order to avoid "arbitrary command execution"
+ var validPath = function (filename){
+-  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"\\|,<>\/?~]/;
++  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"|,<>?~]/;
+   if (filter.test(filename)){
+     console.log('\nInvalid characters inside the path to the browser\n');
+     return
+@@ -28,7 +29,7 @@ module.exports = function(browser) {
+ 
+     debug('Retrieving version for windows executable', command);
+     // Can't use Q.nfcall here unfortunately because of non 0 exit code
+-    exec(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
++    execFile(command.split(' ')[0], command.split(' ').slice(1), function(error, stdout) {
+       var regex = /ProductVersion:\s*(.*)/;
+       // ShowVer.exe returns a non zero status code even if it works
+       if (typeof stdout === 'string' && regex.test(stdout)) {
+
+From a3ff1804f0aacfb4fa20dad1312427b81280bb3e Mon Sep 17 00:00:00 2001
+From: Michele Romano <33063403+Mik317@users.noreply.github.com>
+Date: Fri, 3 Jul 2020 12:31:31 +0200
+Subject: [PATCH 7/7] Update version.js
+
+---
+ lib/local/version.js | 2 +-
+ 1 file changed, 1 insertion(+), 1 deletion(-)
+
+diff --git a/tools/node_tools/node_modules/launchpad/lib/local/version.js b/tools/node_tools/node_modules/launchpad/lib/g/local/version.js
+index d1403a0..d937be4 100644
+--- a/tools/node_tools/node_modules/launchpad/lib/local/version.js
++++ b/tools/node_tools/node_modules/launchpad/lib/local/version.js
+@@ -9,7 +9,7 @@ var debug = require('debug')('launchpad:local:version');
+ 
+ // Validate paths supplied by the user in order to avoid "arbitrary command execution"
+ var validPath = function (filename){
+-  var filter = /[`!@#$%^&*()_+\-=\[\]{};':"|,<>?~]/;
++  var filter = /[`!@#$%^&*()_+\-=\[\]{};'"|,<>?~]/;
+   if (filter.test(filename)){
+     console.log('\nInvalid characters inside the path to the browser\n');
+     return
diff --git a/tools/node_tools/legacy/BUILD b/tools/node_tools/legacy/BUILD
deleted file mode 100644
index ed0946e..0000000
--- a/tools/node_tools/legacy/BUILD
+++ /dev/null
@@ -1,15 +0,0 @@
-load("@build_bazel_rules_nodejs//:index.bzl", "nodejs_binary")
-
-package(default_visibility = ["//visibility:public"])
-
-nodejs_binary(
-    name = "polymer-bundler-bin",
-    data = ["@tools_npm//:node_modules"],
-    entry_point = "@tools_npm//:node_modules/polymer-bundler/lib/bin/polymer-bundler.js",
-)
-
-nodejs_binary(
-    name = "crisper-bin",
-    data = ["@tools_npm//:node_modules"],
-    entry_point = "@tools_npm//:node_modules/crisper/bin/crisper",
-)
diff --git a/tools/node_tools/legacy/index.bzl b/tools/node_tools/legacy/index.bzl
deleted file mode 100644
index fe66bf8..0000000
--- a/tools/node_tools/legacy/index.bzl
+++ /dev/null
@@ -1,66 +0,0 @@
-""" File contains a wrapper for legacy polymer-bundler and crisper tools. """
-
-# File must be removed after get rid of HTML imports
-
-def _polymer_bundler_tool_impl(ctx):
-    """Wrapper for the polymer-bundler and crisper command-line tools"""
-
-    html_bundled_file = ctx.actions.declare_file(ctx.label.name + "_tmp.html")
-    ctx.actions.run(
-        executable = ctx.executable._bundler,
-        outputs = [html_bundled_file],
-        inputs = ctx.files.srcs,
-        arguments = [
-            "--inline-css",
-            "--sourcemaps",
-            "--strip-comments",
-            "--root",
-            ctx.file.entry_point.dirname,
-            "--out-file",
-            html_bundled_file.path,
-            "--in-file",
-            ctx.file.entry_point.basename,
-        ],
-    )
-
-    output_js_file = ctx.outputs.js
-    if ctx.attr.script_src_value:
-        output_js_file = ctx.actions.declare_file(ctx.attr.script_src_value, sibling = ctx.outputs.html)
-    script_src_value = ctx.attr.script_src_value if ctx.attr.script_src_value else ctx.outputs.js.path
-
-    ctx.actions.run(
-        executable = ctx.executable._crisper,
-        outputs = [ctx.outputs.html, output_js_file],
-        inputs = [html_bundled_file],
-        arguments = ["-s", html_bundled_file.path, "-h", ctx.outputs.html.path, "-j", output_js_file.path, "--always-write-script", "--script-in-head=false"],
-    )
-
-    if ctx.attr.script_src_value:
-        ctx.actions.expand_template(
-            template = output_js_file,
-            output = ctx.outputs.js,
-            substitutions = {},
-        )
-
-polymer_bundler_tool = rule(
-    implementation = _polymer_bundler_tool_impl,
-    attrs = {
-        "entry_point": attr.label(allow_single_file = True, mandatory = True),
-        "srcs": attr.label_list(allow_files = True),
-        "script_src_value": attr.string(),
-        "_bundler": attr.label(
-            default = ":polymer-bundler-bin",
-            executable = True,
-            cfg = "host",
-        ),
-        "_crisper": attr.label(
-            default = ":crisper-bin",
-            executable = True,
-            cfg = "host",
-        ),
-    },
-    outputs = {
-        "html": "%{name}.html",
-        "js": "%{name}.js",
-    },
-)
diff --git a/tools/node_tools/node_modules_licenses/tsconfig.json b/tools/node_tools/node_modules_licenses/tsconfig.json
index cb7bb60..4a57571 100644
--- a/tools/node_tools/node_modules_licenses/tsconfig.json
+++ b/tools/node_tools/node_modules_licenses/tsconfig.json
@@ -8,8 +8,8 @@
         ]
       }
     ],
-    "target": "es6",
-    "module": "commonjs",
+    "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "strict": true,
diff --git a/tools/node_tools/package.json b/tools/node_tools/package.json
index fc7ac16..a5bf124 100644
--- a/tools/node_tools/package.json
+++ b/tools/node_tools/package.json
@@ -17,9 +17,15 @@
     "rollup": "^2.3.4",
     "rollup-plugin-node-resolve": "^5.2.0",
     "rollup-plugin-terser": "^5.1.3",
-    "typescript": "4.0.5"
+    "typescript": "4.3.2"
   },
   "devDependencies": {},
+  "scripts": {
+    "postinstall": "(git apply --reverse --ignore-whitespace launchpad.patch || true) && git apply --ignore-whitespace launchpad.patch"
+  },
   "license": "Apache-2.0",
-  "private": true
+  "private": true,
+  "resolutions": {
+    "lodash": "4.17.21"
+  }
 }
diff --git a/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json b/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
index 34ffb2f..d3c7d1d 100644
--- a/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
+++ b/tools/node_tools/polygerrit_app_preprocessor/tsconfig.json
@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
-    "target": "es6",
-    "module": "commonjs",
+    "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "strict": true,
diff --git a/tools/node_tools/utils/tsconfig.json b/tools/node_tools/utils/tsconfig.json
index 56ab91b..d6bffa0 100644
--- a/tools/node_tools/utils/tsconfig.json
+++ b/tools/node_tools/utils/tsconfig.json
@@ -1,7 +1,7 @@
 {
   "compilerOptions": {
-    "target": "es6",
-    "module": "commonjs",
+    "target": "es2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "strict": true,
diff --git a/tools/node_tools/yarn.lock b/tools/node_tools/yarn.lock
index 4aa7ebb..edb4c98 100644
--- a/tools/node_tools/yarn.lock
+++ b/tools/node_tools/yarn.lock
@@ -2,255 +2,262 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
-  integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==
+"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5", "@babel/code-frame@^7.5.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
+  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
   dependencies:
-    "@babel/highlight" "^7.8.3"
+    "@babel/highlight" "^7.14.5"
+
+"@babel/compat-data@^7.14.7", "@babel/compat-data@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.15.0.tgz#2dbaf8b85334796cafbb0f5793a90a2fc010b176"
+  integrity sha512-0NqAC1IJE0S0+lL1SWFMxMkz1pKCNCjI4tr2Zx4LJSXxCLAdr6KyArnY+sno5m3yH9g737ygOyPABDsnXkpxiA==
 
 "@babel/core@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.8.3.tgz#30b0ebb4dd1585de6923a0b4d179e0b9f5d82941"
-  integrity sha512-4XFkf8AwyrEG7Ziu3L2L0Cv+WyY47Tcsp70JFmpftbAA1K7YL/sgE9jh9HyNj08Y/U50ItUchpN0w6HxAoX1rA==
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.15.0.tgz#749e57c68778b73ad8082775561f67f5196aafa8"
+  integrity sha512-tXtmTminrze5HEUPn/a0JtOzzfp0nk+UEXQ/tqIJo3WDGypl/2OFQEMll/zSFU8f/lfmfLXvTaORHF3cfXIQMw==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/generator" "^7.8.3"
-    "@babel/helpers" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/code-frame" "^7.14.5"
+    "@babel/generator" "^7.15.0"
+    "@babel/helper-compilation-targets" "^7.15.0"
+    "@babel/helper-module-transforms" "^7.15.0"
+    "@babel/helpers" "^7.14.8"
+    "@babel/parser" "^7.15.0"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
     convert-source-map "^1.7.0"
     debug "^4.1.0"
-    gensync "^1.0.0-beta.1"
-    json5 "^2.1.0"
-    lodash "^4.17.13"
-    resolve "^1.3.2"
-    semver "^5.4.1"
+    gensync "^1.0.0-beta.2"
+    json5 "^2.1.2"
+    semver "^6.3.0"
     source-map "^0.5.0"
 
-"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.8.3.tgz#0e22c005b0a94c1c74eafe19ef78ce53a4d45c03"
-  integrity sha512-WjoPk8hRpDRqqzRpvaR8/gDUPkrnOOeuT2m8cNICJtZH6mwaCo3v0OKMI7Y6SM1pBtyijnLtAL0HDi41pf41ug==
+"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.15.0.tgz#a7d0c172e0d814974bad5aa77ace543b97917f15"
+  integrity sha512-eKl4XdMrbpYvuB505KTta4AV9g+wWzmVBW69tX0H2NwKVKd2YJbKgyK6M8j/rgLbmHOYJn6rUklV677nOyJrEQ==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.15.0"
     jsesc "^2.5.1"
-    lodash "^4.17.13"
     source-map "^0.5.0"
 
-"@babel/helper-annotate-as-pure@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.8.3.tgz#60bc0bc657f63a0924ff9a4b4a0b24a13cf4deee"
-  integrity sha512-6o+mJrZBxOoEX77Ezv9zwW7WV8DdluouRKNY/IR5u/YTMuKHgugHOzYWlYvYLpLA9nPsQCAAASpCIbjI9Mv+Uw==
+"@babel/helper-annotate-as-pure@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.14.5.tgz#7bf478ec3b71726d56a8ca5775b046fc29879e61"
+  integrity sha512-EivH9EgBIb+G8ij1B2jAwSH36WnGvkQSEC6CkX/6v6ZFlw5fVOHvsgGF4uiEHO2GzMvunZb6tDLQEQSdrdocrA==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.8.3.tgz#c84097a427a061ac56a1c30ebf54b7b22d241503"
-  integrity sha512-5eFOm2SyFPK4Rh3XMMRDjN7lBH0orh3ss0g3rTYZnBQ+r6YPj7lgDyCvPphynHvUrobJmeMignBr6Acw9mAPlw==
+"@babel/helper-builder-binary-assignment-operator-visitor@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.14.5.tgz#b939b43f8c37765443a19ae74ad8b15978e0a191"
+  integrity sha512-YTA/Twn0vBXDVGJuAX6PwW7x5zQei1luDDo2Pl6q1qZ7hVNl0RZrhHCQG/ArGpR29Vl7ETiB8eJyrvpuRp300w==
   dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-explode-assignable-expression" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-call-delegate@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-call-delegate/-/helper-call-delegate-7.8.3.tgz#de82619898aa605d409c42be6ffb8d7204579692"
-  integrity sha512-6Q05px0Eb+N4/GTyKPPvnkig7Lylw+QzihMpws9iiZQv7ZImf84ZsZpQH7QoWN4n4tm81SnSzPgHw2qtO0Zf3A==
+"@babel/helper-compilation-targets@^7.14.5", "@babel/helper-compilation-targets@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.15.0.tgz#973df8cbd025515f3ff25db0c05efc704fa79818"
+  integrity sha512-h+/9t0ncd4jfZ8wsdAsoIxSa61qhBYlycXiHWqJaQBCXAhDCMbPRSMTGnZIkkmt1u4ag+UQmuqcILwqKzZ4N2A==
   dependencies:
-    "@babel/helper-hoist-variables" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/compat-data" "^7.15.0"
+    "@babel/helper-validator-option" "^7.14.5"
+    browserslist "^4.16.6"
+    semver "^6.3.0"
 
-"@babel/helper-create-regexp-features-plugin@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.8.3.tgz#c774268c95ec07ee92476a3862b75cc2839beb79"
-  integrity sha512-Gcsm1OHCUr9o9TcJln57xhWHtdXbA2pgQ58S0Lxlks0WMGNXuki4+GLfX0p+L2ZkINUGZvfkz8rzoqJQSthI+Q==
+"@babel/helper-create-regexp-features-plugin@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.14.5.tgz#c7d5ac5e9cf621c26057722fb7a8a4c5889358c4"
+  integrity sha512-TLawwqpOErY2HhWbGJ2nZT5wSkR192QpN+nBg1THfBfftrlvOh+WbhrxXCH4q4xJ9Gl16BGPR/48JA+Ryiho/A==
   dependencies:
-    "@babel/helper-regex" "^7.8.3"
-    regexpu-core "^4.6.0"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    regexpu-core "^4.7.1"
 
-"@babel/helper-define-map@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.8.3.tgz#a0655cad5451c3760b726eba875f1cd8faa02c15"
-  integrity sha512-PoeBYtxoZGtct3md6xZOCWPcKuMuk3IHhgxsRRNtnNShebf4C8YonTSblsK4tvDbm+eJAw2HAPOfCr+Q/YRG/g==
+"@babel/helper-explode-assignable-expression@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.14.5.tgz#8aa72e708205c7bb643e45c73b4386cdf2a1f645"
+  integrity sha512-Htb24gnGJdIGT4vnRKMdoXiOIlqOLmdiUYpAQ0mYfgVT/GDm8GOYhgi4GL+hMKrkiPRohO4ts34ELFsGAPQLDQ==
   dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/types" "^7.8.3"
-    lodash "^4.17.13"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-explode-assignable-expression@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.8.3.tgz#a728dc5b4e89e30fc2dfc7d04fa28a930653f982"
-  integrity sha512-N+8eW86/Kj147bO9G2uclsg5pwfs/fqqY5rwgIL7eTBklgXjcOJ3btzS5iM6AitJcftnY7pm2lGsrJVYLGjzIw==
+"@babel/helper-function-name@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.14.5.tgz#89e2c474972f15d8e233b52ee8c480e2cfcd50c4"
+  integrity sha512-Gjna0AsXWfFvrAuX+VKcN/aNNWonizBj39yGwUzVDVTlMYJMK2Wp6xdpy72mfArFq5uK+NOuexfzZlzI1z9+AQ==
   dependencies:
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-get-function-arity" "^7.14.5"
+    "@babel/template" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-function-name@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.8.3.tgz#eeeb665a01b1f11068e9fb86ad56a1cb1a824cca"
-  integrity sha512-BCxgX1BC2hD/oBlIFUgOCQDOPV8nSINxCwM3o93xP4P9Fq6aV5sgv2cOOITDMtCfQ+3PvHp3l689XZvAM9QyOA==
+"@babel/helper-get-function-arity@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.14.5.tgz#25fbfa579b0937eee1f3b805ece4ce398c431815"
+  integrity sha512-I1Db4Shst5lewOM4V+ZKJzQ0JGGaZ6VY1jYvMghRjqs6DWgxLCIyFt30GlnKkfUeFLpJt2vzbMVEXVSXlIFYUg==
   dependencies:
-    "@babel/helper-get-function-arity" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-get-function-arity@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.8.3.tgz#b894b947bd004381ce63ea1db9f08547e920abd5"
-  integrity sha512-FVDR+Gd9iLjUMY1fzE2SR0IuaJToR4RkCDARVfsBBPSP53GEqSFjD8gNyxg246VUyc/ALRxFaAK8rVG7UT7xRA==
+"@babel/helper-hoist-variables@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.14.5.tgz#e0dd27c33a78e577d7c8884916a3e7ef1f7c7f8d"
+  integrity sha512-R1PXiz31Uc0Vxy4OEOm07x0oSjKAdPPCh3tPivn/Eo8cvz6gveAeuyUUPB21Hoiif0uoPQSSdhIPS3352nvdyQ==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-hoist-variables@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.8.3.tgz#1dbe9b6b55d78c9b4183fc8cdc6e30ceb83b7134"
-  integrity sha512-ky1JLOjcDUtSc+xkt0xhYff7Z6ILTAHKmZLHPxAhOP0Nd77O+3nCsd6uSVYur6nJnCI029CrNbYlc0LoPfAPQg==
+"@babel/helper-member-expression-to-functions@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.15.0.tgz#0ddaf5299c8179f27f37327936553e9bba60990b"
+  integrity sha512-Jq8H8U2kYiafuj2xMTPQwkTBnEEdGKpT35lJEQsRRjnG0LW3neucsaMWLgKcwu3OHKNeYugfw+Z20BXBSEs2Lg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.15.0"
 
-"@babel/helper-member-expression-to-functions@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.8.3.tgz#659b710498ea6c1d9907e0c73f206eee7dadc24c"
-  integrity sha512-fO4Egq88utkQFjbPrSHGmGLFqmrshs11d46WI+WZDESt7Wu7wN2G2Iu+NMMZJFDOVRHAMIkB5SNh30NtwCA7RA==
+"@babel/helper-module-imports@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.14.5.tgz#6d1a44df6a38c957aa7c312da076429f11b422f3"
+  integrity sha512-SwrNHu5QWS84XlHwGYPDtCxcA0hrSlL2yhWYLgeOc0w7ccOl2qv4s/nARI0aYZW+bSwAL5CukeXA47B/1NKcnQ==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-module-imports@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.8.3.tgz#7fe39589b39c016331b6b8c3f441e8f0b1419498"
-  integrity sha512-R0Bx3jippsbAEtzkpZ/6FIiuzOURPcMjHp+Z6xPe6DtApDJx+w7UYyOLanZqO8+wKR9G10s/FmHXvxaMd9s6Kg==
+"@babel/helper-module-transforms@^7.14.5", "@babel/helper-module-transforms@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.15.0.tgz#679275581ea056373eddbe360e1419ef23783b08"
+  integrity sha512-RkGiW5Rer7fpXv9m1B3iHIFDZdItnO2/BLfWVW/9q7+KqQSDY5kUfQEbzdXM1MVhJGcugKV7kRrNVzNxmk7NBg==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/helper-module-imports" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.15.0"
+    "@babel/helper-simple-access" "^7.14.8"
+    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/helper-validator-identifier" "^7.14.9"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
 
-"@babel/helper-module-transforms@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.8.3.tgz#d305e35d02bee720fbc2c3c3623aa0c316c01590"
-  integrity sha512-C7NG6B7vfBa/pwCOshpMbOYUmrYQDfCpVL/JCRu0ek8B5p8kue1+BCXpg2vOYs7w5ACB9GTOBYQ5U6NwrMg+3Q==
+"@babel/helper-optimise-call-expression@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.14.5.tgz#f27395a8619e0665b3f0364cddb41c25d71b499c"
+  integrity sha512-IqiLIrODUOdnPU9/F8ib1Fx2ohlgDhxnIDU7OEVi+kAbEZcyiF7BLU8W6PfvPi9LzztjS7kcbzbmL7oG8kD6VA==
   dependencies:
-    "@babel/helper-module-imports" "^7.8.3"
-    "@babel/helper-simple-access" "^7.8.3"
-    "@babel/helper-split-export-declaration" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
-    lodash "^4.17.13"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-optimise-call-expression@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.8.3.tgz#7ed071813d09c75298ef4f208956006b6111ecb9"
-  integrity sha512-Kag20n86cbO2AvHca6EJsvqAd82gc6VMGule4HwebwMlwkpXuVqrNRj6CkCV2sKxgi9MyAUnZVnZ6lJ1/vKhHQ==
+"@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.8.0":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.14.5.tgz#5ac822ce97eec46741ab70a517971e443a70c5a9"
+  integrity sha512-/37qQCE3K0vvZKwoK4XU/irIJQdIfCJuhU5eKnNxpFDsOkgFaUAwbv+RYw6eYgsC0E4hS7r5KqGULUogqui0fQ==
+
+"@babel/helper-remap-async-to-generator@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.14.5.tgz#51439c913612958f54a987a4ffc9ee587a2045d6"
+  integrity sha512-rLQKdQU+HYlxBwQIj8dk4/0ENOUEhA/Z0l4hN8BexpvmSMN9oA9EagjnhnDpNsRdWCfjwa4mn/HyBXO9yhQP6A==
   dependencies:
-    "@babel/types" "^7.8.3"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    "@babel/helper-wrap-function" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.8.3.tgz#9ea293be19babc0f52ff8ca88b34c3611b208670"
-  integrity sha512-j+fq49Xds2smCUNYmEHF9kGNkhbet6yVIBp4e6oeQpH1RUs/Ir06xUKzDjDkGcaaokPiTNs2JBWHjaE4csUkZQ==
-
-"@babel/helper-regex@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.8.3.tgz#139772607d51b93f23effe72105b319d2a4c6965"
-  integrity sha512-BWt0QtYv/cg/NecOAZMdcn/waj/5P26DR4mVLXfFtDokSR6fyuG0Pj+e2FqtSME+MqED1khnSMulkmGl8qWiUQ==
+"@babel/helper-replace-supers@^7.14.5", "@babel/helper-replace-supers@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.15.0.tgz#ace07708f5bf746bf2e6ba99572cce79b5d4e7f4"
+  integrity sha512-6O+eWrhx+HEra/uJnifCwhwMd6Bp5+ZfZeJwbqUTuqkhIT6YcRhiZCOOFChRypOIe0cV46kFrRBlm+t5vHCEaA==
   dependencies:
-    lodash "^4.17.13"
+    "@babel/helper-member-expression-to-functions" "^7.15.0"
+    "@babel/helper-optimise-call-expression" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
 
-"@babel/helper-remap-async-to-generator@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.8.3.tgz#273c600d8b9bf5006142c1e35887d555c12edd86"
-  integrity sha512-kgwDmw4fCg7AVgS4DukQR/roGp+jP+XluJE5hsRZwxCYGg+Rv9wSGErDWhlI90FODdYfd4xG4AQRiMDjjN0GzA==
+"@babel/helper-simple-access@^7.14.8":
+  version "7.14.8"
+  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.14.8.tgz#82e1fec0644a7e775c74d305f212c39f8fe73924"
+  integrity sha512-TrFN4RHh9gnWEU+s7JloIho2T76GPwRHhdzOWLqTrMnlas8T9O7ec+oEDNsRXndOmru9ymH9DFrEOxpzPoSbdg==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-wrap-function" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.8"
 
-"@babel/helper-replace-supers@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.8.3.tgz#91192d25f6abbcd41da8a989d4492574fb1530bc"
-  integrity sha512-xOUssL6ho41U81etpLoT2RTdvdus4VfHamCuAm4AHxGr+0it5fnwoVdwUJ7GFEqCsQYzJUhcbsN9wB9apcYKFA==
+"@babel/helper-skip-transparent-expression-wrappers@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.14.5.tgz#96f486ac050ca9f44b009fbe5b7d394cab3a0ee4"
+  integrity sha512-dmqZB7mrb94PZSAOYtr+ZN5qt5owZIAgqtoTuqiFbHFtxgEcmQlRJVI+bO++fciBunXtB6MK7HrzrfcAzIz2NQ==
   dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.8.3"
-    "@babel/helper-optimise-call-expression" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-simple-access@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.8.3.tgz#7f8109928b4dab4654076986af575231deb639ae"
-  integrity sha512-VNGUDjx5cCWg4vvCTR8qQ7YJYZ+HBjxOgXEl7ounz+4Sn7+LMD3CFrCTEU6/qXKbA2nKg21CwhhBzO0RpRbdCw==
+"@babel/helper-split-export-declaration@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.14.5.tgz#22b23a54ef51c2b7605d851930c1976dd0bc693a"
+  integrity sha512-hprxVPu6e5Kdp2puZUmvOGjaLv9TCe58E/Fl6hRq4YiVQxIcNvuq6uTM2r1mT/oPskuS9CgR+I94sqAYv0NGKA==
   dependencies:
-    "@babel/template" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/types" "^7.14.5"
 
-"@babel/helper-split-export-declaration@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.8.3.tgz#31a9f30070f91368a7182cf05f831781065fc7a9"
-  integrity sha512-3x3yOeyBhW851hroze7ElzdkeRXQYQbFIb7gLK1WQYsw2GWDay5gAJNw1sWJ0VFP6z5J1whqeXH/WCdCjZv6dA==
-  dependencies:
-    "@babel/types" "^7.8.3"
+"@babel/helper-validator-identifier@^7.14.5", "@babel/helper-validator-identifier@^7.14.9":
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
+  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
 
-"@babel/helper-wrap-function@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz#9dbdb2bb55ef14aaa01fe8c99b629bd5352d8610"
-  integrity sha512-LACJrbUET9cQDzb6kG7EeD7+7doC3JNvUgTEQOx2qaO1fKlzE/Bf05qs9w1oXQMmXlPO65lC3Tq9S6gZpTErEQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+"@babel/helper-validator-option@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.14.5.tgz#6e72a1fff18d5dfcb878e1e62f1a021c4b72d5a3"
+  integrity sha512-OX8D5eeX4XwcroVW45NMvoYaIuFI+GQpA2a8Gi+X/U/cDUIRsV37qQfF905F0htTRCREQIB4KqPeaveRJUl3Ow==
 
-"@babel/helpers@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.8.3.tgz#382fbb0382ce7c4ce905945ab9641d688336ce85"
-  integrity sha512-LmU3q9Pah/XyZU89QvBgGt+BCsTPoQa+73RxAQh8fb8qkDyIfeQnmgs+hvzhTCKTzqOyk7JTkS3MS1S8Mq5yrQ==
+"@babel/helper-wrap-function@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.14.5.tgz#5919d115bf0fe328b8a5d63bcb610f51601f2bff"
+  integrity sha512-YEdjTCq+LNuNS1WfxsDCNpgXkJaIyqco6DAelTUjT4f2KIWC1nBcaCaSdHTBqQVLnTBexBcVcFhLSU1KnYuePQ==
   dependencies:
-    "@babel/template" "^7.8.3"
-    "@babel/traverse" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.14.5"
+    "@babel/types" "^7.14.5"
 
-"@babel/highlight@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.8.3.tgz#28f173d04223eaaa59bc1d439a3836e6d1265797"
-  integrity sha512-PX4y5xQUvy0fnEVHrYOarRPXVWafSjTW9T0Hab8gVIawpl2Sj0ORyrygANq+KjcNlSSTw0YCLSNA8OyZ1I4yEg==
+"@babel/helpers@^7.14.8":
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.15.3.tgz#c96838b752b95dcd525b4e741ed40bb1dc2a1357"
+  integrity sha512-HwJiz52XaS96lX+28Tnbu31VeFSQJGOeKHJeaEPQlTl7PnlhFElWPj8tUXtqFIzeN86XxXoBr+WFAyK2PPVz6g==
   dependencies:
+    "@babel/template" "^7.14.5"
+    "@babel/traverse" "^7.15.0"
+    "@babel/types" "^7.15.0"
+
+"@babel/highlight@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
+  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
+  dependencies:
+    "@babel/helper-validator-identifier" "^7.14.5"
     chalk "^2.0.0"
-    esutils "^2.0.2"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.8.3.tgz#790874091d2001c9be6ec426c2eed47bc7679081"
-  integrity sha512-/V72F4Yp/qmHaTALizEm9Gf2eQHV3QyTL3K0cNfijwnMnb1L+LDlAubb/ZnSdGAVzVSWakujHYs1I26x66sMeQ==
+"@babel/parser@^7.14.5", "@babel/parser@^7.15.0":
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.15.3.tgz#3416d9bea748052cfcb63dbcc27368105b1ed862"
+  integrity sha512-O0L6v/HvqbdJawj0iBEfVQMc3/6WP+AeOsovsIgBFyJaG+W2w7eqvZB7puddATmWuARlm1SX7DwxJ/JJUnDpEA==
 
 "@babel/plugin-external-helpers@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.8.3.tgz#5a94164d9af393b2820a3cdc407e28ebf237de4b"
-  integrity sha512-mx0WXDDiIl5DwzMtzWGRSPugXi9BxROS05GQrhLNbEamhBiicgn994ibwkyiBH+6png7bm/yA7AUsvHyCXi4Vw==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.14.5.tgz#920baa1569a8df5d5710abc342c7b1ac8968ed76"
+  integrity sha512-q/B/hLX+nDGk73Xn529d7Ar4ih17J8pNBbsXafq8oXij0XfFEA/bks+u+6q5q04zO5o/qivjzui6BqzPfYShEg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-proposal-async-generator-functions@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.8.3.tgz#bad329c670b382589721b27540c7d288601c6e6f"
-  integrity sha512-NZ9zLv848JsV3hs8ryEh7Uaz/0KsmPLqv0+PdkDJL1cJy0K4kOCFa8zc1E3mp+RHPQcpdfb/6GovEsW4VDrOMw==
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.14.9.tgz#7028dc4fa21dc199bbacf98b39bab1267d0eaf9a"
+  integrity sha512-d1lnh+ZnKrFKwtTYdw320+sQWCTwgkB9fmUhNXRADA4akR6wLjaruSGnIEUjpt9HCOwTr4ynFTKu19b7rFRpmw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-remap-async-to-generator" "^7.8.3"
-    "@babel/plugin-syntax-async-generators" "^7.8.0"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-remap-async-to-generator" "^7.14.5"
+    "@babel/plugin-syntax-async-generators" "^7.8.4"
 
 "@babel/plugin-proposal-object-rest-spread@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.8.3.tgz#eb5ae366118ddca67bed583b53d7554cad9951bb"
-  integrity sha512-8qvuPwU/xxUCt78HocNlv0mXXo0wdh9VT1R04WU8HGOfaOob26pF+9P5/lYjN/q7DHOX1bvX60hnhOvuQUJdbA==
+  version "7.14.7"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.14.7.tgz#5920a2b3df7f7901df0205974c0641b13fd9d363"
+  integrity sha512-082hsZz+sVabfmDWo1Oct1u1AgbKbUAyVgmX4otIc7bdsRgHBXwTwb3DpDmD4Eyyx6DNiuz5UAATT655k+kL5g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.0"
+    "@babel/compat-data" "^7.14.7"
+    "@babel/helper-compilation-targets" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
+    "@babel/plugin-transform-parameters" "^7.14.5"
 
-"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.0":
+"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.4":
   version "7.8.4"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
   integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
@@ -265,13 +272,13 @@
     "@babel/helper-plugin-utils" "^7.8.0"
 
 "@babel/plugin-syntax-import-meta@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.8.3.tgz#230afff79d3ccc215b5944b438e4e266daf3d84d"
-  integrity sha512-vYiGd4wQ9gx0Lngb7+bPCwQXGK/PR6FeTIJ+TIOlq+OfOKG/kCAOO2+IBac3oMM9qV7/fU76hfcqxUaLKZf1hQ==
+  version "7.10.4"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
+  integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.10.4"
 
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.0":
+"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3":
   version "7.8.3"
   resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
   integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
@@ -279,223 +286,228 @@
     "@babel/helper-plugin-utils" "^7.8.0"
 
 "@babel/plugin-transform-arrow-functions@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.8.3.tgz#82776c2ed0cd9e1a49956daeb896024c9473b8b6"
-  integrity sha512-0MRF+KC8EqH4dbuITCWwPSzsyO3HIWWlm30v8BbbpOrS1B++isGxPnnuq/IZvOX5J2D/p7DQalQm+/2PnlKGxg==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.14.5.tgz#f7187d9588a768dd080bf4c9ffe117ea62f7862a"
+  integrity sha512-KOnO0l4+tD5IfOdi4x8C1XmEIRWUjNRV8wc6K2vz/3e8yAOoZZvsRXRRIF/yo/MAOFb4QjtAw9xSxMXbSMRy8A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-async-to-generator@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.8.3.tgz#4308fad0d9409d71eafb9b1a6ee35f9d64b64086"
-  integrity sha512-imt9tFLD9ogt56Dd5CI/6XgpukMwd/fLGSrix2httihVe7LOGVPhyhMh1BU5kDM7iHD08i8uUtmV2sWaBFlHVQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.14.5.tgz#72c789084d8f2094acb945633943ef8443d39e67"
+  integrity sha512-szkbzQ0mNk0rpu76fzDdqSyPu0MuvpXgC+6rz5rpMb5OIRxdmHfQxrktL8CYolL2d8luMCZTR0DpIMIdL27IjA==
   dependencies:
-    "@babel/helper-module-imports" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-remap-async-to-generator" "^7.8.3"
+    "@babel/helper-module-imports" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-remap-async-to-generator" "^7.14.5"
 
 "@babel/plugin-transform-block-scoped-functions@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.8.3.tgz#437eec5b799b5852072084b3ae5ef66e8349e8a3"
-  integrity sha512-vo4F2OewqjbB1+yaJ7k2EJFHlTP3jR634Z9Cj9itpqNjuLXvhlVxgnjsHsdRgASR8xYDrx6onw4vW5H6We0Jmg==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.14.5.tgz#e48641d999d4bc157a67ef336aeb54bc44fd3ad4"
+  integrity sha512-dtqWqdWZ5NqBX3KzsVCWfQI3A53Ft5pWFCT2eCVUftWZgjc5DpDponbIF1+c+7cSGk2wN0YK7HGL/ezfRbpKBQ==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-block-scoping@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.8.3.tgz#97d35dab66857a437c166358b91d09050c868f3a"
-  integrity sha512-pGnYfm7RNRgYRi7bids5bHluENHqJhrV4bCZRwc5GamaWIIs07N4rZECcmJL6ZClwjDz1GbdMZFtPs27hTB06w==
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.15.3.tgz#94c81a6e2fc230bcce6ef537ac96a1e4d2b3afaf"
+  integrity sha512-nBAzfZwZb4DkaGtOes1Up1nOAp9TDRRFw4XBzBBSG9QK7KVFmYzgj9o9sbPv7TX5ofL4Auq4wZnxCoPnI/lz2Q==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    lodash "^4.17.13"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-classes@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.8.3.tgz#46fd7a9d2bb9ea89ce88720477979fe0d71b21b8"
-  integrity sha512-SjT0cwFJ+7Rbr1vQsvphAHwUHvSUPmMjMU/0P59G8U2HLFqSa082JO7zkbDNWs9kH/IUqpHI6xWNesGf8haF1w==
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.14.9.tgz#2a391ffb1e5292710b00f2e2c210e1435e7d449f"
+  integrity sha512-NfZpTcxU3foGWbl4wxmZ35mTsYJy8oQocbeIMoDAGGFarAmSQlL+LWMkDx/tj6pNotpbX3rltIA4dprgAPOq5A==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-define-map" "^7.8.3"
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-optimise-call-expression" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-replace-supers" "^7.8.3"
-    "@babel/helper-split-export-declaration" "^7.8.3"
+    "@babel/helper-annotate-as-pure" "^7.14.5"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-optimise-call-expression" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.14.5"
+    "@babel/helper-split-export-declaration" "^7.14.5"
     globals "^11.1.0"
 
 "@babel/plugin-transform-computed-properties@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.8.3.tgz#96d0d28b7f7ce4eb5b120bb2e0e943343c86f81b"
-  integrity sha512-O5hiIpSyOGdrQZRQ2ccwtTVkgUDBBiCuK//4RJ6UfePllUTCENOzKxfh6ulckXKc0DixTFLCfb2HVkNA7aDpzA==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.14.5.tgz#1b9d78987420d11223d41195461cc43b974b204f"
+  integrity sha512-pWM+E4283UxaVzLb8UBXv4EIxMovU4zxT1OPnpHJcmnvyY9QbPPTKZfEj31EUvG3/EQRbYAGaYEUZ4yWOBC2xg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-destructuring@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.8.3.tgz#20ddfbd9e4676906b1056ee60af88590cc7aaa0b"
-  integrity sha512-H4X646nCkiEcHZUZaRkhE2XVsoz0J/1x3VVujnn96pSoGCtKPA99ZZA+va+gK+92Zycd6OBKCD8tDb/731bhgQ==
+  version "7.14.7"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.14.7.tgz#0ad58ed37e23e22084d109f185260835e5557576"
+  integrity sha512-0mDE99nK+kVh3xlc5vKwB6wnP9ecuSj+zQCa/n0voENtP/zymdT4HH6QEb65wjjcbqr1Jb/7z9Qp7TF5FtwYGw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-duplicate-keys@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.8.3.tgz#8d12df309aa537f272899c565ea1768e286e21f1"
-  integrity sha512-s8dHiBUbcbSgipS4SMFuWGqCvyge5V2ZeAWzR6INTVC3Ltjig/Vw1G2Gztv0vU/hRG9X8IvKvYdoksnUfgXOEQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.14.5.tgz#365a4844881bdf1501e3a9f0270e7f0f91177954"
+  integrity sha512-iJjbI53huKbPDAsJ8EmVmvCKeeq21bAze4fu9GBQtSLqfvzj2oRuHVx4ZkDwEhg1htQ+5OBZh/Ab0XDf5iBZ7A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-exponentiation-operator@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.8.3.tgz#581a6d7f56970e06bf51560cd64f5e947b70d7b7"
-  integrity sha512-zwIpuIymb3ACcInbksHaNcR12S++0MDLKkiqXHl3AzpgdKlFNhog+z/K0+TGW+b0w5pgTq4H6IwV/WhxbGYSjQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.14.5.tgz#5154b8dd6a3dfe6d90923d61724bd3deeb90b493"
+  integrity sha512-jFazJhMBc9D27o9jDnIE5ZErI0R0m7PbKXVq77FFvqFbzvTMuv8jaAwLZ5PviOLSFttqKIW0/wxNSDbjLk0tYA==
   dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-for-of@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.8.3.tgz#15f17bce2fc95c7d59a24b299e83e81cedc22e18"
-  integrity sha512-ZjXznLNTxhpf4Q5q3x1NsngzGA38t9naWH8Gt+0qYZEJAcvPI9waSStSh56u19Ofjr7QmD0wUsQ8hw8s/p1VnA==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.14.5.tgz#dae384613de8f77c196a8869cbf602a44f7fc0eb"
+  integrity sha512-CfmqxSUZzBl0rSjpoQSFoR9UEj3HzbGuGNL21/iFTmjb5gFggJp3ph0xR1YBhexmLoKRHzgxuFvty2xdSt6gTA==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-function-name@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.8.3.tgz#279373cb27322aaad67c2683e776dfc47196ed8b"
-  integrity sha512-rO/OnDS78Eifbjn5Py9v8y0aR+aSYhDhqAwVfsTl0ERuMZyr05L1aFSCJnbv2mmsLkit/4ReeQ9N2BgLnOcPCQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.14.5.tgz#e81c65ecb900746d7f31802f6bed1f52d915d6f2"
+  integrity sha512-vbO6kv0fIzZ1GpmGQuvbwwm+O4Cbm2NrPzwlup9+/3fdkuzo1YqOZcXw26+YUJB84Ja7j9yURWposEHLYwxUfQ==
   dependencies:
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-instanceof@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.8.3.tgz#a44d7d71590da36be7429573300618aefd784c3d"
-  integrity sha512-c/jB6Ebe2u17hxo+rce6PDgbkuHyfcJOleqgHYttnvMrCsxVwUnYsMq7GhxXekzUQsv9IImhv6YICKihpen+Ag==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.14.5.tgz#8568277fbcfd7a3e4f3e6c8b7aa8ce4f60cba6e7"
+  integrity sha512-3CIpRzBLk5tEwIzjjD86KR8oMYrp1fl9q7kbdJa6O6Lcmkcee9DXfeO6zRXis//5gWRf63o5oDlNBh0VAlmtgw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-literals@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.8.3.tgz#aef239823d91994ec7b68e55193525d76dbd5dc1"
-  integrity sha512-3Tqf8JJ/qB7TeldGl+TT55+uQei9JfYaregDcEAyBZ7akutriFrt6C/wLYIer6OYhleVQvH/ntEhjE/xMmy10A==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.14.5.tgz#41d06c7ff5d4d09e3cf4587bd3ecf3930c730f78"
+  integrity sha512-ql33+epql2F49bi8aHXxvLURHkxJbSmMKl9J5yHqg4PLtdE6Uc48CH1GS6TQvZ86eoB/ApZXwm7jlA+B3kra7A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-modules-amd@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.8.3.tgz#65606d44616b50225e76f5578f33c568a0b876a5"
-  integrity sha512-MadJiU3rLKclzT5kBH4yxdry96odTUwuqrZM+GllFI/VhxfPz+k9MshJM+MwhfkCdxxclSbSBbUGciBngR+kEQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.14.5.tgz#4fd9ce7e3411cb8b83848480b7041d83004858f7"
+  integrity sha512-3lpOU8Vxmp3roC4vzFpSdEpGUWSMsHFreTWOMMLzel2gNGfHE5UWIh/LN6ghHs2xurUp4jRFYMUIZhuFbody1g==
   dependencies:
-    "@babel/helper-module-transforms" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
-    babel-plugin-dynamic-import-node "^2.3.0"
+    "@babel/helper-module-transforms" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    babel-plugin-dynamic-import-node "^2.3.3"
 
 "@babel/plugin-transform-object-super@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.8.3.tgz#ebb6a1e7a86ffa96858bd6ac0102d65944261725"
-  integrity sha512-57FXk+gItG/GejofIyLIgBKTas4+pEU47IXKDBWFTxdPd7F80H8zybyAY7UoblVfBhBGs2EKM+bJUu2+iUYPDQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.14.5.tgz#d0b5faeac9e98597a161a9cf78c527ed934cdc45"
+  integrity sha512-MKfOBWzK0pZIrav9z/hkRqIk/2bTv9qvxHzPQc12RcVkMOzpIKnFCNYJip00ssKWYkd8Sf5g0Wr7pqJ+cmtuFg==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-replace-supers" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-replace-supers" "^7.14.5"
 
-"@babel/plugin-transform-parameters@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.8.3.tgz#7890576a13b17325d8b7d44cb37f21dc3bbdda59"
-  integrity sha512-/pqngtGb54JwMBZ6S/D3XYylQDFtGjWrnoCF4gXZOUpFV/ujbxnoNGNvDGu6doFWRPBveE72qTx/RRU44j5I/Q==
+"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.14.5.tgz#49662e86a1f3ddccac6363a7dfb1ff0a158afeb3"
+  integrity sha512-Tl7LWdr6HUxTmzQtzuU14SqbgrSKmaR77M0OKyq4njZLQTPfOvzblNKyNkGwOfEFCEx7KeYHQHDI0P3F02IVkA==
   dependencies:
-    "@babel/helper-call-delegate" "^7.8.3"
-    "@babel/helper-get-function-arity" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-regenerator@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.8.3.tgz#b31031e8059c07495bf23614c97f3d9698bc6ec8"
-  integrity sha512-qt/kcur/FxrQrzFR432FGZznkVAjiyFtCOANjkAKwCbt465L6ZCiUQh2oMYGU3Wo8LRFJxNDFwWn106S5wVUNA==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.14.5.tgz#9676fd5707ed28f522727c5b3c0aa8544440b04f"
+  integrity sha512-NVIY1W3ITDP5xQl50NgTKlZ0GrotKtLna08/uGY6ErQt6VEQZXla86x/CTddm5gZdcr+5GSsvMeTmWA5Ii6pkg==
   dependencies:
-    regenerator-transform "^0.14.0"
+    regenerator-transform "^0.14.2"
 
 "@babel/plugin-transform-shorthand-properties@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.8.3.tgz#28545216e023a832d4d3a1185ed492bcfeac08c8"
-  integrity sha512-I9DI6Odg0JJwxCHzbzW08ggMdCezoWcuQRz3ptdudgwaHxTjxw5HgdFJmZIkIMlRymL6YiZcped4TTCB0JcC8w==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.14.5.tgz#97f13855f1409338d8cadcbaca670ad79e091a58"
+  integrity sha512-xLucks6T1VmGsTB+GWK5Pl9Jl5+nRXD1uoFdA5TSO6xtiNjtXTjKkmPdFXVLGlK5A2/or/wQMKfmQ2Y0XJfn5g==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-spread@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.8.3.tgz#9c8ffe8170fdfb88b114ecb920b82fb6e95fe5e8"
-  integrity sha512-CkuTU9mbmAoFOI1tklFWYYbzX5qCIZVXPVy0jpXgGwkplCndQAa58s2jr66fTeQnA64bDox0HL4U56CFYoyC7g==
+  version "7.14.6"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.14.6.tgz#6bd40e57fe7de94aa904851963b5616652f73144"
+  integrity sha512-Zr0x0YroFJku7n7+/HH3A2eIrGMjbmAIbJSVv0IZ+t3U2WUQUA64S/oeied2e+MaGSjmt4alzBCsK9E8gh+fag==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
+    "@babel/helper-skip-transparent-expression-wrappers" "^7.14.5"
 
 "@babel/plugin-transform-sticky-regex@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.8.3.tgz#be7a1290f81dae767475452199e1f76d6175b100"
-  integrity sha512-9Spq0vGCD5Bb4Z/ZXXSK5wbbLFMG085qd2vhL1JYu1WcQ5bXqZBAYRzU1d+p79GcHs2szYv5pVQCX13QgldaWw==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.14.5.tgz#5b617542675e8b7761294381f3c28c633f40aeb9"
+  integrity sha512-Z7F7GyvEMzIIbwnziAZmnSNpdijdr4dWt+FJNBnBLz5mwDFkqIXU9wmBcWWad3QeJF5hMTkRe4dAq2sUZiG+8A==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
-    "@babel/helper-regex" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-template-literals@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.8.3.tgz#7bfa4732b455ea6a43130adc0ba767ec0e402a80"
-  integrity sha512-820QBtykIQOLFT8NZOcTRJ1UNuztIELe4p9DCgvj4NK+PwluSJ49we7s9FB1HIGNIYT7wFUJ0ar2QpCDj0escQ==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.14.5.tgz#a5f2bc233937d8453885dc736bdd8d9ffabf3d93"
+  integrity sha512-22btZeURqiepOfuy/VkFr+zStqlujWaarpMErvay7goJS6BWwdd6BY9zQyDLDa4x2S3VugxFb162IZ4m/S/+Gg==
   dependencies:
-    "@babel/helper-annotate-as-pure" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-typeof-symbol@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.8.3.tgz#5cffb216fb25c8c64ba6bf5f76ce49d3ab079f4d"
-  integrity sha512-3TrkKd4LPqm4jHs6nPtSDI/SV9Cm5PRJkHLUgTcqRQQTMAZ44ZaAdDZJtvWFSaRcvT0a1rTmJ5ZA5tDKjleF3g==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.14.5.tgz#39af2739e989a2bd291bf6b53f16981423d457d4"
+  integrity sha512-lXzLD30ffCWseTbMQzrvDWqljvZlHkXU+CnseMhkMNqU1sASnCsz3tSzAaH3vCUXb9PHeUb90ZT1BdFTm1xxJw==
   dependencies:
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
 "@babel/plugin-transform-unicode-regex@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.8.3.tgz#0cef36e3ba73e5c57273effb182f46b91a1ecaad"
-  integrity sha512-+ufgJjYdmWfSQ+6NS9VGUR2ns8cjJjYbrbi11mZBTaWm+Fui/ncTLFF28Ei1okavY+xkojGr1eJxNsWYeA5aZw==
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.14.5.tgz#4cd09b6c8425dd81255c7ceb3fb1836e7414382e"
+  integrity sha512-UygduJpC5kHeCiRw/xDVzC+wj8VaYSoKl5JNVmbP7MadpNinAm3SvZCxZ42H37KZBKztz46YC73i9yV34d0Tzw==
   dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.8.3"
-    "@babel/helper-plugin-utils" "^7.8.3"
+    "@babel/helper-create-regexp-features-plugin" "^7.14.5"
+    "@babel/helper-plugin-utils" "^7.14.5"
 
-"@babel/template@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.3.tgz#e02ad04fe262a657809327f578056ca15fd4d1b8"
-  integrity sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ==
+"@babel/runtime@^7.8.4":
+  version "7.15.3"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.3.tgz#2e1c2880ca118e5b2f9988322bd8a7656a32502b"
+  integrity sha512-OvwMLqNXkCXSz1kSm58sEsNuhqOx/fKpnUnKnFB5v8uDda5bLNEHNgKPvhDN6IU0LDcnHQ90LlJ0Q6jnyBSIBA==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    regenerator-runtime "^0.13.4"
 
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.8.3.tgz#a826215b011c9b4f73f3a893afbc05151358bf9a"
-  integrity sha512-we+a2lti+eEImHmEXp7bM9cTxGzxPmBiVJlLVD+FuuQMeeO7RaDbutbgeheDkw+Xe3mCfJHnGOWLswT74m2IPg==
+"@babel/template@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.14.5.tgz#a9bc9d8b33354ff6e55a9c60d1109200a68974f4"
+  integrity sha512-6Z3Po85sfxRGachLULUhOmvAaOo7xCvqGQtxINai2mEGPFm6pQ4z5QInFnUrRpfoSV60BnjyF5F3c+15fxFV1g==
   dependencies:
-    "@babel/code-frame" "^7.8.3"
-    "@babel/generator" "^7.8.3"
-    "@babel/helper-function-name" "^7.8.3"
-    "@babel/helper-split-export-declaration" "^7.8.3"
-    "@babel/parser" "^7.8.3"
-    "@babel/types" "^7.8.3"
+    "@babel/code-frame" "^7.14.5"
+    "@babel/parser" "^7.14.5"
+    "@babel/types" "^7.14.5"
+
+"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.14.5", "@babel/traverse@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.15.0.tgz#4cca838fd1b2a03283c1f38e141f639d60b3fc98"
+  integrity sha512-392d8BN0C9eVxVWd8H6x9WfipgVH5IaIoLp23334Sc1vbKKWINnvwRpb4us0xtPaCumlwbTtIYNA0Dv/32sVFw==
+  dependencies:
+    "@babel/code-frame" "^7.14.5"
+    "@babel/generator" "^7.15.0"
+    "@babel/helper-function-name" "^7.14.5"
+    "@babel/helper-hoist-variables" "^7.14.5"
+    "@babel/helper-split-export-declaration" "^7.14.5"
+    "@babel/parser" "^7.15.0"
+    "@babel/types" "^7.15.0"
     debug "^4.1.0"
     globals "^11.1.0"
-    lodash "^4.17.13"
 
-"@babel/types@^7.0.0-beta.42", "@babel/types@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.8.3.tgz#5a383dffa5416db1b73dedffd311ffd0788fb31c"
-  integrity sha512-jBD+G8+LWpMBBWvVcdr4QysjUE4mU/syrhN17o1u3gx0/WzJB1kwiVZAXRtWbsIPOwW8pF/YJV5+nmetPzepXg==
+"@babel/types@^7.0.0-beta.42", "@babel/types@^7.14.5", "@babel/types@^7.14.8", "@babel/types@^7.15.0":
+  version "7.15.0"
+  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd"
+  integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ==
   dependencies:
-    esutils "^2.0.2"
-    lodash "^4.17.13"
+    "@babel/helper-validator-identifier" "^7.14.9"
     to-fast-properties "^2.0.0"
 
 "@bazel/concatjs@^5.1.0":
   version "5.1.0"
   resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.1.0.tgz#f4321dec4a225c3ceac41b2dc7ec7c3dd3dd5e21"
   integrity sha512-sj+vxHVB/swh7awOfQ37h3p/gxSPgLSnUkDt6POrj26qkfi7HrLB1ZkWAPFIIxjEhsBp1LchoHiezjw2GylZQg==
+  dependencies:
+    protobufjs "6.8.8"
+    source-map-support "0.5.9"
+    tsutils "3.21.0"
 
 "@bazel/rollup@^5.1.0":
   version "5.1.0"
@@ -522,6 +534,15 @@
   dependencies:
     google-protobuf "^3.6.1"
 
+"@dabh/diagnostics@^2.0.2":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31"
+  integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==
+  dependencies:
+    colorspace "1.1.x"
+    enabled "2.0.x"
+    kuler "^2.0.0"
+
 "@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"
@@ -536,50 +557,85 @@
   integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
 
 "@octokit/auth-token@^2.4.0":
+  version "2.4.5"
+  resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.5.tgz#568ccfb8cb46f36441fac094ce34f7a875b197f3"
+  integrity sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==
+  dependencies:
+    "@octokit/types" "^6.0.3"
+
+"@octokit/endpoint@^6.0.1":
+  version "6.0.12"
+  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.12.tgz#3b4d47a4b0e79b1027fb8d75d4221928b2d05658"
+  integrity sha512-lF3puPwkQWGfkMClXb4k/eUT/nZKQfxinRWJrdZaJO85Dqwo/G0yOC434Jr2ojwafWJMYqFGFa5ms4jJUgujdA==
+  dependencies:
+    "@octokit/types" "^6.0.3"
+    is-plain-object "^5.0.0"
+    universal-user-agent "^6.0.0"
+
+"@octokit/openapi-types@^10.0.0":
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-10.0.0.tgz#db4335de99509021f501fc4e026e6ff495fe1e62"
+  integrity sha512-k1iO2zKuEjjRS1EJb4FwSLk+iF6EGp+ZV0OMRViQoWhQ1fZTk9hg1xccZII5uyYoiqcbC73MRBmT45y1vp2PPg==
+
+"@octokit/plugin-paginate-rest@^1.1.1":
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz#004170acf8c2be535aba26727867d692f7b488fc"
+  integrity sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==
+  dependencies:
+    "@octokit/types" "^2.0.1"
+
+"@octokit/plugin-request-log@^1.0.0":
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.4.tgz#5e50ed7083a613816b1e4a28aeec5fb7f1462e85"
+  integrity sha512-mLUsMkgP7K/cnFEw07kWqXGF5LKrOkD+lhCrKvPHXWDywAwuDUeDwWBpc69XK3pNX0uKiVt8g5z96PJ6z9xCFA==
+
+"@octokit/plugin-rest-endpoint-methods@2.4.0":
   version "2.4.0"
-  resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.0.tgz#b64178975218b99e4dfe948253f0673cbbb59d9f"
-  integrity sha512-eoOVMjILna7FVQf96iWc3+ZtE/ZT6y8ob8ZzcqKY1ibSQCnu4O/B7pJvzMx5cyZ/RjAff6DAdEb0O0Cjcxidkg==
+  resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz#3288ecf5481f68c494dd0602fc15407a59faf61e"
+  integrity sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==
   dependencies:
-    "@octokit/types" "^2.0.0"
+    "@octokit/types" "^2.0.1"
+    deprecation "^2.3.1"
 
-"@octokit/endpoint@^5.5.0":
-  version "5.5.1"
-  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-5.5.1.tgz#2eea81e110ca754ff2de11c79154ccab4ae16b3f"
-  integrity sha512-nBFhRUb5YzVTCX/iAK1MgQ4uWo89Gu0TH00qQHoYRCsE12dWcG1OiLd7v2EIo2+tpUKPMOQ62QFy9hy9Vg2ULg==
+"@octokit/request-error@^1.0.2":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801"
+  integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==
   dependencies:
     "@octokit/types" "^2.0.0"
-    is-plain-object "^3.0.0"
-    universal-user-agent "^4.0.0"
+    deprecation "^2.0.0"
+    once "^1.4.0"
 
-"@octokit/request-error@^1.0.1", "@octokit/request-error@^1.0.2":
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.0.tgz#a64d2a9d7a13555570cd79722de4a4d76371baaa"
-  integrity sha512-DNBhROBYjjV/I9n7A8kVkmQNkqFAMem90dSxqvPq57e2hBr7mNTX98y3R2zDpqMQHVRpBDjsvsfIGgBzy+4PAg==
+"@octokit/request-error@^2.1.0":
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.1.0.tgz#9e150357831bfc788d13a4fd4b1913d60c74d677"
+  integrity sha512-1VIvgXxs9WHSjicsRwq8PlR2LR2x6DwsJAaFgzdi0JfJoGSO8mYI/cHJQ+9FbN21aa+DrgNLnwObmyeSC8Rmpg==
   dependencies:
-    "@octokit/types" "^2.0.0"
+    "@octokit/types" "^6.0.3"
     deprecation "^2.0.0"
     once "^1.4.0"
 
 "@octokit/request@^5.2.0":
-  version "5.3.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.3.1.tgz#3a1ace45e6f88b1be4749c5da963b3a3b4a2f120"
-  integrity sha512-5/X0AL1ZgoU32fAepTfEoggFinO3rxsMLtzhlUX+RctLrusn/CApJuGFCd0v7GMFhF+8UiCsTTfsu7Fh1HnEJg==
+  version "5.6.1"
+  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.6.1.tgz#f97aff075c37ab1d427c49082fefeef0dba2d8ce"
+  integrity sha512-Ls2cfs1OfXaOKzkcxnqw5MR6drMA/zWX/LIS/p8Yjdz7QKTPQLMsB3R+OvoxE6XnXeXEE2X7xe4G4l4X0gRiKQ==
   dependencies:
-    "@octokit/endpoint" "^5.5.0"
-    "@octokit/request-error" "^1.0.1"
-    "@octokit/types" "^2.0.0"
-    deprecation "^2.0.0"
-    is-plain-object "^3.0.0"
-    node-fetch "^2.3.0"
-    once "^1.4.0"
-    universal-user-agent "^4.0.0"
+    "@octokit/endpoint" "^6.0.1"
+    "@octokit/request-error" "^2.1.0"
+    "@octokit/types" "^6.16.1"
+    is-plain-object "^5.0.0"
+    node-fetch "^2.6.1"
+    universal-user-agent "^6.0.0"
 
 "@octokit/rest@^16.2.0":
-  version "16.38.3"
-  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.38.3.tgz#d5d200f88962392f71e048e12833ea36f4e0d192"
-  integrity sha512-Ui5W4Gzv0YHe9P3KDZAuU/BkRrT88PCuuATfWBMBf4fux4nB8th8LlyVAVnHKba1s/q4umci+sNHzoFYFujPEg==
+  version "16.43.2"
+  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.43.2.tgz#c53426f1e1d1044dee967023e3279c50993dd91b"
+  integrity sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==
   dependencies:
     "@octokit/auth-token" "^2.4.0"
+    "@octokit/plugin-paginate-rest" "^1.1.1"
+    "@octokit/plugin-request-log" "^1.0.0"
+    "@octokit/plugin-rest-endpoint-methods" "2.4.0"
     "@octokit/request" "^5.2.0"
     "@octokit/request-error" "^1.0.2"
     atob-lite "^2.0.0"
@@ -593,13 +649,20 @@
     once "^1.4.0"
     universal-user-agent "^4.0.0"
 
-"@octokit/types@^2.0.0":
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.1.1.tgz#77e80d1b663c5f1f829e5377b728fa3c4fe5a97d"
-  integrity sha512-89LOYH+d/vsbDX785NOfLxTW88GjNd0lWRz1DVPVsZgg9Yett5O+3MOvwo7iHgvUwbFz0mf/yPIjBkUbs4kxoQ==
+"@octokit/types@^2.0.0", "@octokit/types@^2.0.1":
+  version "2.16.2"
+  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.16.2.tgz#4c5f8da3c6fecf3da1811aef678fda03edac35d2"
+  integrity sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==
   dependencies:
     "@types/node" ">= 8"
 
+"@octokit/types@^6.0.3", "@octokit/types@^6.16.1":
+  version "6.26.0"
+  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.26.0.tgz#b8af298485d064ad9424cb41520541c1bf820346"
+  integrity sha512-RDxZBAFMtqs1ZPnbUu1e7ohPNfoNhTiep4fErY7tZs995BeHu369Vsh5woMIaFbllRWEZBfvTCS4hvDnMPiHrA==
+  dependencies:
+    "@octokit/openapi-types" "^10.0.0"
+
 "@polymer/esm-amd-loader@^1.0.0":
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
@@ -668,24 +731,36 @@
   resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
+"@sindresorhus/is@^4.0.0":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.1.tgz#d26729db850fa327b7cacc5522252194404226f5"
+  integrity sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g==
+
+"@szmarczak/http-timer@^4.0.5":
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807"
+  integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==
+  dependencies:
+    defer-to-connect "^2.0.0"
+
 "@types/babel-generator@^6.25.1":
-  version "6.25.3"
-  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
-  integrity sha512-pGgnuxVddKcYIc+VJkRDop7gxLhqclNKBdlsm/5Vp8d+37pQkkDK7fef8d9YYImRzw9xcojEPc18pUYnbxmjqA==
+  version "6.25.4"
+  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.4.tgz#74eacdaa4822c4c6923e68c541144a04415ad8a1"
+  integrity sha512-Rnsen+ckop5mbl9d43bempS7i9wdTN1vytiTlmQla/YiNm6kH8kEVABVSXmp1UbnpkUV44nUCPeDQoa+Mu7ALA==
   dependencies:
     "@types/babel-types" "*"
 
 "@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
-  version "6.25.5"
-  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.5.tgz#6d293cf7523e48b524faa7b86dc3c488191484e5"
-  integrity sha512-WrMbwmu+MWf8FiUMbmVOGkc7bHPzndUafn1CivMaBHthBBoo0VNIcYk1KV71UovYguhsNOwf3UF5oRmkkGOU3w==
+  version "6.25.7"
+  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.7.tgz#bc75fce23d8394534562a36a32dec94a54d11835"
+  integrity sha512-BeQiEGLnVzypzBdsexEpZAHUx+WucOMXW6srEWDkl4SegBlaCy+iBvRO+4vz6EZ+BNQg22G4MCdDdvZxf+jW5A==
   dependencies:
     "@types/babel-types" "*"
 
 "@types/babel-types@*":
-  version "7.0.7"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.7.tgz#667eb1640e8039436028055737d2b9986ee336e3"
-  integrity sha512-dBtBbrc+qTHy1WdfHYjBwRln4+LWqASWakLHsWHR2NWHIFkv4W3O070IGoGLEBrJBvct3r0L1BUPuvURi7kYUQ==
+  version "7.0.11"
+  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.11.tgz#263b113fa396fac4373188d73225297fb86f19a9"
+  integrity sha512-pkPtJUUY+Vwv6B1inAz55rQvivClHJxc9aVEPPmaq2cbyeMLCiDpbKpcKyX4LAwpNGi+SHBv0tHv6+0gXv0P2A==
 
 "@types/babel-types@^6.25.1":
   version "6.25.2"
@@ -693,25 +768,35 @@
   integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
 
 "@types/babylon@^6.16.2":
-  version "6.16.5"
-  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4"
-  integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==
+  version "6.16.6"
+  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.6.tgz#a1e7e01567b26a5ebad321a74d10299189d8d932"
+  integrity sha512-G4yqdVlhr6YhzLXFKy5F7HtRBU8Y23+iWy7UKthMq/OSQnL1hbsoeXESQ2LY8zEDlknipDG3nRGhUC9tkwvy/w==
   dependencies:
     "@types/babel-types" "*"
 
 "@types/bluebird@*":
-  version "3.5.29"
-  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.29.tgz#7cd933c902c4fc83046517a1bef973886d00bdb6"
-  integrity sha512-kmVtnxTuUuhCET669irqQmPAez4KFnFVKvpleVRyfC3g+SHD1hIkFZcWLim9BVcwUBLO59o8VZE4yGCmTif8Yw==
+  version "3.5.36"
+  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.36.tgz#00d9301d4dc35c2f6465a8aec634bb533674c652"
+  integrity sha512-HBNx4lhkxN7bx6P0++W8E289foSu8kO8GCk2unhuVggO+cE7rh9DhZUyPhUxNRG9m+5B5BTKxZQ5ZP92x/mx9Q==
 
 "@types/body-parser@*":
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.17.1.tgz#18fcf61768fb5c30ccc508c21d6fd2e8b3bf7897"
-  integrity sha512-RoX2EZjMiFMjZh9lmYrwgoP9RTpAjSHiJxdp4oidAQVO02T7HER3xj9UKue5534ULWeqVEkujhWcyvUce+d68w==
+  version "1.19.1"
+  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.1.tgz#0c0174c42a7d017b818303d4b5d969cb0b75929c"
+  integrity sha512-a6bTJ21vFOGIkwM0kzh9Yr89ziVxq4vYH2fQ6N8AeipEzai/cFK6aGMArIkUeIdRIgpwQa+2bXiLuUJCpSf2Cg==
   dependencies:
     "@types/connect" "*"
     "@types/node" "*"
 
+"@types/cacheable-request@^6.0.1":
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9"
+  integrity sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==
+  dependencies:
+    "@types/http-cache-semantics" "*"
+    "@types/keyv" "*"
+    "@types/node" "*"
+    "@types/responselike" "*"
+
 "@types/chai-subset@^1.3.0":
   version "1.3.3"
   resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
@@ -720,9 +805,9 @@
     "@types/chai" "*"
 
 "@types/chai@*":
-  version "4.2.7"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.7.tgz#1c8c25cbf6e59ffa7d6b9652c78e547d9a41692d"
-  integrity sha512-luq8meHGYwvky0O7u0eQZdA7B4Wd9owUCqvbw2m3XCrCU8mplYOujMBbvyS547AxJkC+pGnd0Cm15eNxEUNU8g==
+  version "4.2.21"
+  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.21.tgz#9f35a5643129df132cf3b5c1ec64046ea1af0650"
+  integrity sha512-yd+9qKmJxm496BOV9CMNaey8TWsikaZOwMRwPHQIjcOJM9oV+fi9ZMNw3JsVnbEEbo2gRTDnGEBv8pjyn67hNg==
 
 "@types/chalk@^0.4.30":
   version "0.4.31"
@@ -737,22 +822,18 @@
     chalk "*"
 
 "@types/clean-css@*":
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.1.tgz#cb0134241ec5e6ede1b5344bc829668fd9871a8d"
-  integrity sha512-A1HQhQ0hkvqqByJMgg+Wiv9p9XdoYEzuwm11SVo1mX2/4PSdhjcrUlilJQoqLscIheC51t1D5g+EFWCXZ2VTQQ==
+  version "4.2.5"
+  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.5.tgz#69ce62cc13557c90ca40460133f672dc52ceaf89"
+  integrity sha512-NEzjkGGpbs9S9fgC4abuBvTpVwE3i+Acu9BBod3PUyjDVZcNsGx61b8r2PphR61QGPnn0JHVs5ey6/I4eTrkxw==
   dependencies:
     "@types/node" "*"
+    source-map "^0.6.0"
 
 "@types/clone@^0.1.29", "@types/clone@^0.1.30":
   version "0.1.30"
   resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
   integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
 
-"@types/color-name@^1.1.1":
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
-  integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
-
 "@types/compression@^0.0.33":
   version "0.0.33"
   resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
@@ -761,21 +842,21 @@
     "@types/express" "*"
 
 "@types/connect@*":
-  version "3.4.33"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.33.tgz#31610c901eca573b8713c3330abc6e6b9f588546"
-  integrity sha512-2+FrkXY4zllzTNfJth7jOqEHC+enpLeGslEhpnTAkg21GkRrWV4SsAtqchtT4YS9/nODBU2/ZfsBY2X4J/dX7A==
+  version "3.4.35"
+  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
+  integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
   dependencies:
     "@types/node" "*"
 
 "@types/content-type@^1.1.0":
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.3.tgz#3688bd77fc12f935548eef102a4e34c512b03a07"
-  integrity sha512-pv8VcFrZ3fN93L4rTNIbbUzdkzjEyVMp5mPVjsFfOYTDOZMZiZ8P1dhu+kEv3faYyKzZgLlSvnyQNFg+p/v5ug==
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.5.tgz#aa02dca40864749a9e2bf0161a6216da57e3ede5"
+  integrity sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==
 
 "@types/cssbeautify@^0.3.1":
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.1.tgz#8e0bee8f7decb952250da0caebe05e30591c17ef"
-  integrity sha1-jgvuj33suVIlDaDK6+BeMFkcF+8=
+  version "0.3.2"
+  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.2.tgz#8a76207cd980d3e7b29b4b6dea1f4ed861285615"
+  integrity sha512-b3PXlFAcS4gvGr2pDz0NoZEBo3MMQe8Ozy6+Mvm3XIEcHS4oQstvCnnCofBZD/0tQgxSzkYbW+cD3yD4yaKTxQ==
 
 "@types/del@^3.0.0":
   version "3.0.1"
@@ -795,9 +876,9 @@
   integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
 
 "@types/estree@*":
-  version "0.0.42"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.42.tgz#8d0c1f480339efedb3e46070e22dd63e0430dd11"
-  integrity sha512-K1DPVvnBCPxzD+G51/cxVIoc2X8uUVl1zpJeE6iKcgHMj4+tbat5Xu4TjV7v2QSDbIeAfLi2hIk+u2+s0MlpUQ==
+  version "0.0.50"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
+  integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
 
 "@types/events@*":
   version "3.0.0"
@@ -809,21 +890,23 @@
   resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
   integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
 
-"@types/express-serve-static-core@*":
-  version "4.17.2"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.2.tgz#f6f41fa35d42e79dbf6610eccbb2637e6008a0cf"
-  integrity sha512-El9yMpctM6tORDAiBwZVLMcxoTMcqqRO9dVyYcn7ycLWbvR8klrDn8CAOwRfZujZtWD7yS/mshTdz43jMOejbg==
+"@types/express-serve-static-core@^4.17.18":
+  version "4.17.24"
+  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.24.tgz#ea41f93bf7e0d59cd5a76665068ed6aab6815c07"
+  integrity sha512-3UJuW+Qxhzwjq3xhwXm2onQcFHn76frIYVbTu+kn24LFxI+dEhdfISDFovPB8VpEgW8oQCTpRuCe+0zJxB7NEA==
   dependencies:
     "@types/node" "*"
+    "@types/qs" "*"
     "@types/range-parser" "*"
 
 "@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
-  version "4.17.2"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.2.tgz#a0fb7a23d8855bac31bc01d5a58cadd9b2173e6c"
-  integrity sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==
+  version "4.17.13"
+  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
+  integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
   dependencies:
     "@types/body-parser" "*"
-    "@types/express-serve-static-core" "*"
+    "@types/express-serve-static-core" "^4.17.18"
+    "@types/qs" "*"
     "@types/serve-static" "*"
 
 "@types/fast-levenshtein@0.0.1":
@@ -846,24 +929,23 @@
     form-data "*"
 
 "@types/freeport@^1.0.19":
-  version "1.0.21"
-  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
-  integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
+  version "1.0.22"
+  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.22.tgz#dbe627a20cb30c17c8aaaba09332e1d14cc2281f"
+  integrity sha512-UGg4s5PDPXZXkkrHarU1l6WDbULxN3g7xUEtdbNf9HQhU/JnCj1G1/xZHZmQjC0uWqN1LlB0R0xOlk3k5svgTQ==
 
 "@types/glob-stream@*":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
-  integrity sha512-RHv6ZQjcTncXo3thYZrsbAVwoy4vSKosSWhuhuQxLOTv74OJuFQxXkmUuZCr3q9uNBEVCvIzmZL/FeRNbHZGUg==
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.1.tgz#c792d8d1514278ff03cad5689aba4c4ab4fbc805"
+  integrity sha512-AGOUTsTdbPkRS0qDeyeS+6KypmfVpbT5j23SN8UPG63qjKXNKjXn6V9wZUr8Fin0m9l8oGYaPK8b2WUMF8xI1A==
   dependencies:
     "@types/glob" "*"
     "@types/node" "*"
 
-"@types/glob@*":
-  version "7.1.1"
-  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"
-  integrity sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==
+"@types/glob@*", "@types/glob@^7.1.1":
+  version "7.1.4"
+  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.4.tgz#ea59e21d2ee5c517914cb4bc8e4153b99e566672"
+  integrity sha512-w+LsMxKyYQm347Otw+IfBXOv9UWVjpHpCDdbBMt8Kz/xbvCYNjP+0qPh91Km3iKfSRLBB0P7fAMf0KHrPu+MyA==
   dependencies:
-    "@types/events" "*"
     "@types/minimatch" "*"
     "@types/node" "*"
 
@@ -891,10 +973,15 @@
     "@types/relateurl" "*"
     "@types/uglify-js" "*"
 
+"@types/http-cache-semantics@*":
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812"
+  integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==
+
 "@types/inquirer@*":
-  version "6.5.0"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-6.5.0.tgz#b83b0bf30b88b8be7246d40e51d32fe9d10e09be"
-  integrity sha512-rjaYQ9b9y/VFGOpqBEXRavc3jh0a+e6evAbI31tMda8VlPaSy0AZJfXsvmIe3wklc7W6C3zCSfleuMXR7NOyXw==
+  version "7.3.3"
+  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.3.tgz#92e6676efb67fa6925c69a2ee638f67a822952ac"
+  integrity sha512-HhxyLejTHMfohAuhRun4csWigAMjXTmRyiJTU1Y/I1xmggikFMkOUoMQRlFm+zQcPEGHSs3io/0FAmNZf8EymQ==
   dependencies:
     "@types/through" "*"
     rxjs "^6.4.0"
@@ -912,10 +999,17 @@
   resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
   integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
 
+"@types/keyv@*":
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.2.tgz#5d97bb65526c20b6e0845f6b0d2ade4f28604ee5"
+  integrity sha512-/FvAK2p4jQOaJ6CGDHJTqZcUtbZe820qIeTg7o0Shg7drB4JHeL+V/dhSaly7NXx6u8eSee+r7coT+yuJEvDLg==
+  dependencies:
+    "@types/node" "*"
+
 "@types/launchpad@^0.6.0":
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
-  integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.1.tgz#9a5f285128598f5e0cb4a5db3e458e1b7cf07f35"
+  integrity sha512-kQ1a7PwzJelwwOIw1SABmW5OsbCRPvdjps0J84MahGsEKzN89StrPyrWCMWfwpONR3ZqSxDeblxS+8WznIBEGw==
 
 "@types/long@^4.0.0":
   version "4.0.1"
@@ -929,15 +1023,20 @@
   dependencies:
     "@types/node" "*"
 
-"@types/mime@*", "@types/mime@^2.0.0":
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.1.tgz#dc488842312a7f075149312905b5e3c0b054c79d"
-  integrity sha512-FwI9gX75FgVBJ7ywgnq/P7tw+/o1GUbtP0KzbtusLigAOgIgNISRK0ZPl4qertvXSIE8YbsVJueQ90cDt9YYyw==
+"@types/mime@^1":
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
+  integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
 
-"@types/minimatch@*", "@types/minimatch@^3.0.1":
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
-  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
+"@types/mime@^2.0.0":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
+  integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
+
+"@types/minimatch@*", "@types/minimatch@^3.0.1", "@types/minimatch@^3.0.3":
+  version "3.0.5"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
+  integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==
 
 "@types/mz@0.0.29":
   version "0.0.29"
@@ -955,29 +1054,29 @@
     "@types/node" "*"
 
 "@types/node@*", "@types/node@>= 8":
-  version "13.5.0"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-13.5.0.tgz#4e498dbf355795a611a87ae5ef811a8660d42662"
-  integrity sha512-Onhn+z72D2O2Pb2ql2xukJ55rglumsVo1H6Fmyi8mlU9SvKdBk/pUSUAiBY/d9bAOF7VVWajX3sths/+g6ZiAQ==
+  version "16.7.10"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.7.10.tgz#7aa732cc47341c12a16b7d562f519c2383b6d4fc"
+  integrity sha512-S63Dlv4zIPb8x6MMTgDq5WWRJQe56iBEY0O3SOFA9JrRienkOVDXSXBjjJw6HTNQYSE2JI6GMCR6LVbIMHJVvA==
 
 "@types/node@6.0.*":
   version "6.0.118"
   resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.118.tgz#8014a9b1dee0b72b4d7cd142563f1af21241c3a2"
   integrity sha512-N33cKXGSqhOYaPiT4xUGsYlPPDwFtQM/6QxJxuMXA/7BcySW+lkn2yigWP7vfs4daiL/7NJNU6DMCqg5N4B+xQ==
 
-"@types/node@^10.1.0":
-  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"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.24.tgz#c57511e3a19c4b5e9692bb2995c40a3a52167944"
-  integrity sha512-5SCfvCxV74kzR3uWgTYiGxrd69TbT1I6+cMx1A5kEly/IVveJBimtAMlXiEyVFn5DvUFewQWxOOiJhlxeQwxgA==
+"@types/node@^10.1.0", "@types/node@^10.17.12":
+  version "10.17.60"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
+  integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
 
 "@types/node@^4.0.30":
-  version "4.9.4"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.4.tgz#75ef91733afaa856b01e12da6ecf48aa9d5e221f"
-  integrity sha512-nKoiCZ87x6+fs26bNHjy07AQt6f46nFEitGH0P9JmWbY6tEyum6LLfLf7SIsKFh4DnBWsyUM2gYhaQAt+aA0Sw==
+  version "4.9.5"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.5.tgz#a3785db96b07a4b56466cc99fd624838746f2e25"
+  integrity sha512-+8fpgbXsbATKRF2ayAlYhPl2E9MPdLjrnK/79ZEpyPJ+k7dZwJm9YM8FK+l4rqL//xHk7PgQhGwz6aA2ckxbCQ==
+
+"@types/normalize-package-data@^2.4.0":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
+  integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
 
 "@types/opn@^3.0.28":
   version "3.0.28"
@@ -994,17 +1093,17 @@
     "@types/parse5-sax-parser" "*"
 
 "@types/parse5-sax-parser@*":
-  version "5.0.1"
-  resolved "https://registry.yarnpkg.com/@types/parse5-sax-parser/-/parse5-sax-parser-5.0.1.tgz#f1e26e82bb09e48cb0c16ff6d1e88aea1e538fd5"
-  integrity sha512-wBEwg10aACLggnb44CwzAA27M1Jrc/8TR16zA61/rKO5XZoi7JSfLjdpXbshsm7wOlM6hpfvwygh40rzM2RsQQ==
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/@types/parse5-sax-parser/-/parse5-sax-parser-5.0.2.tgz#4cdca0f8bc0ce71b17e27b96e7ca9b5f79e861ff"
+  integrity sha512-EQtGoduLbdMmS4N27g6wcXdCCJ70dWYemfogWuumYg+JmzRqwYvTRAbGOYFortSHtS/qRzRCFwcP3ixy62RsdA==
   dependencies:
     "@types/node" "*"
     "@types/parse5" "*"
 
 "@types/parse5@*":
-  version "5.0.2"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.2.tgz#a877a4658f8238c8266faef300ae41c84d72ec8a"
-  integrity sha512-BOl+6KDs4ItndUWUFchy3aEqGdHhw0BC4Uu+qoDonN/f0rbUnJbm71Ulj8Tt9jLFRaAxPLKvdS1bBLfx1qXR9g==
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.1.tgz#f8ae4fbcd2b9ba4ff934698e28778961f9cb22ca"
+  integrity sha512-ARATsLdrGPUnaBvxLhUlnltcMgn7pQG312S8ccdYlnyijabrX9RN/KN/iGj9Am96CoW8e/K9628BA7Bv4XHdrA==
 
 "@types/parse5@^0.0.31":
   version "0.0.31"
@@ -1021,9 +1120,9 @@
     "@types/node" "*"
 
 "@types/parse5@^4.0.0":
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-4.0.0.tgz#26dd73df171a69be517395d294c7af2ae0cd2579"
-  integrity sha512-OaBwNFk6dO8gbdfWut41VYiD5Fmj3Yi24cr/oGCXFXCjT2fteSQx2l3kx/phuQvBte/F54ajN2uDQF5MRwupGw==
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-4.0.1.tgz#ec53c3f948f284be08454f622ba83765efdd06d7"
+  integrity sha512-CgJIkoNLclXUUg5cEo/SybyhxgVuKGqGcw8BPBpkKWX7wGcNfDlO2Ot+5O9u5E6k3NDg6RmJzm5w5N2prDIE8Q==
   dependencies:
     "@types/node" "*"
 
@@ -1033,21 +1132,26 @@
   integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
 
 "@types/pem@^1.8.1":
-  version "1.9.5"
-  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.5.tgz#cd5548b5e0acb4b41a9e21067e9fcd8c57089c99"
-  integrity sha512-C0txxEw8B7DCoD85Ko7SEvzUogNd5VDJ5/YBG8XUcacsOGqxr5Oo4g3OUAfdEDUbhXanwUoVh/ZkMFw77FGPQQ==
+  version "1.9.6"
+  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.6.tgz#c3686832e935947fdd9d848dec3b8fe830068de7"
+  integrity sha512-IC67SxacM9fxEi/w7hf98dTun83OwUMeLMo1NS2gE0wdM9MHeg73iH/Pp9nB02OUCQ7Zb2UuKE/IpFCmQw9jxw==
   dependencies:
     "@types/node" "*"
 
+"@types/qs@*":
+  version "6.9.7"
+  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
+  integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
+
 "@types/range-parser@*":
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
-  integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
+  integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
 
 "@types/relateurl@*":
-  version "0.2.28"
-  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
-  integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
+  version "0.2.29"
+  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.29.tgz#68ccecec3d4ffdafb9c577fe764f912afc050fe6"
+  integrity sha512-QSvevZ+IRww2ldtfv1QskYsqVVVwCKQf1XbwtcyyoRvLIQzfyPhj/C+3+PKzSDRdiyejaiLgnq//XTkleorpLg==
 
 "@types/request@2.0.3":
   version "2.0.3"
@@ -1085,6 +1189,13 @@
   dependencies:
     "@types/node" "*"
 
+"@types/responselike@*", "@types/responselike@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"
+  integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==
+  dependencies:
+    "@types/node" "*"
+
 "@types/rimraf@^0.0.28":
   version "0.0.28"
   resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-0.0.28.tgz#5562519bc7963caca8abf7f128cae3b594d41d06"
@@ -1174,9 +1285,9 @@
     "@types/rx-core-binding" "*"
 
 "@types/rx@*":
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.1.tgz#598fc94a56baed975f194574e0f572fd8e627a48"
-  integrity sha1-WY/JSla67ZdfGUV04PVy/Y5iekg=
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.2.tgz#a4061b3d72b03cf11a38d69e2022a17334c54dc0"
+  integrity sha512-1r8ZaT26Nigq7o4UBGl+aXB2UMFUIdLPP/8bLIP0x3d0pZL46ybKKjhWKaJQWIkLl5QCLD0nK3qTOO1QkwdFaA==
   dependencies:
     "@types/rx-core" "*"
     "@types/rx-core-binding" "*"
@@ -1197,17 +1308,17 @@
   integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
 
 "@types/serve-static@*", "@types/serve-static@^1.7.31":
-  version "1.13.3"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.3.tgz#eb7e1c41c4468272557e897e9171ded5e2ded9d1"
-  integrity sha512-oprSwp094zOglVrXdlo/4bAHtKTAxX6VT8FOZlBKrmyLbNvE1zxZyJ6yikMVtHIvwP45+ZQGJn+FdXGKTozq0g==
+  version "1.13.10"
+  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
+  integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
   dependencies:
-    "@types/express-serve-static-core" "*"
-    "@types/mime" "*"
+    "@types/mime" "^1"
+    "@types/node" "*"
 
 "@types/spdy@^3.4.1":
-  version "3.4.4"
-  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
-  integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
+  version "3.4.5"
+  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.5.tgz#194dc132312ddcd31e8053789ae83a7bb32a8aaf"
+  integrity sha512-/33fIRK/aqkKNxg9BSjpzt1ucmvPremgeDywm9z2C2mOlIh5Ljjvgc3UhQHqwXsSLDLHPT9jlsnrjKQ1XiVJzA==
   dependencies:
     "@types/node" "*"
 
@@ -1226,28 +1337,26 @@
     "@types/node" "*"
 
 "@types/ua-parser-js@^0.7.31":
-  version "0.7.33"
-  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.33.tgz#4a92089511574e12928a7cb6b99a01831acd1dd7"
-  integrity sha512-ngUKcHnytUodUCL7C6EZ+lVXUjTMQb+9p/e1JjV5tN9TVzS98lHozWEFRPY1QcCdwFeMsmVWfZ3DPPT/udCyIw==
+  version "0.7.36"
+  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190"
+  integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==
 
 "@types/uglify-js@*":
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
-  integrity sha512-SudIN9TRJ+v8g5pTG8RRCqfqTMNqgWCKKd3vtynhGzkIIjxaicNAMuY5TRadJ6tzDu3Dotf3ngaMILtmOdmWEQ==
+  version "3.13.1"
+  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.13.1.tgz#5e889e9e81e94245c75b6450600e1c5ea2878aea"
+  integrity sha512-O3MmRAk6ZuAKa9CHgg0Pr0+lUOqoMLpc9AS4R8ano2auvsg7IE8syF3Xh/NPr26TWklxYcqoEEFdzLLs1fV9PQ==
   dependencies:
     source-map "^0.6.1"
 
 "@types/update-notifier@^1.0.0":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.3.tgz#3c7ee1921af6f16149cdcaef356baf57d7a0b806"
-  integrity sha512-BLStNhP2DFF7funARwTcoD6tetRte8NK3Sc59mn7GNALCN975jOlKX3dGvsFxXr/HwQMxxCuRn9IWB3WQ7odHQ==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.4.tgz#ce73d597bd399d5df4544fe136a79c2b9fe41958"
+  integrity sha512-smyU9GTDitojg87woCcLNCdPnUfNx4LHRBWf+aWmHsAgE1kaCDhhcu84W+dFymAKL1yKDsq2JFWKkR2K6WjJfw==
 
 "@types/uuid@^3.4.3":
-  version "3.4.6"
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.6.tgz#d2c4c48eb85a757bf2927f75f939942d521e3016"
-  integrity sha512-cCdlC/1kGEZdEglzOieLDYBxHsvEOIg7kp/2FYyVR9Pxakq+Qf/inL3RKQ+PA8gOlI/NnL+fXmQH12nwcGzsHw==
-  dependencies:
-    "@types/node" "*"
+  version "3.4.10"
+  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.10.tgz#637d3c8431f112edf6728ac9bdfadfe029540f48"
+  integrity sha512-BgeaZuElf7DEYZhWYDTc/XcLZXdVgFkVSTa13BqKvbnmUrxr3TJFKofUxCtDO9UQOdhnV+HPOESdHiHKZOJV1A==
 
 "@types/vinyl-fs@0.0.28":
   version "0.0.28"
@@ -1259,18 +1368,18 @@
     "@types/vinyl" "*"
 
 "@types/vinyl-fs@^2.4.8":
-  version "2.4.11"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.11.tgz#b98119b8bb2494141eaf649b09fbfeb311161206"
-  integrity sha512-2OzQSfIr9CqqWMGqmcERE6Hnd2KY3eBVtFaulVo3sJghplUcaeMdL9ZjEiljcQQeHjheWY9RlNmumjIAvsBNaA==
+  version "2.4.12"
+  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.12.tgz#7b4673d9b4d5a874c8652d10f0f0265479014c8e"
+  integrity sha512-LgBpYIWuuGsihnlF+OOWWz4ovwCYlT03gd3DuLwex50cYZLmX3yrW+sFF9ndtmh7zcZpS6Ri47PrIu+fV+sbXw==
   dependencies:
     "@types/glob-stream" "*"
     "@types/node" "*"
     "@types/vinyl" "*"
 
 "@types/vinyl@*", "@types/vinyl@^2.0.0":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.4.tgz#9a7a8071c8d14d3a95d41ebe7135babe4ad5995a"
-  integrity sha512-2o6a2ixaVI2EbwBPg1QYLGQoHK56p/8X/sGfKbFC8N6sY9lfjsMf/GprtkQkSya0D4uRiutRZ2BWj7k3JvLsAQ==
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.5.tgz#52d3b850a4ed494aaad51e96708834c500c8d5cd"
+  integrity sha512-1m6uReH8R/RuLVQGvTT/4LlWq67jZEUxp+FBHt0hYv2BT7TUwFbKI0wa7JZVEU/XtlcnX1QcTuZ36es4rGj7jg==
   dependencies:
     "@types/expect" "^1.20.4"
     "@types/node" "*"
@@ -1300,6 +1409,14 @@
   resolved "https://registry.yarnpkg.com/@webcomponents/webcomponentsjs/-/webcomponentsjs-1.3.3.tgz#5bb82a0d3210c836bd4623e13a4a93145cb9dc27"
   integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
 
+JSONStream@^1.2.1, JSONStream@^1.3.5:
+  version "1.3.5"
+  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
+  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
+  dependencies:
+    jsonparse "^1.2.0"
+    through ">=2.2.7 <3"
+
 accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
   version "1.3.7"
   resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@@ -1326,25 +1443,32 @@
   integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
 
 acorn@^5.5.0:
-  version "5.7.3"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.3.tgz#67aa231bf8812974b85235a96771eb6bd07ea279"
-  integrity sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==
+  version "5.7.4"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
+  integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==
 
 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==
+  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"
-  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.13.tgz#597e2f8cc3672151e1307d3e95cddbc75672314a"
-  integrity sha512-fERNJX8sOXfel6qCBCMPvZLzENBEhZTzKqg6vrOW5pvoEaQuJhRU4ndTAh6lHOxn1I6jnz2NHra56ZODM751uw==
+  version "0.4.16"
+  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.16.tgz#cf4c508fdffab02c269cbc7f471a875f05570365"
+  integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==
 
 after@0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
   integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
 
+agent-base@6:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
+  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
+  dependencies:
+    debug "4"
+
 agent-base@^4.3.0:
   version "4.3.0"
   resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
@@ -1352,10 +1476,10 @@
   dependencies:
     es6-promisify "^5.0.0"
 
-ajv@^6.5.5:
-  version "6.11.0"
-  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.11.0.tgz#c3607cbc8ae392d8a5a536f25b21f8e5f3f87fe9"
-  integrity sha512-nCprB/0syFYy9fVYU1ox1l2KN8S9I+tziH8D4zdZuLT3N6RMlGSGt5FSTpAiHB/Whv8Qs1cWHma1aMKZyaHRKA==
+ajv@^6.12.3:
+  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"
@@ -1388,10 +1512,12 @@
   resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e"
   integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
 
-ansi-escapes@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b"
-  integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==
+ansi-escapes@^4.2.1:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
+  integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
+  dependencies:
+    type-fest "^0.21.3"
 
 ansi-regex@^2.0.0:
   version "2.1.1"
@@ -1403,10 +1529,10 @@
   resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
   integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
 
-ansi-regex@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
-  integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
+ansi-regex@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
+  integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
 
 ansi-styles@^2.2.1:
   version "2.2.1"
@@ -1421,11 +1547,10 @@
     color-convert "^1.9.0"
 
 ansi-styles@^4.1.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
-  integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
   dependencies:
-    "@types/color-name" "^1.1.1"
     color-convert "^2.0.1"
 
 ansi-styles@~1.0.0:
@@ -1516,7 +1641,7 @@
   dependencies:
     typical "^2.6.1"
 
-array-back@^3.0.1:
+array-back@^3.0.1, array-back@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
   integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
@@ -1526,6 +1651,11 @@
   resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
   integrity sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=
 
+array-differ@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b"
+  integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==
+
 array-find-index@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
@@ -1536,13 +1666,18 @@
   resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
   integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
 
-array-union@^1.0.1:
+array-union@^1.0.1, array-union@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
   integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=
   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"
@@ -1568,6 +1703,11 @@
   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"
@@ -1590,18 +1730,23 @@
   resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
   integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
 
-async-limiter@~1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
-  integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
+async@0.9.x:
+  version "0.9.2"
+  resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
+  integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
 
-async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.0, async@^2.6.1, async@^2.6.2, async@^2.6.3:
+async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.0, async@^2.6.2, async@^2.6.3:
   version "2.6.3"
   resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
   integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
   dependencies:
     lodash "^4.17.14"
 
+async@^3.1.0:
+  version "3.2.1"
+  resolved "https://registry.yarnpkg.com/async/-/async-3.2.1.tgz#d3274ec66d107a47476a4c49136aacdb00665fc8"
+  integrity sha512-XdD5lRO/87udXCMC9meWdYiR+Nq6ZjUfXidViUZGu2F1MO4T3XwZ1et0hb2++BgLfhyJwy44BGB/yx80ABx8hg==
+
 async@~0.2.9:
   version "0.2.10"
   resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
@@ -1628,9 +1773,16 @@
   integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
 
 aws4@^1.8.0:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.9.1.tgz#7e33d8f7d449b3f673cd72deb9abdc552dbe528e"
-  integrity sha512-wMHVg2EOHaMRxbzgFJ9gtjOOCrI80OHLG14rxi28XwOW8ux6IiEbRCGGGqCtdAIg4FQCbW20k9RsT4y3gJlFug==
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
+  integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
+
+axios@^0.21.1:
+  version "0.21.1"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
+  integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
+  dependencies:
+    follow-redirects "^1.10.0"
 
 babel-code-frame@^6.26.0:
   version "6.26.0"
@@ -1697,10 +1849,10 @@
   dependencies:
     babel-runtime "^6.22.0"
 
-babel-plugin-dynamic-import-node@^2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.0.tgz#f00f507bdaa3c3e3ff6e7e5e98d90a7acab96f7f"
-  integrity sha512-o6qFkpeQEBxcqt0XYlWzAVxNCSCZdUgcR8IRlhD/8DylxjjO4foPcvTW0GGKa/cVt3rvxZ7o5ippJ+/0nvLhlQ==
+babel-plugin-dynamic-import-node@^2.3.3:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
+  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
   dependencies:
     object.assign "^4.1.0"
 
@@ -1917,24 +2069,24 @@
   integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
 
 balanced-match@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
-  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@0.1.5:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.5.tgz#73926771923b5a19747ad666aa5cd4bf9c6e9ce8"
-  integrity sha1-c5JncZI7Whl0etZmqlzUv5xunOg=
+base64-arraybuffer@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
+  integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
 
 base64-js@1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
   integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
 
-base64-js@^1.0.2:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
-  integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
+base64-js@^1.3.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
 
 base64id@2.0.0:
   version "2.0.0"
@@ -1962,16 +2114,9 @@
     tweetnacl "^0.14.3"
 
 before-after-hook@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635"
-  integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==
-
-better-assert@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/better-assert/-/better-assert-1.0.2.tgz#40866b9e1b9e0b55b481894311e68faffaebc522"
-  integrity sha1-QIZrnhueC1W0gYlDEeaPr/rrxSI=
-  dependencies:
-    callsite "1.0.0"
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.2.tgz#a6e8ca41028d90ee2c24222f201c90956091613e"
+  integrity sha512-3pZEU3NT5BFUo/AD5ERPWOgQOCZITni6iavr5AUw5AUwQjMlI0kzu5btnyD39AF0gUEsDPwJT+oY1ORBJijPjQ==
 
 binary-extensions@^1.0.0:
   version "1.13.1"
@@ -1979,9 +2124,9 @@
   integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
 
 binaryextensions@^2.1.2:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.2.0.tgz#e7c6ba82d4f5f5758c26078fe8eea28881233311"
-  integrity sha512-bHhs98rj/7i/RZpCSJ3uk55pLXOItjIrh2sRQZSM6OoktScX+LxJzvlU+FELp9j3TdcddTmmYArLSGptCTwjuw==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.3.0.tgz#1d269cbf7e6243ea886aa41453c3651ccbe13c22"
+  integrity sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==
 
 bindings@^1.5.0:
   version "1.5.0"
@@ -1991,27 +2136,21 @@
     file-uri-to-path "1.0.0"
 
 bl@^1.0.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c"
-  integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA==
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7"
+  integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==
   dependencies:
     readable-stream "^2.3.5"
     safe-buffer "^5.1.1"
 
-bl@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.0.tgz#e1a574cdf528e4053019bb800b041c0ac88da493"
-  integrity sha512-wbgvOpqopSr7uq6fJrLH8EsvYMJf9gzfo2jCsL2eTy75qXPukA4pCgHamOQkZtY5vmfVtjB+P3LNlMHW5CEZXA==
+bl@^4.0.3:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
+  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
   dependencies:
-    readable-stream "^2.3.5"
-    safe-buffer "^5.1.1"
-
-bl@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-3.0.0.tgz#3611ec00579fd18561754360b21e9f784500ff88"
-  integrity sha512-EUAyP5UHU5hxF8BPT0LKW8gjYLhq1DQIcneOX/pL/m2Alo+OYDQAJlHq+yseMP50Os2nHXOSic6Ss3vSQeyf4A==
-  dependencies:
-    readable-stream "^3.0.1"
+    buffer "^5.5.0"
+    inherits "^2.0.4"
+    readable-stream "^3.4.0"
 
 blob@0.0.5:
   version "0.0.5"
@@ -2035,25 +2174,28 @@
     type-is "~1.6.17"
 
 bower-config@^1.4.0, bower-config@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.1.tgz#85fd9df367c2b8dbbd0caa4c5f2bad40cd84c2cc"
-  integrity sha1-hf2d82fCuNu9DKpMXyutQM2Ewsw=
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.3.tgz#3454fecdc5f08e7aa9cc6d556e492be0669689ae"
+  integrity sha512-MVyyUk3d1S7d2cl6YISViwJBc2VXCkxF5AUFykvN0PQj5FsUiMNSgAYTso18oRFfyZ6XEtjrgg9MAaufHbOwNw==
   dependencies:
     graceful-fs "^4.1.3"
+    minimist "^0.2.1"
     mout "^1.0.0"
-    optimist "^0.6.1"
     osenv "^0.1.3"
     untildify "^2.1.0"
+    wordwrap "^0.0.3"
 
 bower-json@^0.8.1:
-  version "0.8.1"
-  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.1.tgz#96c14723241ae6466a9c52e16caa32623a883843"
-  integrity sha1-lsFHIyQa5kZqnFLhbKoyYjqIOEM=
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.4.tgz#9c3b375870dcd9581350c1f403f6383dbf6a18b1"
+  integrity sha512-mMKghvq9ivbuzSsY5nrOLnDtZIJMUCpysqbGaGW3mj88JAcuSi8ZAzIt34vNZjohy0aR9VXLwgPTZGnBX2Vpjg==
   dependencies:
-    deep-extend "^0.4.0"
-    ext-name "^3.0.0"
+    deep-extend "^0.5.1"
+    ends-with "^0.2.0"
+    ext-list "^2.0.0"
     graceful-fs "^4.1.3"
     intersect "^1.0.1"
+    sort-keys-length "^1.0.0"
 
 bower-logger@^0.2.2:
   version "0.2.2"
@@ -2061,9 +2203,9 @@
   integrity sha1-Ob4H6Xmy/I4DqUY0IF7ZQiNz04E=
 
 bower@^1.8.8:
-  version "1.8.8"
-  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.8.tgz#82544be34a33aeae7efb8bdf9905247b2cffa985"
-  integrity sha512-1SrJnXnkP9soITHptSO+ahx3QKp3cVzn8poI6ujqc5SeOkg5iqM1pK9H+DSc2OQ8SnO0jC/NG4Ur/UIwy7574A==
+  version "1.8.12"
+  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.12.tgz#44cfca2a5e04b8d9a066621e24c8b179d8ac321e"
+  integrity sha512-u1xy9SrwwoPlgjuHNjhV+YUPVdqyBj2ALBxuzeIUKXaPI2i2xypGgxqXkuHcITGdi5yBj5JuXgyMvgiWiS1S3Q==
 
 boxen@^0.6.0:
   version "0.6.0"
@@ -2141,10 +2283,21 @@
   dependencies:
     pako "~0.2.0"
 
+browserslist@^4.16.6:
+  version "4.16.8"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.8.tgz#cb868b0b554f137ba6e33de0ecff2eda403c4fb0"
+  integrity sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ==
+  dependencies:
+    caniuse-lite "^1.0.30001251"
+    colorette "^1.3.0"
+    electron-to-chromium "^1.3.811"
+    escalade "^3.1.1"
+    node-releases "^1.1.75"
+
 browserstack@^1.2.0:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.5.3.tgz#93ab48799a12ef99dbd074dd595410ddb196a7ac"
-  integrity sha512-AO+mECXsW4QcqC9bxwM29O7qWa7bJT94uBFzeb5brylIQwawuEziwq20dPYbins95GlWzOawgyDNdjYAo32EKg==
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.1.tgz#e051f9733ec3b507659f395c7a4765a1b1e358b3"
+  integrity sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw==
   dependencies:
     https-proxy-agent "^2.2.1"
 
@@ -2177,22 +2330,22 @@
   integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
 
 buffer-from@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
-  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+  integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
-buffer@^5.1.0:
-  version "5.4.3"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.4.3.tgz#3fbc9c69eb713d323e3fc1a895eee0710c072115"
-  integrity sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==
+buffer@^5.1.0, buffer@^5.5.0:
+  version "5.7.1"
+  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
+  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
   dependencies:
-    base64-js "^1.0.2"
-    ieee754 "^1.1.4"
+    base64-js "^1.3.1"
+    ieee754 "^1.1.13"
 
 builtin-modules@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.1.0.tgz#aad97c15131eb76b65b50ef208e7584cd76a7484"
-  integrity sha512-k0KL0aWZuBt2lrxrcASWDfwOLMnodeQjodT/1SxEQAXsHANgo6ZC/VEaSEHCXt7aSTZ4/4H5LKa+tBXmW7Vtvw==
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-3.2.0.tgz#45d5db99e7ee5e6bc4f362e008bf917ab5049887"
+  integrity sha512-lGzLKcioL90C7wMczpkY0n/oART3MbBa8R9OFGE1rJxoVI86u4WAGfEk8Wjv10eKSyTHVGkSo3bvBylCEtk7LA==
 
 busboy@^0.2.11:
   version "0.2.14"
@@ -2227,16 +2380,37 @@
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
+cacheable-lookup@^5.0.3:
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005"
+  integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==
+
+cacheable-request@^7.0.2:
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27"
+  integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==
+  dependencies:
+    clone-response "^1.0.2"
+    get-stream "^5.1.0"
+    http-cache-semantics "^4.0.0"
+    keyv "^4.0.0"
+    lowercase-keys "^2.0.0"
+    normalize-url "^6.0.1"
+    responselike "^2.0.0"
+
+call-bind@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
+  integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
+  dependencies:
+    function-bind "^1.1.1"
+    get-intrinsic "^1.0.2"
+
 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"
   integrity sha1-JtII6onje1y95gJQoV8DHBak1ms=
 
-callsite@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/callsite/-/callsite-1.0.0.tgz#280398e5d664bd74038b6f0905153e6e8af1bc20"
-  integrity sha1-KAOY5dZkvXQDi28JBRU+borxvCA=
-
 camel-case@3.0.x:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
@@ -2270,6 +2444,11 @@
   dependencies:
     "@types/node" "^4.0.30"
 
+caniuse-lite@^1.0.30001251:
+  version "1.0.30001252"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001252.tgz#cb16e4e3dafe948fc4a9bb3307aea054b912019a"
+  integrity sha512-I56jhWDGMtdILQORdusxBOH+Nl/KgQSdDmpJezYddnAkVOmnoU8zwjTV9xAjMIYxr0iPreEAVylCGcmHCjfaOw==
+
 capture-stack-trace@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
@@ -2280,10 +2459,10 @@
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
-chalk@*:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-3.0.0.tgz#3f73c2bf526591f574cc492c51e2456349f844e4"
-  integrity sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==
+chalk@*, chalk@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
   dependencies:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
@@ -2322,7 +2501,7 @@
   resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
   integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
 
-charenc@~0.0.1:
+charenc@0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
   integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
@@ -2344,9 +2523,9 @@
     fsevents "^1.0.0"
 
 chownr@^1.0.1:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.3.tgz#42d837d5239688d55f303003a508230fa6727142"
-  integrity sha512-i70fVHhmV3DtTl6nqvZOnIjbY0Pe4kAUjwHj8z0zAdgBtYrJyYwLKCCuRBQ5ppkyL0AkN7HKRnETdmdp1zqNXw==
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
+  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
 
 ci-info@^1.5.0:
   version "1.6.0"
@@ -2364,9 +2543,9 @@
     static-extend "^0.1.1"
 
 clean-css@4.2.x:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.2.tgz#8519abda724b3e759bc79d196369906925d81a3f"
-  integrity sha512-yKycArwReQXbOD/3pmsPmt6p7oUBww8MisDabL2pCUWkbVONvCJoBdCjgY4ZVQmKX5juz/JB9oDcP6XzGUpjwQ==
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
+  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
   dependencies:
     source-map "~0.6.0"
 
@@ -2387,30 +2566,51 @@
   dependencies:
     restore-cursor "^1.0.1"
 
-cli-cursor@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
-  integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
+cli-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
+  integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
   dependencies:
-    restore-cursor "^2.0.0"
+    restore-cursor "^3.1.0"
 
 cli-table@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.1.tgz#f53b05266a8b1a0b934b3d0821e6e2dc5914ae23"
-  integrity sha1-9TsFJmqLGguTSz0IIebi3FkUriM=
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc"
+  integrity sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==
   dependencies:
     colors "1.0.3"
 
 cli-width@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
-  integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
+  integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
+
+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"
   integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
 
+clone-deep@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
+  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
+  dependencies:
+    is-plain-object "^2.0.4"
+    kind-of "^6.0.2"
+    shallow-clone "^3.0.0"
+
+clone-response@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
+  integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
+  dependencies:
+    mimic-response "^1.0.0"
+
 clone-stats@^0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
@@ -2478,9 +2678,9 @@
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
 color-string@^1.5.2:
-  version "1.5.3"
-  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
-  integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
+  version "1.6.0"
+  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
+  integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
   dependencies:
     color-name "^1.0.0"
     simple-swizzle "^0.2.2"
@@ -2493,10 +2693,10 @@
     color-convert "^1.9.1"
     color-string "^1.5.2"
 
-colornames@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/colornames/-/colornames-1.1.1.tgz#f8889030685c7c4ff9e2a559f5077eb76a816f96"
-  integrity sha1-+IiQMGhcfE/54qVZ9Qd+t2qBb5Y=
+colorette@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af"
+  integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w==
 
 colors@1.0.3:
   version "1.0.3"
@@ -2534,11 +2734,11 @@
     typical "^2.6.0"
 
 command-line-args@^5.0.2:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.1.1.tgz#88e793e5bb3ceb30754a86863f0401ac92fd369a"
-  integrity sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.2.0.tgz#087b02748272169741f1fd7c785b295df079b9be"
+  integrity sha512-4zqtU1hYsSJzcJBOcNZIbW5Fbk9BkjCp1pZVhQKoRaWL5J7N4XphDLwo8aWwdQpTugxwu+jf9u2ZhkXiqp5Z6A==
   dependencies:
-    array-back "^3.0.1"
+    array-back "^3.1.0"
     find-replace "^3.0.0"
     lodash.camelcase "^4.3.0"
     typical "^4.0.0"
@@ -2576,7 +2776,7 @@
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
   integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
 
-commander@^2.19.0, commander@^2.20.0:
+commander@^2.20.0, commander@^2.20.3:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@@ -2601,7 +2801,7 @@
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
   integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
 
-component-emitter@^1.2.1:
+component-emitter@^1.2.1, component-emitter@~1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
   integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
@@ -2672,11 +2872,11 @@
     xdg-basedir "^2.0.0"
 
 configstore@^3.0.0:
-  version "3.1.2"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.2.tgz#c6f25defaeef26df12dd33414b001fe81a543f8f"
-  integrity sha512-vtv5HtGjcYUgFrXc6Kx747B83MRRVS5R1VTEQoXvuP+kMI+if6uywV0nDGoiydJRy4yk7h9od5Og0kxx4zUXmw==
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f"
+  integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA==
   dependencies:
-    dot-prop "^4.1.0"
+    dot-prop "^4.2.1"
     graceful-fs "^4.1.2"
     make-dir "^1.0.0"
     unique-string "^1.0.0"
@@ -2696,9 +2896,9 @@
   integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
 
 convert-source-map@^1.1.1, convert-source-map@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
-  integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369"
+  integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==
   dependencies:
     safe-buffer "~5.1.1"
 
@@ -2707,31 +2907,36 @@
   resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
   integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
 
-cookie@0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.3.1.tgz#e7e0a1f9ef43b4c8ba925c5c5a96e806d16873bb"
-  integrity sha1-5+Ch+e9DtMi6klxcWpboBtFoc7s=
-
 cookie@0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
   integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
 
+cookie@~0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
+  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
+
 copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
   integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
 
 core-js@^2.4.0, core-js@^2.4.1:
-  version "2.6.11"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
-  integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
+  version "2.6.12"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
+  integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
 
-core-util-is@1.0.2, core-util-is@~1.0.0:
+core-util-is@1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
   integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
 
+core-util-is@~1.0.0:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
+  integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
+
 cors@^2.8.4:
   version "2.8.5"
   resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
@@ -2791,7 +2996,16 @@
     shebang-command "^1.2.0"
     which "^1.2.9"
 
-crypt@~0.0.1:
+cross-spawn@^7.0.0, 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==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
+crypt@0.0.2:
   version "0.0.2"
   resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
   integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
@@ -2829,7 +3043,7 @@
   dependencies:
     array-find-index "^1.0.1"
 
-dargs@^6.0.0:
+dargs@^6.0.0, dargs@^6.1.0:
   version "6.1.0"
   resolved "https://registry.yarnpkg.com/dargs/-/dargs-6.1.0.tgz#1f3b9b56393ecf8caa7cbfd6c31496ffcfb9b272"
   integrity sha512-5dVBvpBLBnPwSsYXqfybFyehMmC/EenKEcf23AhCTgTf48JFBbmJKqoZBsERDnjL0FyiVTYWdFsRfTLHxLyKdQ==
@@ -2853,17 +3067,17 @@
   dependencies:
     ms "2.0.0"
 
-debug@^3.0.0, debug@^3.1.0:
-  version "3.2.6"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
-  integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
+debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
+  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
   dependencies:
-    ms "^2.1.1"
+    ms "2.1.2"
 
-debug@^4.1.0, debug@^4.1.1, debug@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
-  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+debug@^3.1.0:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
+  integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
@@ -2874,6 +3088,13 @@
   dependencies:
     ms "2.0.0"
 
+debug@~4.1.0:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
+  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
+  dependencies:
+    ms "^2.1.1"
+
 decamelize@^1.1.2:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
@@ -2891,17 +3112,34 @@
   dependencies:
     mimic-response "^1.0.0"
 
-deep-extend@^0.4.0, deep-extend@~0.4.1:
-  version "0.4.2"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
-  integrity sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=
+decompress-response@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
+  integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==
+  dependencies:
+    mimic-response "^3.1.0"
+
+deep-extend@^0.5.1:
+  version "0.5.1"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f"
+  integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==
 
 deep-extend@^0.6.0, deep-extend@~0.6.0:
   version "0.6.0"
   resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
   integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 
-define-properties@^1.1.2:
+deep-extend@~0.4.1:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f"
+  integrity sha1-SLaZwn4zS/ifEIkr5DL25MfTSn8=
+
+defer-to-connect@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
+  integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
+
+define-properties@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
   integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
@@ -2952,7 +3190,7 @@
   resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
   integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
 
-deprecation@^2.0.0:
+deprecation@^2.0.0, deprecation@^2.3.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
   integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
@@ -2987,18 +3225,9 @@
     repeating "^2.0.0"
 
 detect-node@^2.0.3:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
-  integrity sha512-ZIzRpLJrOj7jjP2miAtgqIfmzbxa4ZOr5jJc601zklsfEx9oTzmmj2nVpIPRpNlRTIh8lc1kyViIY7BWSGNmKw==
-
-diagnostics@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/diagnostics/-/diagnostics-1.1.1.tgz#cab6ac33df70c9d9a727490ae43ac995a769b22a"
-  integrity sha512-8wn1PmdunLJ9Tqbx+Fx/ZEuHfJf4NKSN2ZBj7SJC/OWRWha843+WsTjqMe1B5E3p28jqBlp+mJ2fPVxPyNgYKQ==
-  dependencies:
-    colorspace "1.1.x"
-    enabled "1.0.x"
-    kuler "1.0.x"
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
+  integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
 
 dicer@0.2.5:
   version "0.2.5"
@@ -3018,6 +3247,11 @@
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
   integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
 
+diff@^4.0.1:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
+  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
+
 dir-glob@2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
@@ -3026,6 +3260,13 @@
     arrify "^1.0.1"
     path-type "^3.0.0"
 
+dir-glob@^2.2.2:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4"
+  integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==
+  dependencies:
+    path-type "^3.0.0"
+
 doctrine@^2.0.2:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
@@ -3067,13 +3308,22 @@
   dependencies:
     is-obj "^1.0.0"
 
-dot-prop@^4.1.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.0.tgz#1f19e0c2e1aa0e32797c49799f2837ac6af69c57"
-  integrity sha512-tUMXrxlExSW6U2EXiiKGSBVdYgtV8qlHL+C10TsW4PURY/ic+eaysnSkwB4kA/mBlCyy/IKDJ+Lc3wbWeaXtuQ==
+dot-prop@^4.2.1:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4"
+  integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==
   dependencies:
     is-obj "^1.0.0"
 
+download-stats@^0.3.4:
+  version "0.3.4"
+  resolved "https://registry.yarnpkg.com/download-stats/-/download-stats-0.3.4.tgz#67ea0c32f14acd9f639da704eef509684ba2dae7"
+  integrity sha512-ic2BigbyUWx7/CBbsfGjf71zUNZB4edBGC3oRliSzsoNmvyVx3Ycfp1w3vp2Y78Ee0eIIkjIEO5KzW0zThDGaA==
+  dependencies:
+    JSONStream "^1.2.1"
+    lazy-cache "^2.0.1"
+    moment "^2.15.1"
+
 duplexer2@^0.1.2, duplexer2@^0.1.4:
   version "0.1.4"
   resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
@@ -3105,9 +3355,9 @@
     safer-buffer "^2.1.0"
 
 editions@^2.2.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/editions/-/editions-2.3.0.tgz#47f2d5309340bce93ab5eb6ad755b9e90ff825e4"
-  integrity sha512-jeXYwHPKbitU1l14dWlsl5Nm+b1Hsm7VX73BsrQ4RVwEcAQQIPFHTZAbVtuIGxZBrpdT2FXd8lbtrNBrzZxIsA==
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/editions/-/editions-2.3.1.tgz#3bc9962f1978e801312fbd0aebfed63b49bfe698"
+  integrity sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==
   dependencies:
     errlop "^2.0.0"
     semver "^6.3.0"
@@ -3117,22 +3367,37 @@
   resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
-ejs@^2.5.9:
+ejs@^2.5.9, ejs@^2.6.1:
   version "2.7.4"
   resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
   integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
 
+ejs@^3.1.5:
+  version "3.1.6"
+  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a"
+  integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==
+  dependencies:
+    jake "^10.6.1"
+
+electron-to-chromium@^1.3.811:
+  version "1.3.826"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.826.tgz#dbe356b1546b39d83bcd47e675a9c5f61dadaed2"
+  integrity sha512-bpLc4QU4B8PYmdO4MSu2ZBTMD8lAaEXRS43C09lB31BvYwuk9UxgBRXbY5OJBw7VuMGcg2MZG5FyTaP9u4PQnw==
+
 emitter-component@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
   integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
 
-enabled@1.0.x:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/enabled/-/enabled-1.0.2.tgz#965f6513d2c2d1c5f4652b64a2e3396467fc2f93"
-  integrity sha1-ll9lE9LC0cX0ZStkouM5ZGf8L5M=
-  dependencies:
-    env-variable "0.0.x"
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+enabled@2.0.x:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2"
+  integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==
 
 encodeurl@~1.0.2:
   version "1.0.2"
@@ -3151,55 +3416,50 @@
   resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
   integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o=
 
-engine.io-client@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.4.0.tgz#82a642b42862a9b3f7a188f41776b2deab643700"
-  integrity sha512-a4J5QO2k99CM2a0b12IznnyQndoEvtA4UAldhGzKqnHf42I3Qs2W5SPnDvatZRcMaNZs4IevVicBPayxYt6FwA==
+engine.io-client@~3.5.0:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.2.tgz#0ef473621294004e9ceebe73cef0af9e36f2f5fa"
+  integrity sha512-QEqIp+gJ/kMHeUun7f5Vv3bteRHppHH/FMBQX/esFj/fuYfjyUKWGMo3VCvIP/V8bE9KcjHmRZrhIz2Z9oNsDA==
   dependencies:
-    component-emitter "1.2.1"
+    component-emitter "~1.3.0"
     component-inherit "0.0.3"
-    debug "~4.1.0"
+    debug "~3.1.0"
     engine.io-parser "~2.2.0"
     has-cors "1.1.0"
     indexof "0.0.1"
-    parseqs "0.0.5"
-    parseuri "0.0.5"
-    ws "~6.1.0"
-    xmlhttprequest-ssl "~1.5.4"
+    parseqs "0.0.6"
+    parseuri "0.0.6"
+    ws "~7.4.2"
+    xmlhttprequest-ssl "~1.6.2"
     yeast "0.1.2"
 
 engine.io-parser@~2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.0.tgz#312c4894f57d52a02b420868da7b5c1c84af80ed"
-  integrity sha512-6I3qD9iUxotsC5HEMuuGsKA0cXerGz+4uGcXQEkfBidgKf0amsjrrtwcbwK/nzpZBxclXlV7gGl9dgWvu4LF6w==
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7"
+  integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==
   dependencies:
     after "0.8.2"
     arraybuffer.slice "~0.0.7"
-    base64-arraybuffer "0.1.5"
+    base64-arraybuffer "0.1.4"
     blob "0.0.5"
     has-binary2 "~1.0.2"
 
-engine.io@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.4.0.tgz#3a962cc4535928c252759a00f98519cb46c53ff3"
-  integrity sha512-XCyYVWzcHnK5cMz7G4VTu2W7zJS7SM1QkcelghyIk/FmobWBtXE7fwhBusEKvCSqc3bMh8fNFMlUkCKTFRxH2w==
+engine.io@~3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.5.0.tgz#9d6b985c8a39b1fe87cd91eb014de0552259821b"
+  integrity sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA==
   dependencies:
     accepts "~1.3.4"
     base64id "2.0.0"
-    cookie "0.3.1"
+    cookie "~0.4.1"
     debug "~4.1.0"
     engine.io-parser "~2.2.0"
-    ws "^7.1.2"
-
-env-variable@0.0.x:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/env-variable/-/env-variable-0.0.5.tgz#913dd830bef11e96a039c038d4130604eba37f88"
-  integrity sha512-zoB603vQReOFvTg5xMl9I1P2PnHsHQQKTEowsKKD7nseUfJq6UWzK+4YtlWUO1nhiQUxe6XMkk+JleSZD1NZFA==
+    ws "~7.4.2"
 
 errlop@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/errlop/-/errlop-2.0.0.tgz#52b97d35da1b0795e2647b5d2d3a46d17776f55a"
-  integrity sha512-z00WIrQhtOMUnjdTG0O4f6hMG64EVccVDBy2WwgjcF8S4UB1exGYuc2OFwmdQmsJwLQVEIHWHPCz/omXXgAZHw==
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/errlop/-/errlop-2.2.0.tgz#1ff383f8f917ae328bebb802d6ca69666a42d21b"
+  integrity sha512-e64Qj9+4aZzjzzFpZC7p5kmm/ccCrbLhAJplhsDXQFs87XTsXwOpH4s1Io2s90Tau/8r2j9f4l/thhDevRjzxw==
 
 error-ex@^1.2.0, error-ex@^1.3.1:
   version "1.3.2"
@@ -3228,9 +3488,14 @@
     es6-promise "^4.0.3"
 
 es6-promisify@^6.0.0:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.0.2.tgz#525c23725b8510f5f1f2feb5a1fbad93a93e29b4"
-  integrity sha512-eO6vFm0JvqGzjWIQA6QVKjxpmELfhWbDUWHm1rPfIbn55mhKPiAa5xpLmQWJrNa629ZIeQ8ZvMAi13kvrjK6Mg==
+  version "6.1.1"
+  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.1.1.tgz#46837651b7b06bf6fff893d03f29393668d01621"
+  integrity sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==
+
+escalade@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
+  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
 
 escape-html@^1.0.3, escape-html@~1.0.3:
   version "1.0.3"
@@ -3266,9 +3531,9 @@
   integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
 
 eventemitter3@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.0.tgz#d65176163887ee59f386d64c82610b696a4a74eb"
-  integrity sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
+  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
 
 execa@^0.7.0:
   version "0.7.0"
@@ -3296,6 +3561,21 @@
     signal-exit "^3.0.0"
     strip-eof "^1.0.0"
 
+execa@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
+  integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==
+  dependencies:
+    cross-spawn "^7.0.0"
+    get-stream "^5.0.0"
+    human-signals "^1.1.1"
+    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"
+    strip-final-newline "^2.0.0"
+
 exit-hook@^1.0.0:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
@@ -3385,16 +3665,6 @@
   dependencies:
     mime-db "^1.28.0"
 
-ext-name@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ext-name/-/ext-name-3.0.0.tgz#07e4418737cb1f513c32c6ea48d8b8c8e0471abb"
-  integrity sha1-B+RBhzfLH1E8MsbqSNi4yOBHGrs=
-  dependencies:
-    ends-with "^0.2.0"
-    ext-list "^2.0.0"
-    meow "^3.1.0"
-    sort-keys-length "^1.0.0"
-
 extend-shallow@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
@@ -3465,11 +3735,11 @@
   integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
 
 fast-deep-equal@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz#545145077c501491e33b15ec408c294376e94ae4"
-  integrity sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==
+  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-glob@^2.0.2:
+fast-glob@^2.0.2, fast-glob@^2.2.6:
   version "2.2.7"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
   integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==
@@ -3492,9 +3762,9 @@
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
 fast-safe-stringify@^2.0.4:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
-  integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
+  version "2.0.8"
+  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.8.tgz#dc2af48c46cf712b683e849b2bbd446b32de936f"
+  integrity sha512-lXatBjf3WPjmWD6DpIZxkeSsCOwqI0maYMpgDlx8g4U2qi4lbjA9oH/HD2a87G+KfsUmo5WbJFmqBZlPxtptag==
 
 fd-slicer@~1.1.0:
   version "1.1.0"
@@ -3515,6 +3785,11 @@
   resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
   integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
 
+fecha@^4.2.0:
+  version "4.2.1"
+  resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce"
+  integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==
+
 figures@^1.3.5:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
@@ -3523,10 +3798,10 @@
     escape-string-regexp "^1.0.5"
     object-assign "^4.1.0"
 
-figures@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
-  integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=
+figures@^3.0.0:
+  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"
 
@@ -3535,6 +3810,13 @@
   resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
   integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
 
+filelist@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
+  integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==
+  dependencies:
+    minimatch "^3.0.4"
+
 filename-regex@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
@@ -3648,12 +3930,15 @@
   dependencies:
     readable-stream "^2.0.2"
 
-follow-redirects@^1.0.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.10.0.tgz#01f5263aee921c6a54fb91667f08f4155ce169eb"
-  integrity sha512-4eyLK6s6lH32nOvLLwlIOnr9zrL8Sm+OvW4pVTJNoXeGzYIkHVf+pADQi+OJ0E67hiuSLezPVPyBcIZO50TmmQ==
-  dependencies:
-    debug "^3.0.0"
+fn.name@1.x.x:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
+  integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
+
+follow-redirects@^1.0.0, follow-redirects@^1.10.0:
+  version "1.14.2"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.2.tgz#cecb825047c00f5e66b142f90fed4f515dec789b"
+  integrity sha512-yLR6WaE2lbF0x4K2qE2p9PEXKLDjUjnR/xmjS3wHAYxtlsI9MLLBJUZirAHKzUZDGLxje7w/cXR49WOUo4rbsA==
 
 for-in@^1.0.1, for-in@^1.0.2:
   version "1.0.2"
@@ -3678,9 +3963,9 @@
   integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
 
 form-data@*:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-3.0.0.tgz#31b7e39c85f1355b7139ee0c647cf0de7f83c682"
-  integrity sha512-CKMFDglpbMi6PyN+brwB9Q/GOw0eAnsrEZDgcsH5Krhz5Od/haKHAX0NmQfha2zPPz0JpWzA7GJHGSnvCRLWsg==
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
+  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
   dependencies:
     asynckit "^0.4.0"
     combined-stream "^1.0.8"
@@ -3702,10 +3987,10 @@
   dependencies:
     samsam "1.x"
 
-forwarded@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
-  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
+forwarded@0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
+  integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
 
 fragment-cache@^0.2.1:
   version "0.2.1"
@@ -3740,27 +4025,36 @@
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
 fsevents@^1.0.0:
-  version "1.2.11"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.11.tgz#67bf57f4758f02ede88fb2a1712fef4d15358be3"
-  integrity sha512-+ux3lx6peh0BpvY0JebGyZoiR4D+oYzdPZMKJwkZ+sFkNJzpL7tXc/wehS49gUAxg3tmMHPHZkA8JU2rhhgDHw==
+  version "1.2.13"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
+  integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==
   dependencies:
     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==
+fsevents@~2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
+  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
 
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
-gensync@^1.0.0-beta.1:
-  version "1.0.0-beta.1"
-  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
-  integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==
+gensync@^1.0.0-beta.2:
+  version "1.0.0-beta.2"
+  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
+  integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
+
+get-intrinsic@^1.0.2:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
+  integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
+  dependencies:
+    function-bind "^1.1.1"
+    has "^1.0.3"
+    has-symbols "^1.0.1"
 
 get-stdin@^4.0.1:
   version "4.0.1"
@@ -3779,6 +4073,13 @@
   dependencies:
     pump "^3.0.0"
 
+get-stream@^5.0.0, 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-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"
@@ -3791,6 +4092,14 @@
   dependencies:
     assert-plus "^1.0.0"
 
+gh-got@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-5.0.0.tgz#ee95be37106fd8748a96f8d1db4baea89e1bfa8a"
+  integrity sha1-7pW+NxBv2HSKlvjR20uuqJ4b+oo=
+  dependencies:
+    got "^6.2.0"
+    is-plain-obj "^1.1.0"
+
 gh-got@^6.0.0:
   version "6.0.0"
   resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-6.0.0.tgz#d74353004c6ec466647520a10bd46f7299d268d0"
@@ -3799,6 +4108,13 @@
     got "^7.0.0"
     is-plain-obj "^1.1.0"
 
+github-username@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/github-username/-/github-username-3.0.0.tgz#0a772219b3130743429f2456d0bdd3db55dce7b1"
+  integrity sha1-CnciGbMTB0NCnyRW0L3T21Xc57E=
+  dependencies:
+    gh-got "^5.0.0"
+
 github-username@^4.0.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
@@ -3871,9 +4187,9 @@
     path-is-absolute "^1.0.0"
 
 glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, 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==
+  version "7.1.7"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
+  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
@@ -3973,11 +4289,42 @@
     pify "^3.0.0"
     slash "^1.0.0"
 
+globby@^9.2.0:
+  version "9.2.0"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d"
+  integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==
+  dependencies:
+    "@types/glob" "^7.1.1"
+    array-union "^1.0.2"
+    dir-glob "^2.2.2"
+    fast-glob "^2.2.6"
+    glob "^7.1.3"
+    ignore "^4.0.3"
+    pify "^4.0.1"
+    slash "^2.0.0"
+
 google-protobuf@^3.6.1:
   version "3.19.4"
   resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.4.tgz#8d32c3e34be9250956f28c0fb90955d13f311888"
   integrity sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg==
 
+got@^11.8.2:
+  version "11.8.3"
+  resolved "https://registry.yarnpkg.com/got/-/got-11.8.3.tgz#f496c8fdda5d729a90b4905d2b07dbd148170770"
+  integrity sha512-7gtQ5KiPh1RtGS9/Jbv1ofDpBFuq42gyfEib+ejaRBJuj/3tQFeR5+gw57e4ipaU8c/rCjvX6fkQz2lyDlGAOg==
+  dependencies:
+    "@sindresorhus/is" "^4.0.0"
+    "@szmarczak/http-timer" "^4.0.5"
+    "@types/cacheable-request" "^6.0.1"
+    "@types/responselike" "^1.0.0"
+    cacheable-lookup "^5.0.3"
+    cacheable-request "^7.0.2"
+    decompress-response "^6.0.0"
+    http2-wrapper "^1.0.0-beta.5.2"
+    lowercase-keys "^2.0.0"
+    p-cancelable "^2.0.0"
+    responselike "^2.0.0"
+
 got@^5.0.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
@@ -3999,7 +4346,7 @@
     unzip-response "^1.0.2"
     url-parse-lax "^1.0.0"
 
-got@^6.7.1:
+got@^6.2.0, got@^6.7.1:
   version "6.7.1"
   resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
   integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
@@ -4037,17 +4384,24 @@
     url-to-options "^1.0.1"
 
 graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.2.0:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
-  integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
+  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
 
-grouped-queue@^0.3.0, grouped-queue@^0.3.3:
+grouped-queue@^0.3.0:
   version "0.3.3"
   resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-0.3.3.tgz#c167d2a5319c5a0e0964ef6a25b7c2df8996c85c"
   integrity sha1-wWfSpTGcWg4JZO9qJbfC34mWyFw=
   dependencies:
     lodash "^4.17.2"
 
+grouped-queue@^1.1.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-1.1.0.tgz#63e3f9ca90af952269d1d40879e41221eacc74cb"
+  integrity sha512-rZOFKfCqLhsu5VqjBjEWiwrYqJR07KxIkH4mLZlNlGDfntbb4FbMyGFP14TlvRPrU9S3Hnn/sgxbC5ZeN0no3Q==
+  dependencies:
+    lodash "^4.17.15"
+
 gulp-if@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
@@ -4076,9 +4430,9 @@
     vinyl "^1.0.0"
 
 gunzip-maybe@^1.3.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.1.tgz#39c72ed89d1b49ba708e18776500488902a52027"
-  integrity sha512-qtutIKMthNJJgeHQS7kZ9FqDq59/Wn0G2HYCRNjpup7yKfVI6/eqwpmroyZGFoCYaG+sW6psNVb4zoLADHpp2g==
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac"
+  integrity sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==
   dependencies:
     browserify-zlib "^0.1.4"
     is-deflate "^1.0.0"
@@ -4097,12 +4451,12 @@
   resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
   integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
 
-har-validator@~5.1.0:
-  version "5.1.3"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080"
-  integrity sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==
+har-validator@~5.1.0, har-validator@~5.1.3:
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
+  integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
   dependencies:
-    ajv "^6.5.5"
+    ajv "^6.12.3"
     har-schema "^2.0.0"
 
 has-ansi@^2.0.0:
@@ -4144,10 +4498,10 @@
   resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
   integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==
 
-has-symbols@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.1.tgz#9f5214758a44196c406d9bd76cebf81ec2dd31e8"
-  integrity sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==
+has-symbols@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
+  integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
 has-to-string-tag-x@^1.2.0:
   version "1.4.1"
@@ -4187,6 +4541,13 @@
     is-number "^3.0.0"
     kind-of "^4.0.0"
 
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
 he@1.2.x:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
@@ -4200,9 +4561,9 @@
     parse-passwd "^1.0.0"
 
 hosted-git-info@^2.1.4:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c"
-  integrity sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg==
+  version "2.8.9"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
+  integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hpack.js@^2.1.6:
   version "2.1.6"
@@ -4227,6 +4588,11 @@
     relateurl "0.2.x"
     uglify-js "3.4.x"
 
+http-cache-semantics@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
+  integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
+
 http-deceiver@^1.2.7:
   version "1.2.7"
   resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
@@ -4275,9 +4641,9 @@
     micromatch "^2.3.11"
 
 http-proxy@^1.16.2:
-  version "1.18.0"
-  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.0.tgz#dbe55f63e75a347db7f3d99974f2692a314a6a3a"
-  integrity sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==
+  version "1.18.1"
+  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
+  integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
   dependencies:
     eventemitter3 "^4.0.0"
     follow-redirects "^1.0.0"
@@ -4292,6 +4658,14 @@
     jsprim "^1.2.2"
     sshpk "^1.7.0"
 
+http2-wrapper@^1.0.0-beta.5.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
+  integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==
+  dependencies:
+    quick-lru "^5.1.1"
+    resolve-alpn "^1.0.0"
+
 https-proxy-agent@^2.2.1:
   version "2.2.4"
   resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
@@ -4300,13 +4674,18 @@
     agent-base "^4.3.0"
     debug "^3.1.0"
 
-https-proxy-agent@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81"
-  integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg==
+https-proxy-agent@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
+  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
   dependencies:
-    agent-base "^4.3.0"
-    debug "^3.1.0"
+    agent-base "6"
+    debug "4"
+
+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==
 
 iconv-lite@0.4.24, iconv-lite@^0.4.24:
   version "0.4.24"
@@ -4315,16 +4694,21 @@
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ieee754@^1.1.4:
-  version "1.1.13"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.13.tgz#ec168558e95aa181fd87d37f55c32bbcb6708b84"
-  integrity sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==
+ieee754@^1.1.13:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
 
 ignore@^3.3.5:
   version "3.3.10"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
   integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
 
+ignore@^4.0.3:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
+
 import-lazy@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
@@ -4360,7 +4744,7 @@
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.1, inherits@~2.0.3:
+inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
@@ -4371,9 +4755,9 @@
   integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 
 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==
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
+  integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
 
 inquirer@^1.0.2:
   version "1.2.3"
@@ -4395,29 +4779,29 @@
     strip-ansi "^3.0.0"
     through "^2.3.6"
 
-inquirer@^6.0.0:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca"
-  integrity sha512-cntlB5ghuB0iuO65Ovoi8ogLHiWGs/5yNrtUcKjFhSSiVeAIVpD7koaSU9RM8mpXw5YDi9RdYXGQMaOURB7ycQ==
+inquirer@^7.1.0:
+  version "7.3.3"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
+  integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==
   dependencies:
-    ansi-escapes "^3.2.0"
-    chalk "^2.4.2"
-    cli-cursor "^2.1.0"
-    cli-width "^2.0.0"
+    ansi-escapes "^4.2.1"
+    chalk "^4.1.0"
+    cli-cursor "^3.1.0"
+    cli-width "^3.0.0"
     external-editor "^3.0.3"
-    figures "^2.0.0"
-    lodash "^4.17.12"
-    mute-stream "0.0.7"
-    run-async "^2.2.0"
-    rxjs "^6.4.0"
-    string-width "^2.1.0"
-    strip-ansi "^5.1.0"
+    figures "^3.0.0"
+    lodash "^4.17.19"
+    mute-stream "0.0.8"
+    run-async "^2.4.0"
+    rxjs "^6.6.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
     through "^2.3.6"
 
 interpret@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296"
-  integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw==
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
+  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 
 intersect@^1.0.1:
   version "1.0.1"
@@ -4431,10 +4815,10 @@
   dependencies:
     loose-envify "^1.0.0"
 
-ipaddr.js@1.9.0:
-  version "1.9.0"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.0.tgz#37df74e430a0e47550fe54a2defe30d8acd95f65"
-  integrity sha512-M4Sjn6N/+O6/IXSJseKqHoFc+5FdGJ22sXqnjTpdZweHK64MzEPAyQZyEU3R/KRv2GLoa7nNtg/C2Ev6m7z+eA==
+ipaddr.js@1.9.1:
+  version "1.9.1"
+  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
+  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
 
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
@@ -4467,7 +4851,7 @@
   dependencies:
     binary-extensions "^1.0.0"
 
-is-buffer@^1.1.5, is-buffer@~1.1.1:
+is-buffer@^1.1.5, is-buffer@~1.1.6:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be"
   integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==
@@ -4479,6 +4863,13 @@
   dependencies:
     ci-info "^1.5.0"
 
+is-core-module@^2.2.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19"
+  integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==
+  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"
@@ -4551,11 +4942,9 @@
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
 is-finite@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.0.2.tgz#cc6677695602be550ef11e8b4aa6305342b6d0aa"
-  integrity sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=
-  dependencies:
-    number-is-nan "^1.0.0"
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
+  integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==
 
 is-fullwidth-code-point@^1.0.0:
   version "1.0.0"
@@ -4569,6 +4958,11 @@
   resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
   integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
 is-glob@^2.0.0, is-glob@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
@@ -4638,9 +5032,9 @@
   integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
 
 is-object@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
-  integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA=
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf"
+  integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==
 
 is-path-cwd@^1.0.0:
   version "1.0.0"
@@ -4673,12 +5067,10 @@
   dependencies:
     isobject "^3.0.1"
 
-is-plain-object@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-3.0.0.tgz#47bfc5da1b5d50d64110806c199359482e75a928"
-  integrity sha512-tZIpofR+P05k8Aocp7UI/2UTa9lTJSebCXpFFoR9aibpokDj/uXBsJ8luUu0tTVYKkMU6URDUuOfJZ7koewXvg==
-  dependencies:
-    isobject "^4.0.0"
+is-plain-object@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
+  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
 
 is-posix-bracket@^0.1.0:
   version "0.1.1"
@@ -4686,20 +5078,15 @@
   integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
 
 is-potential-custom-element-name@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
-  integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
+  integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
 
 is-primitive@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
   integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
 
-is-promise@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa"
-  integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=
-
 is-redirect@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
@@ -4722,12 +5109,17 @@
   resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
   integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
 
+is-stream@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
+  integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
+
 is-typedarray@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
-is-utf8@^0.2.0:
+is-utf8@^0.2.0, is-utf8@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
   integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
@@ -4769,6 +5161,11 @@
   dependencies:
     buffer-alloc "^1.2.0"
 
+isbinaryfile@^4.0.0:
+  version "4.0.8"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
+  integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
+
 isexe@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
@@ -4786,17 +5183,12 @@
   resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
   integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
 
-isobject@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/isobject/-/isobject-4.0.0.tgz#3f1c9155e73b192022a80819bacd0343711697b0"
-  integrity sha512-S/2fF5wH8SJA/kmwr6HYhK/RI/OkhD84k8ntalo0iJjZikgq1XFvR5M8NPT1x5F7fBwCG3qHfnzeP/Vh/ZxCUA==
-
 isstream@~0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
   integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
 
-istextorbinary@^2.2.1:
+istextorbinary@^2.2.1, istextorbinary@^2.5.1:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.6.0.tgz#60776315fb0fa3999add276c02c69557b9ca28ab"
   integrity sha512-+XRlFseT8B3L9KyjxxLjfXSLMuErKDsd8DBNrsaxoViABMEZlOSCstwmw0qpoFX3+U6yWU1yhLudAe6/lETGGA==
@@ -4813,6 +5205,16 @@
     has-to-string-tag-x "^1.2.0"
     is-object "^1.0.1"
 
+jake@^10.6.1:
+  version "10.8.2"
+  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
+  integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==
+  dependencies:
+    async "0.9.x"
+    chalk "^2.4.2"
+    filelist "^1.0.1"
+    minimatch "^3.0.4"
+
 jest-worker@^24.9.0:
   version "24.9.0"
   resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5"
@@ -4851,11 +5253,21 @@
   resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
   integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
 
+json-buffer@3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
+  integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
+
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
   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"
@@ -4876,17 +5288,22 @@
   resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
   integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=
 
-json5@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.1.tgz#81b6cb04e9ba496f1c7005d07b4368a2638f90b6"
-  integrity sha512-l+3HXD0GEI3huGq1njuqtzYK8OYJyXMkOLtQ53pjWh89tvWS2h6l+1zMkYWqlb57+SiQodKZyvMEFb2X+KrFhQ==
+json5@^2.1.2:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
+  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
   dependencies:
-    minimist "^1.2.0"
+    minimist "^1.2.5"
+
+jsonparse@^1.2.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
+  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
 
 jsonschema@^1.1.0, jsonschema@^1.1.1:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.2.5.tgz#bab69d97fa28946aec0a56a9cc266d23fe80ae61"
-  integrity sha512-kVTF+08x25PQ0CjuVc0gRM9EUPb0Fe9Ln/utFOgcdxEIOHuU7ooBk/UPTd7t1M91pP35m0MU1T8M5P7vP1bRRw==
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2"
+  integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==
 
 jsprim@^1.2.2:
   version "1.4.1"
@@ -4898,6 +5315,13 @@
     json-schema "0.2.3"
     verror "1.10.0"
 
+keyv@^4.0.0:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254"
+  integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==
+  dependencies:
+    json-buffer "3.0.1"
+
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@@ -4922,12 +5346,10 @@
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
-kuler@1.0.x:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/kuler/-/kuler-1.0.1.tgz#ef7c784f36c9fb6e16dd3150d152677b2b0228a6"
-  integrity sha512-J9nVUucG1p/skKul6DU3PUZrhs0LPulNaeUOox0IyXDi8S4CztTHs1gQphhuZmzXG7VOQSf6NJfKuzteQLv9gQ==
-  dependencies:
-    colornames "^1.1.1"
+kuler@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
+  integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==
 
 latest-version@^2.0.0:
   version "2.0.0"
@@ -4957,6 +5379,13 @@
     rimraf "^3.0.0"
     underscore "^1.8.3"
 
+lazy-cache@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264"
+  integrity sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=
+  dependencies:
+    set-getter "^0.1.0"
+
 lazy-req@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac"
@@ -4969,6 +5398,11 @@
   dependencies:
     readable-stream "^2.0.5"
 
+lines-and-columns@^1.1.6:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
+  integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
+
 load-json-file@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -5038,6 +5472,16 @@
   resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
   integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
 
+lodash.mapvalues@^4.6.0:
+  version "4.6.0"
+  resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
+  integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=
+
+lodash.merge@^4.6.2:
+  version "4.6.2"
+  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
+  integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+
 lodash.padend@^4.6.1:
   version "4.6.1"
   resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
@@ -5078,15 +5522,10 @@
   resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
   integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
 
-lodash@^3.0.0, lodash@^3.10.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
-  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
-
-lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
-  version "4.17.15"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
-  integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+lodash@4.17.21, lodash@^3.0.0, lodash@^3.10.1, lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.4, lodash@^4.3.0:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
 log-symbols@^1.0.0, log-symbols@^1.0.1:
   version "1.0.2"
@@ -5113,14 +5552,14 @@
     ms "^2.1.1"
     triple-beam "^1.2.0"
 
-logform@^2.1.1:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360"
-  integrity sha512-+lZh4OpERDBLqjiwDLpAWNQu6KMjnlXH2ByZwCuSqVPJletw0kTWJf5CgSNAUKn1KUkv3m2cUz/LK8zyEy7wzQ==
+logform@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2"
+  integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==
   dependencies:
     colors "^1.2.1"
     fast-safe-stringify "^2.0.4"
-    fecha "^2.3.3"
+    fecha "^4.2.0"
     ms "^2.1.1"
     triple-beam "^1.3.0"
 
@@ -5159,6 +5598,11 @@
   resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
   integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
 
+lowercase-keys@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
+  integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
+
 lru-cache@^4.0.1, lru-cache@^4.0.2:
   version "4.1.5"
   resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
@@ -5167,10 +5611,17 @@
     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"
-  integrity sha512-OHhSbtcviqMPt7yfw5ef5aghS2jzFVKEFyCJndQt2YpSQ9qRVSEv2axSJI1paVThEu+FFGs584h/1YhxjVqajA==
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.5.0.tgz#067c2c88b5f3fb3c56a375b2ec93826220fa1ff2"
+  integrity sha512-EIgv+QZ9r+814gjJj0Bt5vSLJLzswGmSUbUpbi9AIr/fsN2IWFBl2NucV9PAiek+U1STK468tEkxmVYUtuAN3g==
 
 magic-string@^0.22.4:
   version "0.22.5"
@@ -5186,6 +5637,13 @@
   dependencies:
     pify "^3.0.0"
 
+make-dir@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
+  integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
+  dependencies:
+    semver "^6.0.0"
+
 map-cache@^0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
@@ -5216,13 +5674,13 @@
   integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
 
 md5@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9"
-  integrity sha1-U6s41f48iJG6RlMp6iP6wFQBJvk=
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
+  integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==
   dependencies:
-    charenc "~0.0.1"
-    crypt "~0.0.1"
-    is-buffer "~1.1.1"
+    charenc "0.0.2"
+    crypt "0.0.2"
+    is-buffer "~1.1.6"
 
 media-typer@0.3.0:
   version "0.3.0"
@@ -5246,16 +5704,50 @@
     through2 "^2.0.0"
     vinyl "^2.0.1"
 
-mem-fs@^1.1.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.1.3.tgz#b8ae8d2e3fcb6f5d3f9165c12d4551a065d989cc"
-  integrity sha1-uK6NLj/Lb10/kWXBLUVRoGXZicw=
+mem-fs-editor@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-6.0.0.tgz#d63607cf0a52fe6963fc376c6a7aa52db3edabab"
+  integrity sha512-e0WfJAMm8Gv1mP5fEq/Blzy6Lt1VbLg7gNnZmZak7nhrBTibs+c6nQ4SKs/ZyJYHS1mFgDJeopsLAv7Ow0FMFg==
   dependencies:
-    through2 "^2.0.0"
-    vinyl "^1.1.0"
-    vinyl-file "^2.0.0"
+    commondir "^1.0.1"
+    deep-extend "^0.6.0"
+    ejs "^2.6.1"
+    glob "^7.1.4"
+    globby "^9.2.0"
+    isbinaryfile "^4.0.0"
+    mkdirp "^0.5.0"
+    multimatch "^4.0.0"
+    rimraf "^2.6.3"
+    through2 "^3.0.1"
+    vinyl "^2.2.0"
 
-meow@^3.1.0, meow@^3.7.0:
+mem-fs-editor@^7.0.1:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-7.1.0.tgz#2a16f143228df87bf918874556723a7ee73bfe88"
+  integrity sha512-BH6QEqCXSqGeX48V7zu+e3cMwHU7x640NB8Zk8VNvVZniz+p4FK60pMx/3yfkzo6miI6G3a8pH6z7FeuIzqrzA==
+  dependencies:
+    commondir "^1.0.1"
+    deep-extend "^0.6.0"
+    ejs "^3.1.5"
+    glob "^7.1.4"
+    globby "^9.2.0"
+    isbinaryfile "^4.0.0"
+    mkdirp "^1.0.0"
+    multimatch "^4.0.0"
+    rimraf "^3.0.0"
+    through2 "^3.0.2"
+    vinyl "^2.2.1"
+
+mem-fs@^1.1.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.2.0.tgz#5f29b2d02a5875cd14cd836c388385892d556cde"
+  integrity sha512-b8g0jWKdl8pM0LqAPdK9i8ERL7nYrzmJfRhxMiWH2uYdfYnb7uXnmwVb0ZGe7xyEl4lj+nLIU3yf4zPUT+XsVQ==
+  dependencies:
+    through2 "^3.0.0"
+    vinyl "^2.0.1"
+    vinyl-file "^3.0.0"
+
+meow@^3.7.0:
   version "3.7.0"
   resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
   integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
@@ -5289,9 +5781,9 @@
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
 merge2@^1.2.3:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.3.0.tgz#5b366ee83b2f1582c48f87e47cf1a9352103ca81"
-  integrity sha512-2j4DAdlBOkiSZIsaXk4mTE3sRS02yBHAtfy127xRV3bQUFqXkjHCHLW6Scv7DwNRbIWNHH8zpnz9zMaKXIdvYw==
+  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"
@@ -5336,17 +5828,17 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
-mime-db@1.43.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0:
-  version "1.43.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58"
-  integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==
+mime-db@1.49.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0:
+  version "1.49.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.49.0.tgz#f3dfde60c99e9cf3bc9701d687778f537001cbed"
+  integrity sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==
 
 mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
-  version "2.1.26"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06"
-  integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==
+  version "2.1.32"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.32.tgz#1d00e89e7de7fe02008db61001d9e02852670fd5"
+  integrity sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==
   dependencies:
-    mime-db "1.43.0"
+    mime-db "1.49.0"
 
 mime@1.4.1:
   version "1.4.1"
@@ -5359,20 +5851,25 @@
   integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
 
 mime@^2.3.1:
-  version "2.4.4"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-2.4.4.tgz#bd7b91135fc6b01cde3e9bae33d659b63d8857e5"
-  integrity sha512-LRxmNwziLPT828z+4YkNzloCFC2YM4wrB99k+AV5ZbEyfGNWfG8SO1FUXLmLDBSo89NrJZ4DIWeLjy1CHGhMGA==
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
+  integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
 
-mimic-fn@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022"
-  integrity sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
 mimic-response@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
+mimic-response@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
+  integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
+
 minimalistic-assert@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
@@ -5392,20 +5889,15 @@
   dependencies:
     brace-expansion "^1.1.7"
 
-minimist@0.0.8:
-  version "0.0.8"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d"
-  integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=
+minimist@^0.2.1:
+  version "0.2.1"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.1.tgz#827ba4e7593464e7c221e8c5bed930904ee2c455"
+  integrity sha512-GY8fANSrTMfBVfInqJAY41QkOM+upUTytK1jZ0c8+3HdHrJxBJ3rF5i9moClXTE8uUSnUo8cAsCoxDXvSY4DHg==
 
-minimist@^1.1.3, minimist@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.0.tgz#a35008b20f41383eec1fb914f4cd5df79a264284"
-  integrity sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=
-
-minimist@~0.0.1:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf"
-  integrity sha1-3j+YVD2/lggr5IrRoMfNqDYwHc8=
+minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
 mixin-deep@^1.2.0:
   version "1.3.2"
@@ -5415,12 +5907,22 @@
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
-mkdirp@^0.5.0, mkdirp@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
-  integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=
+mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.4:
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
+  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
   dependencies:
-    minimist "0.0.8"
+    minimist "^1.2.5"
+
+mkdirp@^1.0.0, mkdirp@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
+  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
+
+moment@^2.15.1, moment@^2.24.0:
+  version "2.29.1"
+  resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
+  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
 
 mout@^1.0.0:
   version "1.2.2"
@@ -5437,20 +5939,25 @@
   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:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
 multer@^1.3.0:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
-  integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
+  version "1.4.3"
+  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.3.tgz#4db352d6992e028ac0eacf7be45c6efd0264297b"
+  integrity sha512-np0YLKncuZoTzufbkM6wEKp68EhWJXcU6fq6QqrSwkckd2LlMgd1UqhUJLj6NS/5sZ8dE8LYDWslsltJznnXlg==
   dependencies:
     append-field "^1.0.0"
     busboy "^0.2.11"
     concat-stream "^1.5.2"
-    mkdirp "^0.5.1"
+    mkdirp "^0.5.4"
     object-assign "^4.1.1"
     on-finished "^2.3.0"
     type-is "^1.6.4"
@@ -5466,6 +5973,17 @@
     arrify "^1.0.0"
     minimatch "^3.0.0"
 
+multimatch@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-4.0.0.tgz#8c3c0f6e3e8449ada0af3dd29efb491a375191b3"
+  integrity sha512-lDmx79y1z6i7RNx0ZGCPq1bzJ6ZoDDKbvh7jxr9SJcWLkShMzXrHbYVpTdnhNM5MXpDUxCQ4DgqVttVXlBgiBQ==
+  dependencies:
+    "@types/minimatch" "^3.0.3"
+    array-differ "^3.0.0"
+    array-union "^2.1.0"
+    arrify "^2.0.1"
+    minimatch "^3.0.4"
+
 multipipe@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
@@ -5479,10 +5997,10 @@
   resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db"
   integrity sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=
 
-mute-stream@0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab"
-  integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=
+mute-stream@0.0.8:
+  version "0.0.8"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
+  integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
 mz@^2.4.0, mz@^2.6.0:
   version "2.7.0"
@@ -5494,9 +6012,9 @@
     thenify-all "^1.0.0"
 
 nan@^2.12.1:
-  version "2.14.0"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c"
-  integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==
+  version "2.15.0"
+  resolved "https://registry.yarnpkg.com/nan/-/nan-2.15.0.tgz#3f34a473ff18e15c1b5626b62903b5ad6e665fee"
+  integrity sha512-8ZtvEnA2c5aYCZYd1cvgdnU6cqwixRoYg70xPLWUws5ORTa/lnw+u4amixRS/Ac5U5mQVgp9pnlSUnbNWFaWZQ==
 
 nanomatch@^1.2.9:
   version "1.2.13"
@@ -5537,10 +6055,15 @@
   dependencies:
     lower-case "^1.1.1"
 
-node-fetch@^2.3.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd"
-  integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==
+node-fetch@^2.6.0, node-fetch@^2.6.1:
+  version "2.6.1"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
+  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
+
+node-releases@^1.1.75:
+  version "1.1.75"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.75.tgz#6dd8c876b9897a1b8e5a02de26afa79bb54ebbfe"
+  integrity sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw==
 
 node-status-codes@^1.0.0:
   version "1.0.0"
@@ -5555,7 +6078,7 @@
     chalk "~0.4.0"
     underscore "~1.6.0"
 
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4:
+normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
@@ -5577,6 +6100,23 @@
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
   integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
 
+normalize-url@^6.0.1:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a"
+  integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==
+
+npm-api@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/npm-api/-/npm-api-1.0.1.tgz#3def9b51afedca57db14ca0c970d92442d21c9c5"
+  integrity sha512-4sITrrzEbPcr0aNV28QyOmgn6C9yKiF8k92jn4buYAK8wmA5xo1qL3II5/gT1r7wxbXBflSduZ2K3FbtOrtGkA==
+  dependencies:
+    JSONStream "^1.3.5"
+    clone-deep "^4.0.1"
+    download-stats "^0.3.4"
+    moment "^2.24.0"
+    node-fetch "^2.6.0"
+    paged-request "^2.0.1"
+
 npm-run-path@^2.0.0:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
@@ -5584,6 +6124,13 @@
   dependencies:
     path-key "^2.0.0"
 
+npm-run-path@^4.0.0:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
+  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
+  dependencies:
+    path-key "^3.0.0"
+
 number-is-nan@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
@@ -5599,11 +6146,6 @@
   resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
   integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
 
-object-component@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/object-component/-/object-component-0.0.3.tgz#f0c69aa50efc95b866c186f400a33769cb2f1291"
-  integrity sha1-8MaapQ78lbhmwYb0AKM3acsvEpE=
-
 object-copy@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/object-copy/-/object-copy-0.1.0.tgz#7e7d858b781bd7c991a41ba975ed3812754e998c"
@@ -5613,7 +6155,7 @@
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-keys@^1.0.11, object-keys@^1.0.12:
+object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
@@ -5626,14 +6168,14 @@
     isobject "^3.0.0"
 
 object.assign@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da"
-  integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==
+  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:
-    define-properties "^1.1.2"
-    function-bind "^1.1.1"
-    has-symbols "^1.0.0"
-    object-keys "^1.0.11"
+    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"
@@ -5679,22 +6221,24 @@
   dependencies:
     wrappy "1"
 
-one-time@0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/one-time/-/one-time-0.0.4.tgz#f8cdf77884826fe4dff93e3a9cc37b1e4480742e"
-  integrity sha1-+M33eISCb+Tf+T46nMN7HkSAdC4=
+one-time@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
+  integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==
+  dependencies:
+    fn.name "1.x.x"
 
 onetime@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
   integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
 
-onetime@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
-  integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=
+onetime@^5.1.0:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
   dependencies:
-    mimic-fn "^1.0.0"
+    mimic-fn "^2.1.0"
 
 opn@^3.0.2:
   version "3.0.3"
@@ -5703,14 +6247,6 @@
   dependencies:
     object-assign "^4.0.1"
 
-optimist@^0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
-  integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY=
-  dependencies:
-    minimist "~0.0.1"
-    wordwrap "~0.0.2"
-
 ordered-read-streams@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
@@ -5755,15 +6291,20 @@
   resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
   integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
 
+p-cancelable@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf"
+  integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==
+
 p-finally@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
   integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
 
 p-limit@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.2.2.tgz#61279b67721f5287aa1c13a9a7fbbc48c9291b1e"
-  integrity sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
   dependencies:
     p-try "^2.0.0"
 
@@ -5811,6 +6352,13 @@
     registry-url "^3.0.3"
     semver "^5.1.0"
 
+paged-request@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/paged-request/-/paged-request-2.0.2.tgz#4d621a08b8d6bee4440a0a92112354eeece5b5b0"
+  integrity sha512-NWrGqneZImDdcMU/7vMcAOo1bIi5h/pmpJqe7/jdsy85BA/s5MSaU/KlpxwW/IVPmIwBcq2uKPrBWWhEWhtxag==
+  dependencies:
+    axios "^0.21.1"
+
 pako@~0.2.0:
   version "0.2.9"
   resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
@@ -5848,6 +6396,16 @@
     error-ex "^1.3.1"
     json-parse-better-errors "^1.0.1"
 
+parse-json@^5.0.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
+  integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
+  dependencies:
+    "@babel/code-frame" "^7.0.0"
+    error-ex "^1.3.1"
+    json-parse-even-better-errors "^2.3.0"
+    lines-and-columns "^1.1.6"
+
 parse-passwd@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
@@ -5883,19 +6441,15 @@
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
   integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
 
-parseqs@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.5.tgz#d5208a3738e46766e291ba2ea173684921a8b89d"
-  integrity sha1-1SCKNzjkZ2bikbouoXNoSSGouJ0=
-  dependencies:
-    better-assert "~1.0.0"
+parseqs@0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5"
+  integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==
 
-parseuri@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.5.tgz#80204a50d4dbb779bfdc6ebe2778d90e4bce320a"
-  integrity sha1-gCBKUNTbt3m/3G6+J3jZDkvOMgo=
-  dependencies:
-    better-assert "~1.0.0"
+parseuri@0.0.6:
+  version "0.0.6"
+  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a"
+  integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==
 
 parseurl@~1.3.3:
   version "1.3.3"
@@ -5939,10 +6493,15 @@
   resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
   integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
 
+path-key@^3.0.0, path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
 path-parse@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
-  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
 path-to-regexp@0.1.7:
   version "0.1.7"
@@ -6332,15 +6891,10 @@
   resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
   integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
 
-pretty-bytes@^5.1.0:
-  version "5.3.0"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2"
-  integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg==
-
-private@^0.1.6:
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
-  integrity sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==
+pretty-bytes@^5.1.0, pretty-bytes@^5.2.0:
+  version "5.6.0"
+  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
+  integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
 
 process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
   version "2.0.1"
@@ -6372,22 +6926,22 @@
     long "^4.0.0"
 
 proxy-addr@~2.0.5:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.5.tgz#34cbd64a2d81f4b1fd21e76f9f06c8a45299ee34"
-  integrity sha512-t/7RxHXPH6cJtP0pRG6smSr9QJidhB+3kXu0KgXnbGYMgzEnUxRQ4/LDdfOwZEMyIh3/xHb8PX3t+lfL9z+YVQ==
+  version "2.0.7"
+  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
+  integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
   dependencies:
-    forwarded "~0.1.2"
-    ipaddr.js "1.9.0"
+    forwarded "0.2.0"
+    ipaddr.js "1.9.1"
 
 pseudomap@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
   integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
 
-psl@^1.1.24:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.7.0.tgz#f1c4c47a8ef97167dea5d6bbf4816d736e884a3c"
-  integrity sha512-5NsSEDv8zY70ScRnOTn7bK7eanl2MvFrOrS/R6x+dBt5g1ghnj9Zv90kO8GwT8gxcu2ANyFprnFYB85IogIJOQ==
+psl@^1.1.24, psl@^1.1.28:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
+  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
 
 pump@^1.0.0:
   version "1.0.3"
@@ -6427,7 +6981,7 @@
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
   integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
 
-punycode@^2.1.0:
+punycode@^2.1.0, punycode@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
@@ -6447,6 +7001,11 @@
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
   integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
 
+quick-lru@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
+  integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
+
 randomatic@^3.0.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
@@ -6456,6 +7015,13 @@
     kind-of "^6.0.0"
     math-random "^1.0.1"
 
+randombytes@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
+  integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
+  dependencies:
+    safe-buffer "^5.1.0"
+
 range-parser@~1.2.0, range-parser@~1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
@@ -6489,7 +7055,7 @@
     pinkie-promise "^2.0.0"
     readable-stream "^2.0.0"
 
-read-chunk@^3.0.0:
+read-chunk@^3.0.0, read-chunk@^3.2.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-3.2.0.tgz#2984afe78ca9bfbbdb74b19387bf9e86289c16ca"
   integrity sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==
@@ -6513,6 +7079,14 @@
     find-up "^3.0.0"
     read-pkg "^3.0.0"
 
+read-pkg-up@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-5.0.0.tgz#b6a6741cb144ed3610554f40162aa07a6db621b8"
+  integrity sha512-XBQjqOBtTzyol2CpsQOw8LHV0XbDZVG7xMMjmXAJomlVY03WOBRmYgDJETlvcg0H63AJvPRwT7GFi5rvOzUOKg==
+  dependencies:
+    find-up "^3.0.0"
+    read-pkg "^5.0.0"
+
 read-pkg@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
@@ -6531,6 +7105,16 @@
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
+read-pkg@^5.0.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
+  integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
+  dependencies:
+    "@types/normalize-package-data" "^2.4.0"
+    normalize-package-data "^2.5.0"
+    parse-json "^5.0.0"
+    type-fest "^0.6.0"
+
 readable-stream@1.1.x:
   version "1.1.14"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
@@ -6541,10 +7125,10 @@
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-"readable-stream@2 || 3", readable-stream@^3.0.1, readable-stream@^3.1.1, readable-stream@^3.4.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.5.0.tgz#465d70e6d1087f6162d079cd0b5db7fbebfd1606"
-  integrity sha512-gSz026xs2LfxBPudDuI41V1lka8cxg64E66SGe78zJlsUofOg/yqwezdIcdfwik6B4h8LFmWPA9ef9X3FiNFLA==
+"readable-stream@2 || 3", readable-stream@^3.1.1, readable-stream@^3.4.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
+  integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
   dependencies:
     inherits "^2.0.3"
     string_decoder "^1.1.1"
@@ -6560,7 +7144,7 @@
     isarray "0.0.1"
     string_decoder "~0.10.x"
 
-readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
+readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6:
   version "2.3.7"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
   integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@@ -6602,29 +7186,34 @@
   resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
   integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
 
-regenerate-unicode-properties@^8.1.0:
-  version "8.1.0"
-  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.1.0.tgz#ef51e0f0ea4ad424b77bf7cb41f3e015c70a3f0e"
-  integrity sha512-LGZzkgtLY79GeXLm8Dp0BVLdQlWICzBnJz/ipWUgo59qBaZ+BHtq51P2q1uVZlppMuUAT37SDk39qUbjTWB7bA==
+regenerate-unicode-properties@^8.2.0:
+  version "8.2.0"
+  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
+  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
   dependencies:
     regenerate "^1.4.0"
 
 regenerate@^1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11"
-  integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==
+  version "1.4.2"
+  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
+  integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
 
 regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
   version "0.11.1"
   resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
   integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
 
-regenerator-transform@^0.14.0:
-  version "0.14.1"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.1.tgz#3b2fce4e1ab7732c08f665dfdb314749c7ddd2fb"
-  integrity sha512-flVuee02C3FKRISbxhXl9mGzdbWUVHubl1SMaknjxkFB1/iqpJhArQUvRxOOPEc/9tAiX0BaQ28FJH10E4isSQ==
+regenerator-runtime@^0.13.4:
+  version "0.13.9"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
+  integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
+
+regenerator-transform@^0.14.2:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
+  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
   dependencies:
-    private "^0.1.6"
+    "@babel/runtime" "^7.8.4"
 
 regex-cache@^0.4.2:
   version "0.4.4"
@@ -6641,17 +7230,17 @@
     extend-shallow "^3.0.2"
     safe-regex "^1.1.0"
 
-regexpu-core@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.6.0.tgz#2037c18b327cfce8a6fea2a4ec441f2432afb8b6"
-  integrity sha512-YlVaefl8P5BnFYOITTNzDvan1ulLOiXJzCNZxduTIosN17b87h3bvG9yHMoHaRuo88H4mQ06Aodj5VtYGGGiTg==
+regexpu-core@^4.7.1:
+  version "4.7.1"
+  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
+  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
   dependencies:
     regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.1.0"
-    regjsgen "^0.5.0"
-    regjsparser "^0.6.0"
+    regenerate-unicode-properties "^8.2.0"
+    regjsgen "^0.5.1"
+    regjsparser "^0.6.4"
     unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.1.0"
+    unicode-match-property-value-ecmascript "^1.2.0"
 
 registry-auth-token@^3.0.1:
   version "3.4.0"
@@ -6668,15 +7257,15 @@
   dependencies:
     rc "^1.0.1"
 
-regjsgen@^0.5.0:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.1.tgz#48f0bf1a5ea205196929c0d9798b42d1ed98443c"
-  integrity sha512-5qxzGZjDs9w4tzT3TPhCJqWdCc3RLYwy9J2NB0nm5Lz+S273lvWcpjaTGHsT1dc6Hhfq41uSEOw8wBmxrKOuyg==
+regjsgen@^0.5.1:
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
+  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
 
-regjsparser@^0.6.0:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.2.tgz#fd62c753991467d9d1ffe0a9f67f27a529024b96"
-  integrity sha512-E9ghzUtoLwDekPT0DYCp+c4h+bvuUpe6rRHCTYn6eGoqj1LgKXxT6I0Il4WbjhQkOghzi/V+y03bPKvbllL93Q==
+regjsparser@^0.6.4:
+  version "0.6.9"
+  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6"
+  integrity sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==
   dependencies:
     jsesc "~0.5.0"
 
@@ -6691,9 +7280,9 @@
   integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
 
 repeat-element@^1.1.2:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.3.tgz#782e0d825c0c5a3bb39731f84efee6b742e6b1ce"
-  integrity sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9"
+  integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==
 
 repeat-string@^1.5.2, repeat-string@^1.6.1:
   version "1.6.1"
@@ -6713,11 +7302,11 @@
   integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
 
 replace-ext@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
-  integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
+  integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
 
-request@2.88.0, request@^2.72.0, request@^2.85.0:
+request@2.88.0:
   version "2.88.0"
   resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
   integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
@@ -6743,6 +7332,32 @@
     tunnel-agent "^0.6.0"
     uuid "^3.3.2"
 
+request@^2.72.0, request@^2.85.0:
+  version "2.88.2"
+  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
+  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
+  dependencies:
+    aws-sign2 "~0.7.0"
+    aws4 "^1.8.0"
+    caseless "~0.12.0"
+    combined-stream "~1.0.6"
+    extend "~3.0.2"
+    forever-agent "~0.6.1"
+    form-data "~2.3.2"
+    har-validator "~5.1.3"
+    http-signature "~1.2.0"
+    is-typedarray "~1.0.0"
+    isstream "~0.1.2"
+    json-stringify-safe "~5.0.1"
+    mime-types "~2.1.19"
+    oauth-sign "~0.9.0"
+    performance-now "^2.1.0"
+    qs "~6.5.2"
+    safe-buffer "^5.1.2"
+    tough-cookie "~2.5.0"
+    tunnel-agent "^0.6.0"
+    uuid "^3.3.2"
+
 requirejs@^2.3.4:
   version "2.3.6"
   resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
@@ -6753,6 +7368,11 @@
   resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
   integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
 
+resolve-alpn@^1.0.0:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9"
+  integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==
+
 resolve-dir@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
@@ -6774,13 +7394,21 @@
   resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
   integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
 
-resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.3.2, resolve@^1.5.0:
-  version "1.15.0"
-  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.15.0.tgz#1b7ca96073ebb52e741ffd799f6b39ea462c67f5"
-  integrity sha512-+hTmAldEGE80U2wJJDC1lebb5jWqvTYAfm3YZ1ckk1gBr0MnCqUKlwK1e+anaFljIl+F5tR5IoZcm4ZDA1zMQw==
+resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.1, resolve@^1.5.0:
+  version "1.20.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
+  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
   dependencies:
+    is-core-module "^2.2.0"
     path-parse "^1.0.6"
 
+responselike@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723"
+  integrity sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==
+  dependencies:
+    lowercase-keys "^2.0.0"
+
 restore-cursor@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
@@ -6789,12 +7417,12 @@
     exit-hook "^1.0.0"
     onetime "^1.0.0"
 
-restore-cursor@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
-  integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368=
+restore-cursor@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
+  integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
   dependencies:
-    onetime "^2.0.0"
+    onetime "^5.1.0"
     signal-exit "^3.0.2"
 
 ret@~0.1.10:
@@ -6802,7 +7430,7 @@
   resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
   integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
 
-rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2:
+rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@@ -6810,9 +7438,9 @@
     glob "^7.1.3"
 
 rimraf@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.0.tgz#614176d4b3010b75e5c390eb0ee96f6dc0cebb9b"
-  integrity sha512-NDGVxTsjqfunkds7CqsOiEnxln4Bo7Nddl3XhS4pXg5OzwkLqJ971ZVAAnB+DDLnF76N+VnDEiBHaVV8I06SUg==
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
   dependencies:
     glob "^7.1.3"
 
@@ -6835,14 +7463,14 @@
     rollup-pluginutils "^2.8.1"
 
 rollup-plugin-terser@^5.1.3:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-5.2.0.tgz#ba758adf769347b7f1eaf9ef35978d2e207dccc7"
-  integrity sha512-jQI+nYhtDBc9HFRBz8iGttQg7li9klmzR62RG2W2nN6hJ/FI2K2ItYQ7kJ7/zn+vs+BP1AEccmVRjRN989I+Nw==
+  version "5.3.1"
+  resolved "https://registry.yarnpkg.com/rollup-plugin-terser/-/rollup-plugin-terser-5.3.1.tgz#8c650062c22a8426c64268548957463bf981b413"
+  integrity sha512-1pkwkervMJQGFYvM9nscrUoncPwiKR/K+bHdjv6PFgRo3cgPHoRT83y2Aa3GvINj4539S15t/tpFPb775TDs6w==
   dependencies:
     "@babel/code-frame" "^7.5.5"
     jest-worker "^24.9.0"
     rollup-pluginutils "^2.8.2"
-    serialize-javascript "^2.1.2"
+    serialize-javascript "^4.0.0"
     terser "^4.6.2"
 
 rollup-pluginutils@^2.8.1, rollup-pluginutils@^2.8.2:
@@ -6853,37 +7481,35 @@
     estree-walker "^0.6.1"
 
 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==
+  version "1.32.1"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.32.1.tgz#4480e52d9d9e2ae4b46ba0d9ddeaf3163940f9c4"
+  integrity sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==
   dependencies:
     "@types/estree" "*"
     "@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==
+  version "2.56.3"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.3.tgz#b63edadd9851b0d618a6d0e6af8201955a77aeff"
+  integrity sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==
   optionalDependencies:
-    fsevents "~2.1.2"
+    fsevents "~2.3.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"
-  integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA=
-  dependencies:
-    is-promise "^2.1.0"
+run-async@^2.0.0, run-async@^2.2.0, run-async@^2.4.0:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
+  integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==
 
 rx@^4.1.0:
   version "4.1.0"
   resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
   integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
 
-rxjs@^6.4.0:
-  version "6.5.4"
-  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.4.tgz#e0777fe0d184cec7872df147f303572d414e211c"
-  integrity sha512-naMQXcgEo3csAEGvw/NydRA0fuS2nDZJiw1YUWFKU7aPPAPGZEsD4Iimit96qwCieH6y614MCLYwdkrWx7z/7Q==
+rxjs@^6.4.0, rxjs@^6.6.0:
+  version "6.6.7"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
+  integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
   dependencies:
     tslib "^1.9.0"
 
@@ -6892,10 +7518,10 @@
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
   integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
 
-safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
-  version "5.2.0"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519"
-  integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==
+safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
 
 safe-regex@^1.1.0:
   version "1.1.0"
@@ -6915,13 +7541,13 @@
   integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
 
 sauce-connect-launcher@^1.0.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.1.tgz#31137f57b0f7176e1c0525b7fb09c6da746647cf"
-  integrity sha512-vIf9qDol3q2FlYzrKt0dr3kvec6LSjX2WS+/mVnAJIhqh1evSkPKCR2AzcJrnSmx9Xt9PtV0tLY7jYh0wsQi8A==
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.2.tgz#dfc675a258550809a8eaf457eb9162b943ddbaf0"
+  integrity sha512-wf0coUlidJ7rmeClgVVBh6Kw55/yalZCY/Un5RgjSnTXRAeGqagnTsTYpZaqC4dCtrY4myuYpOAZXCdbO7lHfQ==
   dependencies:
     adm-zip "~0.4.3"
     async "^2.1.2"
-    https-proxy-agent "^3.0.0"
+    https-proxy-agent "^5.0.0"
     lodash "^4.16.6"
     rimraf "^2.5.4"
 
@@ -6936,22 +7562,21 @@
   integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
 
 selenium-standalone@^6.7.0:
-  version "6.17.0"
-  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.17.0.tgz#0f24b691836205ee9bc3d7a6f207ebcb28170cd9"
-  integrity sha512-5PSnDHwMiq+OCiAGlhwQ8BM9xuwFfvBOZ7Tfbw+ifkTnOy0PWbZmI1B9gPGuyGHpbQ/3J3CzIK7BYwrQ7EjtWQ==
+  version "6.24.0"
+  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.24.0.tgz#cca7c1c36bfa3429078a8e6a1a4fd373f641a7c8"
+  integrity sha512-Dun2XgNAgCfJNrrSzuv7Z7Wj7QTvBKpqx0VXFz7bW9T9FUe5ytzgzoCEEshwDVMh0Dv6sCgdZg7VDhM/q2yPPQ==
   dependencies:
-    async "^2.6.2"
-    commander "^2.19.0"
-    cross-spawn "^6.0.5"
-    debug "^4.1.1"
-    lodash "^4.17.11"
-    minimist "^1.2.0"
-    mkdirp "^0.5.1"
+    commander "^2.20.3"
+    cross-spawn "^7.0.3"
+    debug "^4.3.1"
+    got "^11.8.2"
+    lodash.mapvalues "^4.6.0"
+    lodash.merge "^4.6.2"
+    minimist "^1.2.5"
+    mkdirp "^1.0.4"
     progress "2.0.3"
-    request "2.88.0"
-    tar-stream "2.0.0"
-    urijs "^1.19.1"
-    which "^1.3.1"
+    tar-stream "2.2.0"
+    which "^2.0.2"
     yauzl "^2.10.0"
 
 semver-diff@^2.0.0:
@@ -6961,7 +7586,7 @@
   dependencies:
     semver "^5.0.3"
 
-"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.4.1, semver@^5.5.0:
+"semver@2 || 3 || 4 || 5", semver@^5.0.3, semver@^5.1.0, semver@^5.3.0, semver@^5.5.0:
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
@@ -6971,11 +7596,18 @@
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004"
   integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
 
-semver@^6.3.0:
+semver@^6.0.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.1.3, semver@^7.2.1:
+  version "7.3.5"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
+  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
+  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"
@@ -7014,10 +7646,12 @@
     range-parser "~1.2.0"
     statuses "~1.4.0"
 
-serialize-javascript@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
-  integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
+serialize-javascript@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
+  integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
+  dependencies:
+    randombytes "^2.1.0"
 
 serve-static@1.14.1:
   version "1.14.1"
@@ -7039,6 +7673,13 @@
   resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
   integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
 
+set-getter@^0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.1.tgz#a3110e1b461d31a9cfc8c5c9ee2e9737ad447102"
+  integrity sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw==
+  dependencies:
+    to-object-path "^0.3.0"
+
 set-value@^2.0.0, set-value@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/set-value/-/set-value-2.0.1.tgz#a18d40530e6f07de4228c7defe4227af8cad005b"
@@ -7064,6 +7705,13 @@
   resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
   integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
 
+shallow-clone@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
+  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
+  dependencies:
+    kind-of "^6.0.2"
+
 shebang-command@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -7071,24 +7719,36 @@
   dependencies:
     shebang-regex "^1.0.0"
 
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
 shebang-regex@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
   integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
 
-shelljs@^0.8.0:
-  version "0.8.3"
-  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.3.tgz#a7f3319520ebf09ee81275b2368adb286659b097"
-  integrity sha512-fc0BKlAWiLpwZljmOvAOTE/gXawtCoNrP5oaY7KIaQbbyHeQVg01pSEuEGvGh3HEdBU4baCD7wQBwADmM/7f7A==
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+shelljs@^0.8.0, shelljs@^0.8.4:
+  version "0.8.4"
+  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
+  integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
   dependencies:
     glob "^7.0.0"
     interpret "^1.0.0"
     rechoir "^0.6.2"
 
 signal-exit@^3.0.0, signal-exit@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
-  integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
+  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"
@@ -7121,6 +7781,11 @@
   resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
   integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
 
+slash@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
+  integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
+
 slide@^1.1.5:
   version "1.1.6"
   resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
@@ -7161,54 +7826,51 @@
   resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
   integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
 
-socket.io-client@2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.3.0.tgz#14d5ba2e00b9bcd145ae443ab96b3f86cbcc1bb4"
-  integrity sha512-cEQQf24gET3rfhxZ2jJ5xzAOo/xhZwK+mOqtGRg5IowZsMgwvHwnf/mCRapAAkadhM26y+iydgwsXGObBB5ZdA==
+socket.io-client@2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.4.0.tgz#aafb5d594a3c55a34355562fc8aea22ed9119a35"
+  integrity sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ==
   dependencies:
     backo2 "1.0.2"
-    base64-arraybuffer "0.1.5"
     component-bind "1.0.0"
-    component-emitter "1.2.1"
-    debug "~4.1.0"
-    engine.io-client "~3.4.0"
+    component-emitter "~1.3.0"
+    debug "~3.1.0"
+    engine.io-client "~3.5.0"
     has-binary2 "~1.0.2"
-    has-cors "1.1.0"
     indexof "0.0.1"
-    object-component "0.0.3"
-    parseqs "0.0.5"
-    parseuri "0.0.5"
+    parseqs "0.0.6"
+    parseuri "0.0.6"
     socket.io-parser "~3.3.0"
     to-array "0.1.4"
 
 socket.io-parser@~3.3.0:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.0.tgz#2b52a96a509fdf31440ba40fed6094c7d4f1262f"
-  integrity sha512-hczmV6bDgdaEbVqhAeVMM/jfUfzuEZHsQg6eOmLgJht6G3mPKMxYm75w2+qhAQZ+4X+1+ATZ+QFKeOZD5riHng==
+  version "3.3.2"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.2.tgz#ef872009d0adcf704f2fbe830191a14752ad50b6"
+  integrity sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg==
   dependencies:
-    component-emitter "1.2.1"
+    component-emitter "~1.3.0"
     debug "~3.1.0"
     isarray "2.0.1"
 
 socket.io-parser@~3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.0.tgz#370bb4a151df2f77ce3345ff55a7072cc6e9565a"
-  integrity sha512-/G/VOI+3DBp0+DJKW4KesGnQkQPFmUCbA/oO2QGT6CWxU7hLGWqU3tyuzeSK/dqcyeHsQg1vTe9jiZI8GU9SCQ==
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.1.tgz#b06af838302975837eab2dc980037da24054d64a"
+  integrity sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==
   dependencies:
     component-emitter "1.2.1"
     debug "~4.1.0"
     isarray "2.0.1"
 
 socket.io@^2.0.3:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.3.0.tgz#cd762ed6a4faeca59bc1f3e243c0969311eb73fb"
-  integrity sha512-2A892lrj0GcgR/9Qk81EaY2gYhCBxurV0PfmmESO6p27QPrUK1J3zdns+5QPqvUYK2q657nSj0guoIil9+7eFg==
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.4.1.tgz#95ad861c9a52369d7f1a68acf0d4a1b16da451d2"
+  integrity sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w==
   dependencies:
     debug "~4.1.0"
-    engine.io "~3.4.0"
+    engine.io "~3.5.0"
     has-binary2 "~1.0.2"
     socket.io-adapter "~1.1.0"
-    socket.io-client "2.3.0"
+    socket.io-client "2.4.0"
     socket.io-parser "~3.4.0"
 
 sort-keys-length@^1.0.0:
@@ -7245,17 +7907,17 @@
     source-map "^0.6.0"
 
 source-map-support@~0.5.12:
-  version "0.5.16"
-  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.16.tgz#0ae069e7fe3ba7538c64c98515e35339eac5a042"
-  integrity sha512-efyLRJDr68D9hBBNIPWFjhpFzURh+KJykQwvMyW5UiZzYwoF6l4YMMDIJJEyFWxWCqfyxLzz6tSfUFR+kXXsVQ==
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
   dependencies:
     buffer-from "^1.0.0"
     source-map "^0.6.0"
 
 source-map-url@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3"
-  integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
+  integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
 
 source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
   version "0.5.7"
@@ -7276,30 +7938,30 @@
     os-shim "^0.1.2"
 
 spdx-correct@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4"
-  integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
+  integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
   dependencies:
     spdx-expression-parse "^3.0.0"
     spdx-license-ids "^3.0.0"
 
 spdx-exceptions@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977"
-  integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
+  integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 spdx-expression-parse@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0"
-  integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==
+  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.5"
-  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654"
-  integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==
+  version "3.0.10"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
+  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
 
 spdy-transport@^2.0.18:
   version "2.1.1"
@@ -7415,7 +8077,7 @@
     is-fullwidth-code-point "^1.0.0"
     strip-ansi "^3.0.0"
 
-string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
+string-width@^2.0.0, string-width@^2.1.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
   integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
@@ -7423,6 +8085,15 @@
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
+string-width@^4.1.0:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
+  integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.0"
+
 string_decoder@^1.1.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
@@ -7456,18 +8127,25 @@
   dependencies:
     ansi-regex "^3.0.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==
+strip-ansi@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
+  integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
   dependencies:
-    ansi-regex "^4.1.0"
+    ansi-regex "^5.0.0"
 
 strip-ansi@~0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
   integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
 
+strip-bom-buf@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz#1cb45aaf57530f4caf86c7f75179d2c9a51dd572"
+  integrity sha1-HLRar1dTD0yvhsf3UXnSyaUd1XI=
+  dependencies:
+    is-utf8 "^0.2.1"
+
 strip-bom-stream@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
@@ -7501,6 +8179,11 @@
   resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
   integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
 
+strip-final-newline@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
+  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
+
 strip-indent@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
@@ -7538,9 +8221,9 @@
     has-flag "^3.0.0"
 
 supports-color@^7.1.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.1.0.tgz#68e32591df73e25ad1c4b49108a2ec507962bfd1"
-  integrity sha512-oRSIpR8pxT1Wr2FquTNnGet79b3BWljqOuoW/h4oBhxJ/HUbX5nX6JSruTkvXDCFMwDPvsaTTbvMLKZWSy0R5g==
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
@@ -7601,12 +8284,12 @@
     pump "^1.0.0"
     tar-stream "^1.1.2"
 
-tar-stream@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.0.0.tgz#8829bbf83067bc0288a9089db49c56be395b6aea"
-  integrity sha512-n2vtsWshZOVr/SY4KtslPoUlyNh06I2SGgAOCZmquCEjlbV/LjY2CY80rDtdQRHFOYXNlgBDo6Fr3ww2CWPOtA==
+tar-stream@2.2.0, tar-stream@^2.1.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
+  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
   dependencies:
-    bl "^2.2.0"
+    bl "^4.0.3"
     end-of-stream "^1.4.1"
     fs-constants "^1.0.0"
     inherits "^2.0.3"
@@ -7625,17 +8308,6 @@
     to-buffer "^1.1.1"
     xtend "^4.0.0"
 
-tar-stream@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.0.tgz#d1aaa3661f05b38b5acc9b7020efdca5179a2cc3"
-  integrity sha512-+DAn4Nb4+gz6WZigRzKEZl1QuJVOLtAwwF+WUxy1fJ6X63CaGaUAxJRD2KEn1OMfcbCjySTYpNC6WmfQoIEOdw==
-  dependencies:
-    bl "^3.0.0"
-    end-of-stream "^1.4.1"
-    fs-constants "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^3.1.1"
-
 temp@^0.8.1, temp@^0.8.3:
   version "0.8.4"
   resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
@@ -7661,9 +8333,9 @@
     through2 "^2.0.1"
 
 terser@^4.6.2:
-  version "4.6.3"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-4.6.3.tgz#e33aa42461ced5238d352d2df2a67f21921f8d87"
-  integrity sha512-Lw+ieAXmY69d09IIc/yqeBqXpEQIpDGZqT34ui1QWXIUpR2RjbqEkT8X7Lgex19hslSqcWM5iMN2kM11eMsESQ==
+  version "4.8.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-4.8.0.tgz#63056343d7c70bb29f3af665865a46fe03a0df17"
+  integrity sha512-EAPipTNeWsb/3wLPeup1tVPaXfIaU68xMnVdPafIL1TV05OhASArYyIfFvnvJCNrR2NIOvDVNNTFRa+Re2MWyw==
   dependencies:
     commander "^2.20.0"
     source-map "~0.6.1"
@@ -7705,9 +8377,9 @@
     thenify ">= 3.1.0 < 4"
 
 "thenify@>= 3.1.0 < 4":
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.0.tgz#e69e38a1babe969b0108207978b9f62b88604839"
-  integrity sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
+  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
   dependencies:
     any-promise "^1.0.0"
 
@@ -7743,14 +8415,15 @@
     readable-stream "~2.3.6"
     xtend "~4.0.1"
 
-through2@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a"
-  integrity sha512-M96dvTalPT3YbYLaKaCuwu+j06D/8Jfib0o/PxbVt6Amhv3dUAtW6rTV1jPgJSBG83I/e04Y6xkVdVhSRhi0ww==
+through2@^3.0.0, through2@^3.0.1, through2@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.2.tgz#99f88931cfc761ec7678b41d5d7336b5b6a07bf4"
+  integrity sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==
   dependencies:
+    inherits "^2.0.4"
     readable-stream "2 || 3"
 
-through@^2.3.6:
+"through@>=2.2.7 <3", through@^2.3.6:
   version "2.3.8"
   resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
@@ -7844,6 +8517,14 @@
     psl "^1.1.24"
     punycode "^1.4.1"
 
+tough-cookie@~2.5.0:
+  version "2.5.0"
+  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
+  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
+  dependencies:
+    psl "^1.1.28"
+    punycode "^2.1.1"
+
 tr46@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
@@ -7900,6 +8581,16 @@
   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.21.3:
+  version "0.21.3"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
+  integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
+
+type-fest@^0.6.0:
+  version "0.6.0"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
+  integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
+
 type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
   version "1.6.18"
   resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
@@ -7913,10 +8604,10 @@
   resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
   integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 
-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==
+typescript@4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
+  integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
 
 typical@^2.6.0, typical@^2.6.1:
   version "2.6.1"
@@ -7929,9 +8620,9 @@
   integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
 
 ua-parser-js@^0.7.15:
-  version "0.7.21"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
-  integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
+  version "0.7.28"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
+  integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
 
 uglify-js@3.4.x:
   version "3.4.10"
@@ -7942,9 +8633,9 @@
     source-map "~0.6.1"
 
 underscore@^1.8.3:
-  version "1.9.2"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.2.tgz#0c8d6f536d6f378a5af264a72f7bec50feb7cf2f"
-  integrity sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.1.tgz#0c1c6bd2df54b6b69f2314066d65b6cde6fcf9d1"
+  integrity sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==
 
 underscore@~1.6.0:
   version "1.6.0"
@@ -7964,15 +8655,15 @@
     unicode-canonical-property-names-ecmascript "^1.0.4"
     unicode-property-aliases-ecmascript "^1.0.4"
 
-unicode-match-property-value-ecmascript@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.1.0.tgz#5b4b426e08d13a80365e0d657ac7a6c1ec46a277"
-  integrity sha512-hDTHvaBk3RmFzvSl0UVrUmC3PuW9wKVnpoUDYH0JDkSIovzw+J5viQmeYHxVSBptubnr7PbH2e0fnpDRQnQl5g==
+unicode-match-property-value-ecmascript@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
+  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
 
 unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.0.5.tgz#a9cc6cc7ce63a0a3023fc99e341b94431d405a57"
-  integrity sha512-L5RAqCfXqAwR3RriF8pM0lU0w4Ryf/GgzONwi6KnL1taJQa7x1TCxdJnILX59WIGOwR57IVxn7Nej0fz1Ny6fw==
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
+  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
 
 union-value@^1.0.0:
   version "1.0.1"
@@ -8000,12 +8691,17 @@
     crypto-random-string "^1.0.0"
 
 universal-user-agent@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.0.tgz#27da2ec87e32769619f68a14996465ea1cb9df16"
-  integrity sha512-eM8knLpev67iBDizr/YtqkJsF3GK8gzDc6st/WKzrTuPtcsOKW/0IdL4cnMBsU69pOx0otavLWBDGTwg+dB0aA==
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"
+  integrity sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==
   dependencies:
     os-name "^3.1.0"
 
+universal-user-agent@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee"
+  integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==
+
 unpipe@1.0.0, unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -8077,16 +8773,16 @@
   integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
 
 uri-js@^4.2.2:
-  version "4.2.2"
-  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0"
-  integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
+  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
   dependencies:
     punycode "^2.1.0"
 
-urijs@^1.16.1, urijs@^1.19.1:
-  version "1.19.2"
-  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.2.tgz#f9be09f00c4c5134b7cb3cf475c1dd394526265a"
-  integrity sha512-s/UIq9ap4JPZ7H1EB5ULo/aOUbWqfDi7FKzMC2Nz+0Si8GiT1rIEaprt8hy3Vy2Ex2aJPpOQv4P4DuOZ+K1c6w==
+urijs@^1.16.1:
+  version "1.19.7"
+  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.7.tgz#4f594e59113928fea63c00ce688fb395b1168ab9"
+  integrity sha512-Id+IKjdU0Hx+7Zx717jwLPsPeUqz7rAtuVBRLLs+qn+J2nf9NGITWVCxcijgYxBqe83C7sqsQPs6H1pyz3x9gA==
 
 urix@^0.1.0:
   version "0.1.0"
@@ -8171,17 +8867,16 @@
     core-util-is "1.0.2"
     extsprintf "^1.2.0"
 
-vinyl-file@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-2.0.0.tgz#a7ebf5ffbefda1b7d18d140fcb07b223efb6751a"
-  integrity sha1-p+v1/779obfRjRQPyweyI++2dRo=
+vinyl-file@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-3.0.0.tgz#b104d9e4409ffa325faadd520642d0a3b488b365"
+  integrity sha1-sQTZ5ECf+jJfqt1SBkLQo7SIs2U=
   dependencies:
     graceful-fs "^4.1.2"
     pify "^2.3.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^2.0.0"
+    strip-bom-buf "^1.0.0"
     strip-bom-stream "^2.0.0"
-    vinyl "^1.1.0"
+    vinyl "^2.0.1"
 
 vinyl-fs@^2.4.3, vinyl-fs@^2.4.4:
   version "2.4.4"
@@ -8206,7 +8901,7 @@
     vali-date "^1.0.0"
     vinyl "^1.0.0"
 
-vinyl@^1.0.0, vinyl@^1.1.0, vinyl@^1.1.1, vinyl@^1.2.0:
+vinyl@^1.0.0, vinyl@^1.1.1, vinyl@^1.2.0:
   version "1.2.0"
   resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
   integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
@@ -8215,10 +8910,10 @@
     clone-stats "^0.0.1"
     replace-ext "0.0.1"
 
-vinyl@^2.0.1:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.0.tgz#d85b07da96e458d25b2ffe19fece9f2caa13ed86"
-  integrity sha512-MBH+yP0kC/GQ5GwBqrTPTzEfiiLjta7hTtvQtbxBgTeSXsmKQRQecjibMbxIXzVT3Y9KJK+drOz1/k+vsu8Nkg==
+vinyl@^2.0.1, vinyl@^2.2.0, vinyl@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974"
+  integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==
   dependencies:
     clone "^2.1.1"
     clone-buffer "^1.0.0"
@@ -8274,9 +8969,9 @@
     uuid "^3.2.1"
 
 wd@^1.2.0:
-  version "1.12.1"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.12.1.tgz#067eb3674db00eeb9e506701f9314657c44d5a89"
-  integrity sha512-O99X8OnOgkqfmsPyLIRzG9LmZ+rjmdGFBCyhGpnsSL4MB4xzHoeWmSVcumDiQ5QqPZcwGkszTgeJvjk2VjtiNw==
+  version "1.14.0"
+  resolved "https://registry.yarnpkg.com/wd/-/wd-1.14.0.tgz#1fe6450b5baef37caa135e7755292c6998ca8a90"
+  integrity sha512-X7ZfGHHYlQ5zYpRlgP16LUsvYti+Al/6fz3T/ClVyivVCpCZQpESTDdz6zbK910O5OIvujO23Ym2DBBo3XsQlA==
   dependencies:
     archiver "^3.0.0"
     async "^2.0.0"
@@ -8335,14 +9030,14 @@
     tr46 "^1.0.1"
     webidl-conversions "^4.0.2"
 
-which@^1.0.8, which@^1.2.12, which@^1.2.14, which@^1.2.9, which@^1.3.1:
+which@^1.0.8, which@^1.2.12, which@^1.2.14, which@^1.2.9:
   version "1.3.1"
   resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
   integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
   dependencies:
     isexe "^2.0.0"
 
-which@^2.0.2:
+which@^2.0.1, which@^2.0.2:
   version "2.0.2"
   resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
   integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
@@ -8364,34 +9059,34 @@
     string-width "^2.1.1"
 
 windows-release@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f"
-  integrity sha512-QTlz2hKLrdqukrsapKsINzqMgOUpQW268eJ0OaOpJN32h272waxR9fkB9VoWRtK7uKHG5EHJcTXQBD8XZVJkFA==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.3.tgz#1c10027c7225743eec6b89df160d64c2e0293999"
+  integrity sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==
   dependencies:
     execa "^1.0.0"
 
-winston-transport@^4.2.0, winston-transport@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.3.0.tgz#df68c0c202482c448d9b47313c07304c2d7c2c66"
-  integrity sha512-B2wPuwUi3vhzn/51Uukcao4dIduEiPOcOt9HJ3QeaXgkJ5Z7UwpBzxS4ZGNHtrxrUvTwemsQiSys0ihOf8Mp1A==
+winston-transport@^4.2.0, winston-transport@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59"
+  integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==
   dependencies:
-    readable-stream "^2.3.6"
+    readable-stream "^2.3.7"
     triple-beam "^1.2.0"
 
 winston@^3.0.0:
-  version "3.2.1"
-  resolved "https://registry.yarnpkg.com/winston/-/winston-3.2.1.tgz#63061377976c73584028be2490a1846055f77f07"
-  integrity sha512-zU6vgnS9dAWCEKg/QYigd6cgMVVNwyTzKs81XZtTFuRwJOcDdBg7AU0mXVyNbs7O5RH2zdv+BdNZUlx7mXPuOw==
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170"
+  integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==
   dependencies:
-    async "^2.6.1"
-    diagnostics "^1.1.1"
-    is-stream "^1.1.0"
-    logform "^2.1.1"
-    one-time "0.0.4"
-    readable-stream "^3.1.1"
+    "@dabh/diagnostics" "^2.0.2"
+    async "^3.1.0"
+    is-stream "^2.0.0"
+    logform "^2.2.0"
+    one-time "^1.0.0"
+    readable-stream "^3.4.0"
     stack-trace "0.0.x"
     triple-beam "^1.3.0"
-    winston-transport "^4.3.0"
+    winston-transport "^4.4.0"
 
 with-open-file@^0.1.6:
   version "0.1.7"
@@ -8402,7 +9097,7 @@
     p-try "^2.1.0"
     pify "^4.0.1"
 
-wordwrap@~0.0.2:
+wordwrap@^0.0.3:
   version "0.0.3"
   resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
   integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
@@ -8448,17 +9143,10 @@
     imurmurhash "^0.1.4"
     signal-exit "^3.0.2"
 
-ws@^7.1.2:
-  version "7.2.1"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e"
-  integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A==
-
-ws@~6.1.0:
-  version "6.1.4"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-6.1.4.tgz#5b5c8800afab925e94ccb29d153c8d02c1776ef9"
-  integrity sha512-eqZfL+NE/YQc1/ZynhojeV8q+H050oR8AZ2uIev7RU10svA9ZnJUddHcOUZTJLinZ9yEfdA2kSATS2qZK5fhJA==
-  dependencies:
-    async-limiter "~1.0.0"
+ws@~7.4.2:
+  version "7.4.6"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c"
+  integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A==
 
 xdg-basedir@^2.0.0:
   version "2.0.0"
@@ -8482,10 +9170,10 @@
   resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
   integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
 
-xmlhttprequest-ssl@~1.5.4:
-  version "1.5.5"
-  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
-  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
+xmlhttprequest-ssl@~1.6.2:
+  version "1.6.3"
+  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6"
+  integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q==
 
 "xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
   version "4.0.2"
@@ -8497,6 +9185,11 @@
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
 
+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==
+
 yauzl@^2.10.0:
   version "2.10.0"
   resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
@@ -8528,26 +9221,30 @@
     text-table "^0.2.0"
     untildify "^2.0.0"
 
-yeoman-environment@^2.0.5:
-  version "2.7.0"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.7.0.tgz#d1b6679de883ce14a68b869c4b19d55a0d66f477"
-  integrity sha512-YNzSUWgJVSgnm0qgLON4Gb2nTm+kywBiWjK4MbvosjUP2YJJ30lNhEx7ukyzKRPUlsavd5IsuALtF6QaVrq81A==
+yeoman-environment@^2.0.5, yeoman-environment@^2.9.5:
+  version "2.10.3"
+  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.10.3.tgz#9d8f42b77317414434cc0e51fb006a4bdd54688e"
+  integrity sha512-pLIhhU9z/G+kjOXmJ2bPFm3nejfbH+f1fjYRSOteEXDBrv1EoJE/e+kuHixSXfCYfTkxjYsvRaDX+1QykLCnpQ==
   dependencies:
     chalk "^2.4.1"
-    cross-spawn "^6.0.5"
     debug "^3.1.0"
     diff "^3.5.0"
     escape-string-regexp "^1.0.2"
+    execa "^4.0.0"
     globby "^8.0.1"
-    grouped-queue "^0.3.3"
-    inquirer "^6.0.0"
+    grouped-queue "^1.1.0"
+    inquirer "^7.1.0"
     is-scoped "^1.0.0"
     lodash "^4.17.10"
     log-symbols "^2.2.0"
     mem-fs "^1.1.0"
+    mem-fs-editor "^6.0.0"
+    npm-api "^1.0.0"
+    semver "^7.1.3"
     strip-ansi "^4.0.0"
     text-table "^0.2.0"
     untildify "^3.0.3"
+    yeoman-generator "^4.8.2"
 
 yeoman-generator@^3.1.1:
   version "3.2.0"
@@ -8580,6 +9277,40 @@
     through2 "^3.0.0"
     yeoman-environment "^2.0.5"
 
+yeoman-generator@^4.8.2:
+  version "4.13.0"
+  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-4.13.0.tgz#a6caeed8491fceea1f84f53e31795f25888b4672"
+  integrity sha512-f2/5N5IR3M2Ozm+QocvZQudlQITv2DwI6Mcxfy7R7gTTzaKgvUpgo/pQMJ+WQKm0KN0YMWCFOZpj0xFGxevc1w==
+  dependencies:
+    async "^2.6.2"
+    chalk "^2.4.2"
+    cli-table "^0.3.1"
+    cross-spawn "^6.0.5"
+    dargs "^6.1.0"
+    dateformat "^3.0.3"
+    debug "^4.1.1"
+    diff "^4.0.1"
+    error "^7.0.2"
+    find-up "^3.0.0"
+    github-username "^3.0.0"
+    istextorbinary "^2.5.1"
+    lodash "^4.17.11"
+    make-dir "^3.0.0"
+    mem-fs-editor "^7.0.1"
+    minimist "^1.2.5"
+    pretty-bytes "^5.2.0"
+    read-chunk "^3.2.0"
+    read-pkg-up "^5.0.0"
+    rimraf "^2.6.3"
+    run-async "^2.0.0"
+    semver "^7.2.1"
+    shelljs "^0.8.4"
+    text-table "^0.2.0"
+    through2 "^3.0.1"
+  optionalDependencies:
+    grouped-queue "^1.1.0"
+    yeoman-environment "^2.9.5"
+
 zip-stream@^2.1.2:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
diff --git a/tools/nongoogle.bzl b/tools/nongoogle.bzl
index a759dc3..8d98189 100644
--- a/tools/nongoogle.bzl
+++ b/tools/nongoogle.bzl
@@ -6,8 +6,6 @@
 
 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.
 
@@ -99,20 +97,6 @@
         sha1 = "22799941ec7bd5170ea890363cb968e400a69c41",
     )
 
-    # elasticsearch-rest-client explicitly depends on this version
-    maven_jar(
-        name = "httpasyncclient",
-        artifact = "org.apache.httpcomponents:httpasyncclient:4.1.4",
-        sha1 = "f3a3240681faae3fa46b573a4c7e50cec9db0d86",
-    )
-
-    # elasticsearch-rest-client explicitly depends on this version
-    maven_jar(
-        name = "httpcore-nio",
-        artifact = "org.apache.httpcomponents:httpcore-nio:4.4.12",
-        sha1 = "84cd29eca842f31db02987cfedea245af020198b",
-    )
-
     maven_jar(
         name = "openid-consumer",
         artifact = "org.openid4java:openid4java:1.0.0",
@@ -139,12 +123,6 @@
     )
 
     maven_jar(
-        name = "jackson-core",
-        artifact = "com.fasterxml.jackson.core:jackson-core:2.12.0",
-        sha1 = "afe52c6947d9939170da7989612cef544115511a",
-    )
-
-    maven_jar(
         name = "commons-io",
         artifact = "commons-io:commons-io:2.4",
         sha1 = "b1b6ea3b7e4aa4f492509a4952029cd8e48019ad",
@@ -153,24 +131,24 @@
     # Google internal dependencies: these are developed at Google, so there is
     # no concern about version skew.
 
-    FLOGGER_VERS = "0.5.1"
+    FLOGGER_VERS = "0.6"
 
     maven_jar(
         name = "flogger",
         artifact = "com.google.flogger:flogger:" + FLOGGER_VERS,
-        sha1 = "71d1e2cef9cc604800825583df56b8ef5c053f14",
+        sha1 = "155dc6e303a58f7bbff5d2cd1a259de86827f4fe",
     )
 
     maven_jar(
         name = "flogger-log4j-backend",
         artifact = "com.google.flogger:flogger-log4j-backend:" + FLOGGER_VERS,
-        sha1 = "5e2794b75c88223f263f1c1a9d7ea51e2dc45732",
+        sha1 = "9743841bf10309163effd8ddf882b5d5190cc9d9",
     )
 
     maven_jar(
         name = "flogger-system-backend",
         artifact = "com.google.flogger:flogger-system-backend:" + FLOGGER_VERS,
-        sha1 = "b66d3bedb14da604828a8693bb24fd78e36b0e9e",
+        sha1 = "0f0ccf8923c6c315f2f57b108bcc6e46ccd88777",
     )
 
     maven_jar(
@@ -219,52 +197,6 @@
         sha1 = "dc13ae4faca6df981fc7aeb5a522d9db446d5d50",
     )
 
-    DOCKER_JAVA_VERS = "3.2.7"
-
-    maven_jar(
-        name = "docker-java-api",
-        artifact = "com.github.docker-java:docker-java-api:" + DOCKER_JAVA_VERS,
-        sha1 = "81408fc988c229ea11354fee9902c47842343f04",
-    )
-
-    maven_jar(
-        name = "docker-java-transport",
-        artifact = "com.github.docker-java:docker-java-transport:" + DOCKER_JAVA_VERS,
-        sha1 = "315903a129f530422747efc163dd255f0fa2555e",
-    )
-
-    # https://github.com/docker-java/docker-java/blob/3.2.7/pom.xml#L61
-    # <=> DOCKER_JAVA_VERS
-    maven_jar(
-        name = "jackson-annotations",
-        artifact = "com.fasterxml.jackson.core:jackson-annotations:2.10.3",
-        sha1 = "0f63b3b1da563767d04d2e4d3fc1ae0cdeffebe7",
-    )
-
-    maven_jar(
-        name = "testcontainers",
-        artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
-        sha1 = "91e6dfab8f141f77c6a0dd147a94bd186993a22c",
-    )
-
-    maven_jar(
-        name = "duct-tape",
-        artifact = "org.rnorth.duct-tape:duct-tape:1.0.8",
-        sha1 = "92edc22a9ab2f3e17c9bf700aaee377d50e8b530",
-    )
-
-    maven_jar(
-        name = "visible-assertions",
-        artifact = "org.rnorth.visible-assertions:visible-assertions:2.1.2",
-        sha1 = "20d31a578030ec8e941888537267d3123c2ad1c1",
-    )
-
-    maven_jar(
-        name = "jna",
-        artifact = "net.java.dev.jna:jna:5.5.0",
-        sha1 = "0e0845217c4907822403912ad6828d8e0b256208",
-    )
-
     maven_jar(
         name = "jimfs",
         artifact = "com.google.jimfs:jimfs:1.2",
@@ -297,6 +229,38 @@
         sha1 = "64cba89cf87c1d84cb8c81d06f0b9c482f10b4dc",
     )
 
+    LUCENE_VERS = "6.6.5"
+
+    maven_jar(
+        name = "lucene-core",
+        artifact = "org.apache.lucene:lucene-core:" + LUCENE_VERS,
+        sha1 = "2983f80b1037e098209657b0ca9176827892d0c0",
+    )
+
+    maven_jar(
+        name = "lucene-analyzers-common",
+        artifact = "org.apache.lucene:lucene-analyzers-common:" + LUCENE_VERS,
+        sha1 = "6094f91071d90570b7f5f8ce481d5de7d2d2e9d5",
+    )
+
+    maven_jar(
+        name = "backward-codecs",
+        artifact = "org.apache.lucene:lucene-backward-codecs:" + LUCENE_VERS,
+        sha1 = "460a19e8d1aa7d31e9614cf528a6cb508c9e823d",
+    )
+
+    maven_jar(
+        name = "lucene-misc",
+        artifact = "org.apache.lucene:lucene-misc:" + LUCENE_VERS,
+        sha1 = "ce3a1b7b6a92b9af30791356a4bd46d1cea6cc1e",
+    )
+
+    maven_jar(
+        name = "lucene-queryparser",
+        artifact = "org.apache.lucene:lucene-queryparser:" + LUCENE_VERS,
+        sha1 = "2db9ca0086a4b8e0b9bc9f08a9b420303168e37c",
+    )
+
     # JGit's transitive dependencies
     maven_jar(
         name = "hamcrest",
diff --git a/tools/polygerrit-updater/tsconfig.json b/tools/polygerrit-updater/tsconfig.json
index 37ff1b2..80f60c1 100644
--- a/tools/polygerrit-updater/tsconfig.json
+++ b/tools/polygerrit-updater/tsconfig.json
@@ -2,8 +2,8 @@
   "compilerOptions": {
     /* Basic Options */
     // "incremental": true,                   /* Enable incremental compilation */
-    "target": "es2015",                          /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
-    "module": "commonjs",                     /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
+    "target": "es2019", 		      /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
+    "module": "es2015", 		      /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
     // "lib": [],                             /* Specify library files to be included in the compilation. */
     // "allowJs": true,                       /* Allow javascript files to be compiled. */
     // "checkJs": true,                       /* Report errors in .js files. */
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
index 05fa023..167f68a 100644
--- a/tools/release_noter/release_noter.py
+++ b/tools/release_noter/release_noter.py
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 
 import argparse
 import os
@@ -172,7 +172,6 @@
     )
     doc = Component("Documentation", {"document"})
     jgit = Component("JGit", {"jgit"})
-    elastic = Component("Elasticsearch", {"elastic"})
     deps = Component("Other dependency", {"upgrade", "dependenc"})
     otherwise = Component("Other core", {})
 
diff --git a/version.bzl b/version.bzl
index 501af3c..356b0d8 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.4.5-SNAPSHOT"
+GERRIT_VERSION = "3.5.2-SNAPSHOT"
diff --git a/yarn.lock b/yarn.lock
index a30f36f..8f22ce5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2,504 +2,41 @@
 # yarn lockfile v1
 
 
-"@babel/code-frame@7.12.11", "@babel/code-frame@^7.0.0":
+"@babel/code-frame@7.12.11":
   version "7.12.11"
-  resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.12.11.tgz"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f"
   integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==
   dependencies:
     "@babel/highlight" "^7.10.4"
 
-"@babel/code-frame@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658"
-  integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g==
+"@babel/code-frame@^7.0.0":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb"
+  integrity sha512-9pzDqyc6OLDaqe+zbACgFkb6fKMNG6CObKpnYXChRsvYGyEdc7CA2BaqeOM+vOtCS5ndmJicPJhKAwYRI6UfFw==
   dependencies:
-    "@babel/highlight" "^7.12.13"
+    "@babel/highlight" "^7.14.5"
 
-"@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.8":
-  version "7.13.15"
-  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.15.tgz#7e8eea42d0b64fda2b375b22d06c605222e848f4"
-  integrity sha512-ltnibHKR1VnrU4ymHyQ/CXtNXI6yZC0oJThyW78Hft8XndANwi+9H+UIklBDraIjFEJzw8wmcM427oDd9KS5wA==
+"@babel/helper-validator-identifier@^7.14.5":
+  version "7.14.9"
+  resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.14.9.tgz#6654d171b2024f6d8ee151bf2509699919131d48"
+  integrity sha512-pQYxPY0UP6IHISRitNe8bsijHex4TWZXi2HwKVsjPiltzlhse2znVcm9Ace510VT1kxIHjGJCZZQBX2gJDbo0g==
 
-"@babel/core@^7.0.0":
-  version "7.13.15"
-  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.15.tgz#a6d40917df027487b54312202a06812c4f7792d0"
-  integrity sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==
+"@babel/highlight@^7.10.4", "@babel/highlight@^7.14.5":
+  version "7.14.5"
+  resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.14.5.tgz#6861a52f03966405001f6aa534a01a24d99e8cd9"
+  integrity sha512-qf9u2WFWVV0MppaL877j2dBtQIDgmidgjGk5VIMw3OadXvYaXn66U1BFlH2t4+t3i+8PhedppRv+i40ABzd+gg==
   dependencies:
-    "@babel/code-frame" "^7.12.13"
-    "@babel/generator" "^7.13.9"
-    "@babel/helper-compilation-targets" "^7.13.13"
-    "@babel/helper-module-transforms" "^7.13.14"
-    "@babel/helpers" "^7.13.10"
-    "@babel/parser" "^7.13.15"
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.15"
-    "@babel/types" "^7.13.14"
-    convert-source-map "^1.7.0"
-    debug "^4.1.0"
-    gensync "^1.0.0-beta.2"
-    json5 "^2.1.2"
-    semver "^6.3.0"
-    source-map "^0.5.0"
-
-"@babel/generator@^7.0.0-beta.42", "@babel/generator@^7.13.9":
-  version "7.13.9"
-  resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.9.tgz#3a7aa96f9efb8e2be42d38d80e2ceb4c64d8de39"
-  integrity sha512-mHOOmY0Axl/JCTkxTU6Lf5sWOg/v8nUa+Xkt4zMTftX0wqmb6Sh7J8gvcehBw7q0AhrhAR+FDacKjCZ2X8K+Sw==
-  dependencies:
-    "@babel/types" "^7.13.0"
-    jsesc "^2.5.1"
-    source-map "^0.5.0"
-
-"@babel/helper-annotate-as-pure@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.13.tgz#0f58e86dfc4bb3b1fcd7db806570e177d439b6ab"
-  integrity sha512-7YXfX5wQ5aYM/BOlbSccHDbuXXFPxeoUmfWtz8le2yTkTZc+BxsiEnENFoi2SlmA8ewDkG2LgIMIVzzn2h8kfw==
-  dependencies:
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-builder-binary-assignment-operator-visitor@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.12.13.tgz#6bc20361c88b0a74d05137a65cac8d3cbf6f61fc"
-  integrity sha512-CZOv9tGphhDRlVjVkAgm8Nhklm9RzSmWpX2my+t7Ua/KT616pEzXsQCjinzvkRvHWJ9itO4f296efroX23XCMA==
-  dependencies:
-    "@babel/helper-explode-assignable-expression" "^7.12.13"
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8":
-  version "7.13.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz#2b2972a0926474853f41e4adbc69338f520600e5"
-  integrity sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==
-  dependencies:
-    "@babel/compat-data" "^7.13.12"
-    "@babel/helper-validator-option" "^7.12.17"
-    browserslist "^4.14.5"
-    semver "^6.3.0"
-
-"@babel/helper-create-regexp-features-plugin@^7.12.13":
-  version "7.12.17"
-  resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.12.17.tgz#a2ac87e9e319269ac655b8d4415e94d38d663cb7"
-  integrity sha512-p2VGmBu9oefLZ2nQpgnEnG0ZlRPvL8gAGvPUMQwUdaE8k49rOMuZpOwdQoy5qJf6K8jL3bcAMhVUlHAjIgJHUg==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.12.13"
-    regexpu-core "^4.7.1"
-
-"@babel/helper-explode-assignable-expression@^7.12.13":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.13.0.tgz#17b5c59ff473d9f956f40ef570cf3a76ca12657f"
-  integrity sha512-qS0peLTDP8kOisG1blKbaoBg/o9OSa1qoumMjTK5pM+KDTtpxpsiubnCGP34vK8BXGcb2M9eigwgvoJryrzwWA==
-  dependencies:
-    "@babel/types" "^7.13.0"
-
-"@babel/helper-function-name@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a"
-  integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA==
-  dependencies:
-    "@babel/helper-get-function-arity" "^7.12.13"
-    "@babel/template" "^7.12.13"
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-get-function-arity@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583"
-  integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg==
-  dependencies:
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-member-expression-to-functions@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.13.12.tgz#dfe368f26d426a07299d8d6513821768216e6d72"
-  integrity sha512-48ql1CLL59aKbU94Y88Xgb2VFy7a95ykGRbJJaaVv+LX5U8wFpLfiGXJJGUozsmA1oEh/o5Bp60Voq7ACyA/Sw==
-  dependencies:
-    "@babel/types" "^7.13.12"
-
-"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977"
-  integrity sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==
-  dependencies:
-    "@babel/types" "^7.13.12"
-
-"@babel/helper-module-transforms@^7.13.0", "@babel/helper-module-transforms@^7.13.14":
-  version "7.13.14"
-  resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.13.14.tgz#e600652ba48ccb1641775413cb32cfa4e8b495ef"
-  integrity sha512-QuU/OJ0iAOSIatyVZmfqB0lbkVP0kDRiKj34xy+QNsnVZi/PA6BoSoreeqnxxa9EHFAIL0R9XOaAR/G9WlIy5g==
-  dependencies:
-    "@babel/helper-module-imports" "^7.13.12"
-    "@babel/helper-replace-supers" "^7.13.12"
-    "@babel/helper-simple-access" "^7.13.12"
-    "@babel/helper-split-export-declaration" "^7.12.13"
-    "@babel/helper-validator-identifier" "^7.12.11"
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.13"
-    "@babel/types" "^7.13.14"
-
-"@babel/helper-optimise-call-expression@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.12.13.tgz#5c02d171b4c8615b1e7163f888c1c81c30a2aaea"
-  integrity sha512-BdWQhoVJkp6nVjB7nkFWcn43dkprYauqtk++Py2eaf/GRDFm5BxRqEIZCiHlZUGAVmtwKcsVL1dC68WmzeFmiA==
-  dependencies:
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.8.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.13.0.tgz#806526ce125aed03373bc416a828321e3a6a33af"
-  integrity sha512-ZPafIPSwzUlAoWT8DKs1W2VyF2gOWthGd5NGFMsBcMMol+ZhK+EQY/e6V96poa6PA/Bh+C9plWN0hXO1uB8AfQ==
-
-"@babel/helper-remap-async-to-generator@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.13.0.tgz#376a760d9f7b4b2077a9dd05aa9c3927cadb2209"
-  integrity sha512-pUQpFBE9JvC9lrQbpX0TmeNIy5s7GnZjna2lhhcHC7DzgBs6fWn722Y5cfwgrtrqc7NAJwMvOa0mKhq6XaE4jg==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.12.13"
-    "@babel/helper-wrap-function" "^7.13.0"
-    "@babel/types" "^7.13.0"
-
-"@babel/helper-replace-supers@^7.12.13", "@babel/helper-replace-supers@^7.13.0", "@babel/helper-replace-supers@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.13.12.tgz#6442f4c1ad912502481a564a7386de0c77ff3804"
-  integrity sha512-Gz1eiX+4yDO8mT+heB94aLVNCL+rbuT2xy4YfyNqu8F+OI6vMvJK891qGBTqL9Uc8wxEvRW92Id6G7sDen3fFw==
-  dependencies:
-    "@babel/helper-member-expression-to-functions" "^7.13.12"
-    "@babel/helper-optimise-call-expression" "^7.12.13"
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.12"
-
-"@babel/helper-simple-access@^7.13.12":
-  version "7.13.12"
-  resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.13.12.tgz#dd6c538afb61819d205a012c31792a39c7a5eaf6"
-  integrity sha512-7FEjbrx5SL9cWvXioDbnlYTppcZGuCY6ow3/D5vMggb2Ywgu4dMrpTJX0JdQAIcRRUElOIxF3yEooa9gUb9ZbA==
-  dependencies:
-    "@babel/types" "^7.13.12"
-
-"@babel/helper-skip-transparent-expression-wrappers@^7.12.1":
-  version "7.12.1"
-  resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.12.1.tgz#462dc63a7e435ade8468385c63d2b84cce4b3cbf"
-  integrity sha512-Mf5AUuhG1/OCChOJ/HcADmvcHM42WJockombn8ATJG3OnyiSxBK/Mm5x78BQWvmtXZKHgbjdGL2kin/HOLlZGA==
-  dependencies:
-    "@babel/types" "^7.12.1"
-
-"@babel/helper-split-export-declaration@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05"
-  integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg==
-  dependencies:
-    "@babel/types" "^7.12.13"
-
-"@babel/helper-validator-identifier@^7.12.11":
-  version "7.12.11"
-  resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz"
-  integrity sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw==
-
-"@babel/helper-validator-option@^7.12.17":
-  version "7.12.17"
-  resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.12.17.tgz#d1fbf012e1a79b7eebbfdc6d270baaf8d9eb9831"
-  integrity sha512-TopkMDmLzq8ngChwRlyjR6raKD6gMSae4JdYDB8bByKreQgG0RBTuKe9LRxW3wFtUnjxOPRKBDwEH6Mg5KeDfw==
-
-"@babel/helper-wrap-function@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.13.0.tgz#bdb5c66fda8526ec235ab894ad53a1235c79fcc4"
-  integrity sha512-1UX9F7K3BS42fI6qd2A4BjKzgGjToscyZTdp1DjknHLCIvpgne6918io+aL5LXFcER/8QWiwpoY902pVEqgTXA==
-  dependencies:
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.0"
-
-"@babel/helpers@^7.13.10":
-  version "7.13.10"
-  resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.13.10.tgz#fd8e2ba7488533cdeac45cc158e9ebca5e3c7df8"
-  integrity sha512-4VO883+MWPDUVRF3PhiLBUFHoX/bsLTGFpFK/HqvvfBZz2D57u9XzPVNFVBTc0PW/CWR9BXTOKt8NF4DInUHcQ==
-  dependencies:
-    "@babel/template" "^7.12.13"
-    "@babel/traverse" "^7.13.0"
-    "@babel/types" "^7.13.0"
-
-"@babel/highlight@^7.10.4", "@babel/highlight@^7.12.13":
-  version "7.13.10"
-  resolved "https://registry.npmjs.org/@babel/highlight/-/highlight-7.13.10.tgz"
-  integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.12.11"
+    "@babel/helper-validator-identifier" "^7.14.5"
     chalk "^2.0.0"
     js-tokens "^4.0.0"
 
-"@babel/parser@^7.12.13", "@babel/parser@^7.13.15":
-  version "7.13.15"
-  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.15.tgz#8e66775fb523599acb6a289e12929fa5ab0954d8"
-  integrity sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==
-
-"@babel/plugin-external-helpers@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-external-helpers/-/plugin-external-helpers-7.12.13.tgz#65ef9f4576297250dc601d2aa334769790d9966d"
-  integrity sha512-ClvAsk4RqpE6iacYUjdU9PtvIwC9yAefZENsPfGeG5FckX3jFZLDlWPuyv5gi9/9C2VgwX6H8q1ukBifC0ha+Q==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-proposal-async-generator-functions@^7.0.0":
-  version "7.13.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.15.tgz#80e549df273a3b3050431b148c892491df1bcc5b"
-  integrity sha512-VapibkWzFeoa6ubXy/NgV5U2U4MVnUlvnx6wo1XhlsaTrLYWE0UFpDQsVrmn22q5CzeloqJ8gEMHSKxuee6ZdA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-remap-async-to-generator" "^7.13.0"
-    "@babel/plugin-syntax-async-generators" "^7.8.4"
-
-"@babel/plugin-proposal-object-rest-spread@^7.0.0":
-  version "7.13.8"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.13.8.tgz#5d210a4d727d6ce3b18f9de82cc99a3964eed60a"
-  integrity sha512-DhB2EuB1Ih7S3/IRX5AFVgZ16k3EzfRbq97CxAVI1KSYcW+lexV8VZb7G7L8zuPVSdQMRn0kiBpf/Yzu9ZKH0g==
-  dependencies:
-    "@babel/compat-data" "^7.13.8"
-    "@babel/helper-compilation-targets" "^7.13.8"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/plugin-syntax-object-rest-spread" "^7.8.3"
-    "@babel/plugin-transform-parameters" "^7.13.0"
-
-"@babel/plugin-syntax-async-generators@^7.0.0", "@babel/plugin-syntax-async-generators@^7.8.4":
-  version "7.8.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d"
-  integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-syntax-dynamic-import@^7.0.0":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3"
-  integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-syntax-import-meta@^7.0.0":
-  version "7.10.4"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51"
-  integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.10.4"
-
-"@babel/plugin-syntax-object-rest-spread@^7.0.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3":
-  version "7.8.3"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871"
-  integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.8.0"
-
-"@babel/plugin-transform-arrow-functions@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.13.0.tgz#10a59bebad52d637a027afa692e8d5ceff5e3dae"
-  integrity sha512-96lgJagobeVmazXFaDrbmCLQxBysKu7U6Do3mLsx27gf5Dk85ezysrs2BZUpXD703U/Su1xTBDxxar2oa4jAGg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-
-"@babel/plugin-transform-async-to-generator@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.13.0.tgz#8e112bf6771b82bf1e974e5e26806c5c99aa516f"
-  integrity sha512-3j6E004Dx0K3eGmhxVJxwwI89CTJrce7lg3UrtFuDAVQ/2+SJ/h/aSFOeE6/n0WB1GsOffsJp6MnPQNQ8nmwhg==
-  dependencies:
-    "@babel/helper-module-imports" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-remap-async-to-generator" "^7.13.0"
-
-"@babel/plugin-transform-block-scoped-functions@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.12.13.tgz#a9bf1836f2a39b4eb6cf09967739de29ea4bf4c4"
-  integrity sha512-zNyFqbc3kI/fVpqwfqkg6RvBgFpC4J18aKKMmv7KdQ/1GgREapSJAykLMVNwfRGO3BtHj3YQZl8kxCXPcVMVeg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-block-scoping@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.12.13.tgz#f36e55076d06f41dfd78557ea039c1b581642e61"
-  integrity sha512-Pxwe0iqWJX4fOOM2kEZeUuAxHMWb9nK+9oh5d11bsLoB0xMg+mkDpt0eYuDZB7ETrY9bbcVlKUGTOGWy7BHsMQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-classes@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.13.0.tgz#0265155075c42918bf4d3a4053134176ad9b533b"
-  integrity sha512-9BtHCPUARyVH1oXGcSJD3YpsqRLROJx5ZNP6tN5vnk17N0SVf9WCtf8Nuh1CFmgByKKAIMstitKduoCmsaDK5g==
-  dependencies:
-    "@babel/helper-annotate-as-pure" "^7.12.13"
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-optimise-call-expression" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-replace-supers" "^7.13.0"
-    "@babel/helper-split-export-declaration" "^7.12.13"
-    globals "^11.1.0"
-
-"@babel/plugin-transform-computed-properties@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.13.0.tgz#845c6e8b9bb55376b1fa0b92ef0bdc8ea06644ed"
-  integrity sha512-RRqTYTeZkZAz8WbieLTvKUEUxZlUTdmL5KGMyZj7FnMfLNKV4+r5549aORG/mgojRmFlQMJDUupwAMiF2Q7OUg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-
-"@babel/plugin-transform-destructuring@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.13.0.tgz#c5dce270014d4e1ebb1d806116694c12b7028963"
-  integrity sha512-zym5em7tePoNT9s964c0/KU3JPPnuq7VhIxPRefJ4/s82cD+q1mgKfuGRDMCPL0HTyKz4dISuQlCusfgCJ86HA==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-
-"@babel/plugin-transform-duplicate-keys@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.12.13.tgz#6f06b87a8b803fd928e54b81c258f0a0033904de"
-  integrity sha512-NfADJiiHdhLBW3pulJlJI2NB0t4cci4WTZ8FtdIuNc2+8pslXdPtRRAEWqUY+m9kNOk2eRYbTAOipAxlrOcwwQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-exponentiation-operator@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.12.13.tgz#4d52390b9a273e651e4aba6aee49ef40e80cd0a1"
-  integrity sha512-fbUelkM1apvqez/yYx1/oICVnGo2KM5s63mhGylrmXUxK/IAXSIf87QIxVfZldWf4QsOafY6vV3bX8aMHSvNrA==
-  dependencies:
-    "@babel/helper-builder-binary-assignment-operator-visitor" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-for-of@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.13.0.tgz#c799f881a8091ac26b54867a845c3e97d2696062"
-  integrity sha512-IHKT00mwUVYE0zzbkDgNRP6SRzvfGCYsOxIRz8KsiaaHCcT9BWIkO+H9QRJseHBLOGBZkHUdHiqj6r0POsdytg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-
-"@babel/plugin-transform-function-name@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.12.13.tgz#bb024452f9aaed861d374c8e7a24252ce3a50051"
-  integrity sha512-6K7gZycG0cmIwwF7uMK/ZqeCikCGVBdyP2J5SKNCXO5EOHcqi+z7Jwf8AmyDNcBgxET8DrEtCt/mPKPyAzXyqQ==
-  dependencies:
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-instanceof@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-instanceof/-/plugin-transform-instanceof-7.12.13.tgz#5df8ead82ed421b728662c9e0ed943536c872ced"
-  integrity sha512-lYZ6F2xmM797Nk8PzDn2AreAEjKb96S3JkZkaMUlRTIThaYjvo1+aLa7oegnVc42lJY7Hr4yT1M/i6kwRcPlsQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-literals@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.12.13.tgz#2ca45bafe4a820197cf315794a4d26560fe4bdb9"
-  integrity sha512-FW+WPjSR7hiUxMcKqyNjP05tQ2kmBCdpEpZHY1ARm96tGQCCBvXKnpjILtDplUnJ/eHZ0lALLM+d2lMFSpYJrQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-modules-amd@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.13.0.tgz#19f511d60e3d8753cc5a6d4e775d3a5184866cc3"
-  integrity sha512-EKy/E2NHhY/6Vw5d1k3rgoobftcNUmp9fGjb9XZwQLtTctsRBOTRO7RHHxfIky1ogMN5BxN7p9uMA3SzPfotMQ==
-  dependencies:
-    "@babel/helper-module-transforms" "^7.13.0"
-    "@babel/helper-plugin-utils" "^7.13.0"
-    babel-plugin-dynamic-import-node "^2.3.3"
-
-"@babel/plugin-transform-object-super@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.12.13.tgz#b4416a2d63b8f7be314f3d349bd55a9c1b5171f7"
-  integrity sha512-JzYIcj3XtYspZDV8j9ulnoMPZZnF/Cj0LUxPOjR89BdBVx+zYJI9MdMIlUZjbXDX+6YVeS6I3e8op+qQ3BYBoQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-    "@babel/helper-replace-supers" "^7.12.13"
-
-"@babel/plugin-transform-parameters@^7.0.0", "@babel/plugin-transform-parameters@^7.13.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.13.0.tgz#8fa7603e3097f9c0b7ca1a4821bc2fb52e9e5007"
-  integrity sha512-Jt8k/h/mIwE2JFEOb3lURoY5C85ETcYPnbuAJ96zRBzh1XHtQZfs62ChZ6EP22QlC8c7Xqr9q+e1SU5qttwwjw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-
-"@babel/plugin-transform-regenerator@^7.0.0":
-  version "7.13.15"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.13.15.tgz#e5eb28945bf8b6563e7f818945f966a8d2997f39"
-  integrity sha512-Bk9cOLSz8DiurcMETZ8E2YtIVJbFCPGW28DJWUakmyVWtQSm6Wsf0p4B4BfEr/eL2Nkhe/CICiUiMOCi1TPhuQ==
-  dependencies:
-    regenerator-transform "^0.14.2"
-
-"@babel/plugin-transform-shorthand-properties@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.12.13.tgz#db755732b70c539d504c6390d9ce90fe64aff7ad"
-  integrity sha512-xpL49pqPnLtf0tVluuqvzWIgLEhuPpZzvs2yabUHSKRNlN7ScYU7aMlmavOeyXJZKgZKQRBlh8rHbKiJDraTSw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-spread@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.13.0.tgz#84887710e273c1815ace7ae459f6f42a5d31d5fd"
-  integrity sha512-V6vkiXijjzYeFmQTr3dBxPtZYLPcUfY34DebOU27jIl2M/Y8Egm52Hw82CSjjPqd54GTlJs5x+CR7HeNr24ckg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
-
-"@babel/plugin-transform-sticky-regex@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.12.13.tgz#760ffd936face73f860ae646fb86ee82f3d06d1f"
-  integrity sha512-Jc3JSaaWT8+fr7GRvQP02fKDsYk4K/lYwWq38r/UGfaxo89ajud321NH28KRQ7xy1Ybc0VUE5Pz8psjNNDUglg==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-template-literals@^7.0.0":
-  version "7.13.0"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.13.0.tgz#a36049127977ad94438dee7443598d1cefdf409d"
-  integrity sha512-d67umW6nlfmr1iehCcBv69eSUSySk1EsIS8aTDX4Xo9qajAh6mYtcl4kJrBkGXuxZPEgVr7RVfAvNW6YQkd4Mw==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.13.0"
-
-"@babel/plugin-transform-typeof-symbol@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.12.13.tgz#785dd67a1f2ea579d9c2be722de8c84cb85f5a7f"
-  integrity sha512-eKv/LmUJpMnu4npgfvs3LiHhJua5fo/CysENxa45YCQXZwKnGCQKAg87bvoqSW1fFT+HA32l03Qxsm8ouTY3ZQ==
-  dependencies:
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/plugin-transform-unicode-regex@^7.0.0":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.12.13.tgz#b52521685804e155b1202e83fc188d34bb70f5ac"
-  integrity sha512-mDRzSNY7/zopwisPZ5kM9XKCfhchqIYwAKRERtEnhYscZB79VRekuRSoYbN0+KVe3y8+q1h6A4svXtP7N+UoCA==
-  dependencies:
-    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
-    "@babel/helper-plugin-utils" "^7.12.13"
-
-"@babel/runtime@^7.8.4":
-  version "7.13.10"
-  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.13.10.tgz#47d42a57b6095f4468da440388fdbad8bebf0d7d"
-  integrity sha512-4QPkjJq6Ns3V/RgpEahRk+AGfL0eO6RHHtTWoNNr5mO49G6B5+X6d6THgWEAvTrznU5xYpbAlVKRYcsCgh/Akw==
+"@babel/runtime@^7.10.2":
+  version "7.15.4"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.15.4.tgz#fd17d16bfdf878e6dd02d19753a39fa8a8d9c84a"
+  integrity sha512-99catp6bHCaxr4sJ/DbTGgHS4+Rs2RVd2g7iOap6SLGPDknRK9ztKNsE/Fg6QhSeh1FGE5f6gHGQmvvn3I3xhw==
   dependencies:
     regenerator-runtime "^0.13.4"
 
-"@babel/template@^7.12.13":
-  version "7.12.13"
-  resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327"
-  integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA==
-  dependencies:
-    "@babel/code-frame" "^7.12.13"
-    "@babel/parser" "^7.12.13"
-    "@babel/types" "^7.12.13"
-
-"@babel/traverse@^7.0.0", "@babel/traverse@^7.0.0-beta.42", "@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.13.15":
-  version "7.13.15"
-  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.15.tgz#c38bf7679334ddd4028e8e1f7b3aa5019f0dada7"
-  integrity sha512-/mpZMNvj6bce59Qzl09fHEs8Bt8NnpEDQYleHUPZQ3wXUMvXi+HJPLars68oAbmp839fGoOkv2pSL2z9ajCIaQ==
-  dependencies:
-    "@babel/code-frame" "^7.12.13"
-    "@babel/generator" "^7.13.9"
-    "@babel/helper-function-name" "^7.12.13"
-    "@babel/helper-split-export-declaration" "^7.12.13"
-    "@babel/parser" "^7.13.15"
-    "@babel/types" "^7.13.14"
-    debug "^4.1.0"
-    globals "^11.1.0"
-
-"@babel/types@^7.0.0-beta.42", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.14":
-  version "7.13.14"
-  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.14.tgz#c35a4abb15c7cd45a2746d78ab328e362cbace0d"
-  integrity sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==
-  dependencies:
-    "@babel/helper-validator-identifier" "^7.12.11"
-    lodash "^4.17.19"
-    to-fast-properties "^2.0.0"
-
 "@bazel/concatjs@^5.1.0":
   version "5.4.0"
   resolved "https://registry.yarnpkg.com/@bazel/concatjs/-/concatjs-5.4.0.tgz#04e752a6ea3e684f00879e6683657c4ede72df6e"
@@ -539,30 +76,35 @@
   dependencies:
     google-protobuf "^3.6.1"
 
-"@dabh/diagnostics@^2.0.2":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@dabh/diagnostics/-/diagnostics-2.0.2.tgz#290d08f7b381b8f94607dc8f471a12c675f9db31"
-  integrity sha512-+A1YivoVDNNVCdfozHSR8v/jyuuLTMXwjWuxPFlFlUapXoGc+Gj9mDlTDDfrwl7rXCl2tNZ0kE8sIBO6YOn96Q==
-  dependencies:
-    colorspace "1.1.x"
-    enabled "2.0.x"
-    kuler "^2.0.0"
-
-"@eslint/eslintrc@^0.4.0":
-  version "0.4.0"
-  resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.0.tgz"
-  integrity sha512-2ZPCc+uNbjV5ERJr+aKSPRwZgKd2z11x0EgLvb1PURmUrn9QNRXFqje0Ldq454PfAVyaJYyrDvvIKSFP4NnBog==
+"@eslint/eslintrc@^0.4.3":
+  version "0.4.3"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c"
+  integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==
   dependencies:
     ajv "^6.12.4"
     debug "^4.1.1"
     espree "^7.3.0"
-    globals "^12.1.0"
+    globals "^13.9.0"
     ignore "^4.0.6"
     import-fresh "^3.2.1"
     js-yaml "^3.13.1"
     minimatch "^3.0.4"
     strip-json-comments "^3.1.1"
 
+"@humanwhocodes/config-array@^0.5.0":
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9"
+  integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==
+  dependencies:
+    "@humanwhocodes/object-schema" "^1.2.0"
+    debug "^4.1.1"
+    minimatch "^3.0.4"
+
+"@humanwhocodes/object-schema@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz#87de7af9c231826fdd68ac7258f77c429e0e5fcf"
+  integrity sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==
+
 "@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"
@@ -571,18 +113,18 @@
     call-me-maybe "^1.0.1"
     glob-to-regexp "^0.3.0"
 
-"@nodelib/fs.scandir@2.1.4":
-  version "2.1.4"
-  resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz"
-  integrity sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==
+"@nodelib/fs.scandir@2.1.5":
+  version "2.1.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
+  integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==
   dependencies:
-    "@nodelib/fs.stat" "2.0.4"
+    "@nodelib/fs.stat" "2.0.5"
     run-parallel "^1.1.9"
 
-"@nodelib/fs.stat@2.0.4", "@nodelib/fs.stat@^2.0.2":
-  version "2.0.4"
-  resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz"
-  integrity sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b"
+  integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
 
 "@nodelib/fs.stat@^1.1.2":
   version "1.1.3"
@@ -590,158 +132,36 @@
   integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
 
 "@nodelib/fs.walk@^1.2.3":
-  version "1.2.6"
-  resolved "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz"
-  integrity sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a"
+  integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==
   dependencies:
-    "@nodelib/fs.scandir" "2.1.4"
+    "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
-"@octokit/auth-token@^2.4.0":
-  version "2.4.5"
-  resolved "https://registry.yarnpkg.com/@octokit/auth-token/-/auth-token-2.4.5.tgz#568ccfb8cb46f36441fac094ce34f7a875b197f3"
-  integrity sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-
-"@octokit/endpoint@^6.0.1":
-  version "6.0.11"
-  resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-6.0.11.tgz#082adc2aebca6dcefa1fb383f5efb3ed081949d1"
-  integrity sha512-fUIPpx+pZyoLW4GCs3yMnlj2LfoXTWDUVPTC4V3MUEKZm48W+XYpeWSZCv+vYF1ZABUm2CqnDVf1sFtIYrj7KQ==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-    is-plain-object "^5.0.0"
-    universal-user-agent "^6.0.0"
-
-"@octokit/openapi-types@^6.0.0":
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-6.0.0.tgz#7da8d7d5a72d3282c1a3ff9f951c8133a707480d"
-  integrity sha512-CnDdK7ivHkBtJYzWzZm7gEkanA7gKH6a09Eguz7flHw//GacPJLmkHA3f3N++MJmlxD1Fl+mB7B32EEpSCwztQ==
-
-"@octokit/plugin-paginate-rest@^1.1.1":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-1.1.2.tgz#004170acf8c2be535aba26727867d692f7b488fc"
-  integrity sha512-jbsSoi5Q1pj63sC16XIUboklNw+8tL9VOnJsWycWYR78TKss5PVpIPb1TUUcMQ+bBh7cY579cVAWmf5qG+dw+Q==
-  dependencies:
-    "@octokit/types" "^2.0.1"
-
-"@octokit/plugin-request-log@^1.0.0":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-request-log/-/plugin-request-log-1.0.3.tgz#70a62be213e1edc04bb8897ee48c311482f9700d"
-  integrity sha512-4RFU4li238jMJAzLgAwkBAw+4Loile5haQMQr+uhFq27BmyJXcXSKvoQKqh0agsZEiUlW6iSv3FAgvmGkur7OQ==
-
-"@octokit/plugin-rest-endpoint-methods@2.4.0":
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-2.4.0.tgz#3288ecf5481f68c494dd0602fc15407a59faf61e"
-  integrity sha512-EZi/AWhtkdfAYi01obpX0DF7U6b1VRr30QNQ5xSFPITMdLSfhcBqjamE3F+sKcxPbD7eZuMHu3Qkk2V+JGxBDQ==
-  dependencies:
-    "@octokit/types" "^2.0.1"
-    deprecation "^2.3.1"
-
-"@octokit/request-error@^1.0.2":
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-1.2.1.tgz#ede0714c773f32347576c25649dc013ae6b31801"
-  integrity sha512-+6yDyk1EES6WK+l3viRDElw96MvwfJxCt45GvmjDUKWjYIb3PJZQkq3i46TwGwoPD4h8NmTrENmtyA1FwbmhRA==
-  dependencies:
-    "@octokit/types" "^2.0.0"
-    deprecation "^2.0.0"
-    once "^1.4.0"
-
-"@octokit/request-error@^2.0.0":
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-2.0.5.tgz#72cc91edc870281ad583a42619256b380c600143"
-  integrity sha512-T/2wcCFyM7SkXzNoyVNWjyVlUwBvW3igM3Btr/eKYiPmucXTtkxt2RBsf6gn3LTzaLSLTQtNmvg+dGsOxQrjZg==
-  dependencies:
-    "@octokit/types" "^6.0.3"
-    deprecation "^2.0.0"
-    once "^1.4.0"
-
-"@octokit/request@^5.2.0":
-  version "5.4.15"
-  resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.4.15.tgz#829da413dc7dd3aa5e2cdbb1c7d0ebe1f146a128"
-  integrity sha512-6UnZfZzLwNhdLRreOtTkT9n57ZwulCve8q3IT/Z477vThu6snfdkBuhxnChpOKNGxcQ71ow561Qoa6uqLdPtag==
-  dependencies:
-    "@octokit/endpoint" "^6.0.1"
-    "@octokit/request-error" "^2.0.0"
-    "@octokit/types" "^6.7.1"
-    is-plain-object "^5.0.0"
-    node-fetch "^2.6.1"
-    universal-user-agent "^6.0.0"
-
-"@octokit/rest@^16.2.0":
-  version "16.43.2"
-  resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.43.2.tgz#c53426f1e1d1044dee967023e3279c50993dd91b"
-  integrity sha512-ngDBevLbBTFfrHZeiS7SAMAZ6ssuVmXuya+F/7RaVvlysgGa1JKJkKWY+jV6TCJYcW0OALfJ7nTIGXcBXzycfQ==
-  dependencies:
-    "@octokit/auth-token" "^2.4.0"
-    "@octokit/plugin-paginate-rest" "^1.1.1"
-    "@octokit/plugin-request-log" "^1.0.0"
-    "@octokit/plugin-rest-endpoint-methods" "2.4.0"
-    "@octokit/request" "^5.2.0"
-    "@octokit/request-error" "^1.0.2"
-    atob-lite "^2.0.0"
-    before-after-hook "^2.0.0"
-    btoa-lite "^1.0.0"
-    deprecation "^2.0.0"
-    lodash.get "^4.4.2"
-    lodash.set "^4.3.2"
-    lodash.uniq "^4.5.0"
-    octokit-pagination-methods "^1.1.0"
-    once "^1.4.0"
-    universal-user-agent "^4.0.0"
-
-"@octokit/types@^2.0.0", "@octokit/types@^2.0.1":
-  version "2.16.2"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-2.16.2.tgz#4c5f8da3c6fecf3da1811aef678fda03edac35d2"
-  integrity sha512-O75k56TYvJ8WpAakWwYRN8Bgu60KrmX0z1KqFp1kNiFNkgW+JW+9EBKZ+S33PU6SLvbihqd+3drvPxKK68Ee8Q==
-  dependencies:
-    "@types/node" ">= 8"
-
-"@octokit/types@^6.0.3", "@octokit/types@^6.7.1":
-  version "6.13.0"
-  resolved "https://registry.yarnpkg.com/@octokit/types/-/types-6.13.0.tgz#779e5b7566c8dde68f2f6273861dd2f0409480d0"
-  integrity sha512-W2J9qlVIU11jMwKHUp5/rbVUeErqelCsO5vW5PKNb7wAXQVUz87Rc+imjlEvpvbH8yUb+KHmv8NEjVZdsdpyxA==
-  dependencies:
-    "@octokit/openapi-types" "^6.0.0"
-
-"@polymer/esm-amd-loader@^1.0.0":
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/@polymer/esm-amd-loader/-/esm-amd-loader-1.0.4.tgz#4e77f2f59b29b01e0ad02aa83d33716cddc5f9f9"
-  integrity sha512-h+hqYkL+tQV/y2ESD5gFXMl5z4cC+XY1jTlBeGSBaTcj3VbB5OBEScbvRXm63NcEbBneQQYbHfBAXAkF9i9wIA==
-
-"@polymer/sinonjs@^1.14.1":
-  version "1.17.1"
-  resolved "https://registry.yarnpkg.com/@polymer/sinonjs/-/sinonjs-1.17.1.tgz#e47d3785b7d0e8c29feb97f7e924b0fc597e2e9b"
-  integrity sha512-/U8F/cOTrbF2iVVYgINYmvKbtbexs+89Q3v8AaHADRYabTg7aOZGOb0RyWpOI+sUJt04kj63U4FwMhzW5r4wZA==
-
-"@polymer/test-fixture@^0.0.3":
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/@polymer/test-fixture/-/test-fixture-0.0.3.tgz#4443752697d4d9293bbc412ea0b5e4d341f149d9"
-  integrity sha1-REN1JpfU2Sk7vEEuoLXk00HxSdk=
-
 "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf"
   integrity sha1-m4sMxmPWaafY9vXQiToU00jzD78=
 
 "@protobufjs/base64@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735"
   integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==
 
 "@protobufjs/codegen@^2.0.4":
   version "2.0.4"
-  resolved "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.4.tgz#7ef37f0d010fb028ad1ad59722e506d9262815cb"
   integrity sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==
 
 "@protobufjs/eventemitter@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70"
   integrity sha1-NVy8mLr61ZePntCV85diHx0Ga3A=
 
 "@protobufjs/fetch@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45"
   integrity sha1-upn7WYYUr2VwDBYZ/wbUVLDYTEU=
   dependencies:
     "@protobufjs/aspromise" "^1.1.1"
@@ -749,893 +169,163 @@
 
 "@protobufjs/float@^1.0.2":
   version "1.0.2"
-  resolved "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1"
   integrity sha1-Xp4avctz/Ap8uLKR33jIy9l7h9E=
 
 "@protobufjs/inquire@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.0.tgz#ff200e3e7cf2429e2dcafc1140828e8cc638f089"
   integrity sha1-/yAOPnzyQp4tyvwRQIKOjMY48Ik=
 
 "@protobufjs/path@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d"
   integrity sha1-bMKyDFya1q0NzP0hynZz2Nf79o0=
 
 "@protobufjs/pool@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54"
   integrity sha1-Cf0V8tbTq/qbZbw2ZQbWrXhG/1Q=
 
 "@protobufjs/utf8@^1.1.0":
   version "1.1.0"
-  resolved "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.0.tgz#a777360b5b39a1a2e5106f8e858f2fd2d060c570"
   integrity sha1-p3c2C1s5oaLlEG+OhY8v0tBgxXA=
 
 "@sindresorhus/is@^0.14.0":
   version "0.14.0"
-  resolved "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz"
+  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea"
   integrity sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==
 
-"@sindresorhus/is@^4.0.0":
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.0.0.tgz#2ff674e9611b45b528896d820d3d7a812de2f0e4"
-  integrity sha512-FyD2meJpDPjyNQejSjvnhpgI/azsQkA4lGbuu5BQZfjvJ9cbRZXzeWL2HceCekW4lixO9JPesIIQkSoLjeJHNQ==
-
 "@szmarczak/http-timer@^1.1.2":
   version "1.1.2"
-  resolved "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-1.1.2.tgz#b1665e2c461a2cd92f4c1bbf50d5454de0d4b421"
   integrity sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==
   dependencies:
     defer-to-connect "^1.0.1"
 
-"@szmarczak/http-timer@^4.0.5":
-  version "4.0.5"
-  resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.5.tgz#bfbd50211e9dfa51ba07da58a14cdfd333205152"
-  integrity sha512-PyRA9sm1Yayuj5OIoJ1hGt2YISX45w9WcFbh6ddT0Z/0yaFxOtGLInr4jUfU1EAFVs0Yfyfev4RNwBlUaHdlDQ==
-  dependencies:
-    defer-to-connect "^2.0.0"
-
-"@types/babel-generator@^6.25.1":
-  version "6.25.3"
-  resolved "https://registry.yarnpkg.com/@types/babel-generator/-/babel-generator-6.25.3.tgz#8f06caa12d0595a0538560abe771966d77d29286"
-  integrity sha512-pGgnuxVddKcYIc+VJkRDop7gxLhqclNKBdlsm/5Vp8d+37pQkkDK7fef8d9YYImRzw9xcojEPc18pUYnbxmjqA==
-  dependencies:
-    "@types/babel-types" "*"
-
-"@types/babel-traverse@^6.25.2", "@types/babel-traverse@^6.25.3":
-  version "6.25.5"
-  resolved "https://registry.yarnpkg.com/@types/babel-traverse/-/babel-traverse-6.25.5.tgz#6d293cf7523e48b524faa7b86dc3c488191484e5"
-  integrity sha512-WrMbwmu+MWf8FiUMbmVOGkc7bHPzndUafn1CivMaBHthBBoo0VNIcYk1KV71UovYguhsNOwf3UF5oRmkkGOU3w==
-  dependencies:
-    "@types/babel-types" "*"
-
-"@types/babel-types@*":
+"@types/json-schema@^7.0.7":
   version "7.0.9"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-7.0.9.tgz#01d7b86949f455402a94c788883fe4ba574cad41"
-  integrity sha512-qZLoYeXSTgQuK1h7QQS16hqLGdmqtRmN8w/rl3Au/l5x/zkHx+a4VHrHyBsi1I1vtK2oBHxSzKIu0R5p6spdOA==
-
-"@types/babel-types@^6.25.1":
-  version "6.25.2"
-  resolved "https://registry.yarnpkg.com/@types/babel-types/-/babel-types-6.25.2.tgz#5c57f45973e4f13742dbc5273dd84cffe7373a9e"
-  integrity sha512-+3bMuktcY4a70a0KZc8aPJlEOArPuAKQYHU5ErjkOqGJdx8xuEEVK6nWogqigBOJ8nKPxRpyCUDTCPmZ3bUxGA==
-
-"@types/babylon@^6.16.2":
-  version "6.16.5"
-  resolved "https://registry.yarnpkg.com/@types/babylon/-/babylon-6.16.5.tgz#1c5641db69eb8cdf378edd25b4be7754beeb48b4"
-  integrity sha512-xH2e58elpj1X4ynnKp9qSnWlsRTIs6n3tgLGNfwAGHwePw0mulHQllV34n0T25uYSu1k0hRKkWXF890B1yS47w==
-  dependencies:
-    "@types/babel-types" "*"
-
-"@types/bluebird@*":
-  version "3.5.33"
-  resolved "https://registry.yarnpkg.com/@types/bluebird/-/bluebird-3.5.33.tgz#d79c020f283bd50bd76101d7d300313c107325fc"
-  integrity sha512-ndEo1xvnYeHxm7I/5sF6tBvnsA4Tdi3zj1keRKRs12SP+2ye2A27NDJ1B6PqkfMbGAcT+mqQVqbZRIrhfOp5PQ==
-
-"@types/body-parser@*":
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.0.tgz#0685b3c47eb3006ffed117cdd55164b61f80538f"
-  integrity sha512-W98JrE0j2K78swW4ukqMleo8R7h/pFETjM2DQ90MF6XK2i4LO4W3gQ71Lt4w3bfm2EvVSyWHplECvB5sK22yFQ==
-  dependencies:
-    "@types/connect" "*"
-    "@types/node" "*"
-
-"@types/cacheable-request@^6.0.1":
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.1.tgz#5d22f3dded1fd3a84c0bbeb5039a7419c2c91976"
-  integrity sha512-ykFq2zmBGOCbpIXtoVbz4SKY5QriWPh3AjyU4G74RYbtt5yOc5OfaY75ftjg7mikMOla1CTGpX3lLbuJh8DTrQ==
-  dependencies:
-    "@types/http-cache-semantics" "*"
-    "@types/keyv" "*"
-    "@types/node" "*"
-    "@types/responselike" "*"
-
-"@types/chai-subset@^1.3.0":
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94"
-  integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==
-  dependencies:
-    "@types/chai" "*"
-
-"@types/chai@*":
-  version "4.2.16"
-  resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.2.16.tgz#f09cc36e18d28274f942e7201147cce34d97e8c8"
-  integrity sha512-vI5iOAsez9+roLS3M3+Xx7w+WRuDtSmF8bQkrbcIJ2sC1PcDgVoA0WGpa+bIrJ+y8zqY2oi//fUctkxtIcXJCw==
-
-"@types/chalk@^0.4.30":
-  version "0.4.31"
-  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-0.4.31.tgz#a31d74241a6b1edbb973cf36d97a2896834a51f9"
-  integrity sha1-ox10JBprHtu5c8822XooloNKUfk=
-
-"@types/chalk@^2.2.0":
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/@types/chalk/-/chalk-2.2.0.tgz#b7f6e446f4511029ee8e3f43075fb5b73fbaa0ba"
-  integrity sha512-1zzPV9FDe1I/WHhRkf9SNgqtRJWZqrBWgu7JGveuHmmyR9CnAPCie2N/x+iHrgnpYBIcCJWHBoMRv2TRWktsvw==
-  dependencies:
-    chalk "*"
-
-"@types/clean-css@*":
-  version "4.2.4"
-  resolved "https://registry.yarnpkg.com/@types/clean-css/-/clean-css-4.2.4.tgz#4fe4705c384e6ec9ee8454bc3d49089f38dc038a"
-  integrity sha512-x8xEbfTtcv5uyQDrBXKg9Beo5QhTPqO4vM0uq4iU27/nhyRRWNEMKHjxvAb0WDvp2Mnt4Sw0jKmIi5yQF/k2Ag==
-  dependencies:
-    "@types/node" "*"
-    source-map "^0.6.0"
-
-"@types/clone@^0.1.30":
-  version "0.1.30"
-  resolved "https://registry.yarnpkg.com/@types/clone/-/clone-0.1.30.tgz#e7365648c1b42136a59c7d5040637b3b5c83b614"
-  integrity sha1-5zZWSMG0ITalnH1QQGN7O1yDthQ=
-
-"@types/compression@^0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/compression/-/compression-0.0.33.tgz#95dc733a2339aa846381d7f1377792d2553dc27d"
-  integrity sha1-ldxzOiM5qoRjgdfxN3eS0lU9wn0=
-  dependencies:
-    "@types/express" "*"
-
-"@types/connect@*":
-  version "3.4.34"
-  resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901"
-  integrity sha512-ePPA/JuI+X0vb+gSWlPKOY0NdNAie/rPUqX2GUPpbZwiKTkSPhjXWuee47E4MtE54QVzGCQMQkAL6JhV2E1+cQ==
-  dependencies:
-    "@types/node" "*"
-
-"@types/content-type@^1.1.0":
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/@types/content-type/-/content-type-1.1.3.tgz#3688bd77fc12f935548eef102a4e34c512b03a07"
-  integrity sha512-pv8VcFrZ3fN93L4rTNIbbUzdkzjEyVMp5mPVjsFfOYTDOZMZiZ8P1dhu+kEv3faYyKzZgLlSvnyQNFg+p/v5ug==
-
-"@types/cssbeautify@^0.3.1":
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/@types/cssbeautify/-/cssbeautify-0.3.1.tgz#8e0bee8f7decb952250da0caebe05e30591c17ef"
-  integrity sha1-jgvuj33suVIlDaDK6+BeMFkcF+8=
-
-"@types/del@^3.0.0":
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/@types/del/-/del-3.0.1.tgz#4712da8c119873cbbf533ad8dbf1baac5940ac5d"
-  integrity sha512-y6qRq6raBuu965clKgx6FHuiPu3oHdtmzMPXi8Uahsjdq1L6DL5fS/aY5/s71YwM7k6K1QIWvem5vNwlnNGIkQ==
-  dependencies:
-    "@types/glob" "*"
-
-"@types/doctrine@^0.0.1":
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.1.tgz#b999f2d9f7b43cabe0a1a2f39bc203bc7dcada9d"
-  integrity sha1-uZny2fe0PKvgoaLzm8IDvH3K2p0=
-
-"@types/escape-html@0.0.20":
-  version "0.0.20"
-  resolved "https://registry.yarnpkg.com/@types/escape-html/-/escape-html-0.0.20.tgz#cae698714dd61ebee5ab3f2aeb9a34ba1011735a"
-  integrity sha512-6dhZJLbA7aOwkYB2GDGdIqJ20wmHnkDzaxV9PJXe7O02I2dSFTERzRB6JrX6cWKaS+VqhhY7cQUMCbO5kloFUw==
-
-"@types/estree@*":
-  version "0.0.47"
-  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.47.tgz#d7a51db20f0650efec24cd04994f523d93172ed4"
-  integrity sha512-c5ciR06jK8u9BstrmJyO97m+klJrrhCf9u3rLu3DEAJBirxRqSCvDQoYKmxuYwQI5SZChAWu+tq9oVlGRuzPAg==
-
-"@types/events@*":
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7"
-  integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==
-
-"@types/expect@^1.20.4":
-  version "1.20.4"
-  resolved "https://registry.yarnpkg.com/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5"
-  integrity sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==
-
-"@types/express-serve-static-core@^4.17.18":
-  version "4.17.19"
-  resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.19.tgz#00acfc1632e729acac4f1530e9e16f6dd1508a1d"
-  integrity sha512-DJOSHzX7pCiSElWaGR8kCprwibCB/3yW6vcT8VG3P0SJjnv19gnWG/AZMfM60Xj/YJIp/YCaDHyvzsFVeniARA==
-  dependencies:
-    "@types/node" "*"
-    "@types/qs" "*"
-    "@types/range-parser" "*"
-
-"@types/express@*", "@types/express@^4.0.30", "@types/express@^4.0.36":
-  version "4.17.11"
-  resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.11.tgz#debe3caa6f8e5fcda96b47bd54e2f40c4ee59545"
-  integrity sha512-no+R6rW60JEc59977wIxreQVsIEOAYwgCqldrA/vkpCnbD7MqTefO97lmoBe4WE0F156bC4uLSP1XHDOySnChg==
-  dependencies:
-    "@types/body-parser" "*"
-    "@types/express-serve-static-core" "^4.17.18"
-    "@types/qs" "*"
-    "@types/serve-static" "*"
-
-"@types/fast-levenshtein@0.0.1":
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/@types/fast-levenshtein/-/fast-levenshtein-0.0.1.tgz#3a3615cf173645c8fca58d051e4e32824e4bd286"
-  integrity sha1-OjYVzxc2Rcj8pY0FHk4ygk5L0oY=
-
-"@types/findup-sync@^0.3.29":
-  version "0.3.30"
-  resolved "https://registry.yarnpkg.com/@types/findup-sync/-/findup-sync-0.3.30.tgz#8ab7bdbd6ba7cbf4f33b6596fde6fff1129c738d"
-  integrity sha512-Dpt1x3rhz6t8BMTS4vziTVos8VLkF4RngIxMBCSE6w0STmnVEEaoe3w+BG5xHyZXshye9lyZE99lpBDoLGY8eA==
-  dependencies:
-    "@types/minimatch" "*"
-
-"@types/form-data@*":
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.5.0.tgz#5025f7433016f923348434c40006d9a797c1b0e8"
-  integrity sha512-23/wYiuckYYtFpL+4RPWiWmRQH2BjFuqCUi2+N3amB1a1Drv+i/byTrGvlLwRVLFNAZbwpbQ7JvTK+VCAPMbcg==
-  dependencies:
-    form-data "*"
-
-"@types/freeport@^1.0.19":
-  version "1.0.21"
-  resolved "https://registry.yarnpkg.com/@types/freeport/-/freeport-1.0.21.tgz#73f6543ed67d3ca3fff97b985591598b7092066f"
-  integrity sha1-c/ZUPtZ9PKP/+XuYVZFZi3CSBm8=
-
-"@types/glob-stream@*":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@types/glob-stream/-/glob-stream-6.1.0.tgz#7ede8a33e59140534f8d8adfb8ac9edfb31897bc"
-  integrity sha512-RHv6ZQjcTncXo3thYZrsbAVwoy4vSKosSWhuhuQxLOTv74OJuFQxXkmUuZCr3q9uNBEVCvIzmZL/FeRNbHZGUg==
-  dependencies:
-    "@types/glob" "*"
-    "@types/node" "*"
-
-"@types/glob@*", "@types/glob@^7.1.1":
-  version "7.1.3"
-  resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.3.tgz#e6ba80f36b7daad2c685acd9266382e68985c183"
-  integrity sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==
-  dependencies:
-    "@types/minimatch" "*"
-    "@types/node" "*"
-
-"@types/globby@^6.1.0":
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/@types/globby/-/globby-6.1.0.tgz#7c25b975512a89effea2a656ca8cf6db7fb29d11"
-  integrity sha512-j3XSDNoK4LO5T+ZviQD6PqfEjm07QFEacOTbJR3hnLWuWX0ZMLJl9oRPgj1PyrfGbXhfHFkksC9QZ9HFltJyrw==
-  dependencies:
-    "@types/glob" "*"
-
-"@types/gulp-if@0.0.33":
-  version "0.0.33"
-  resolved "https://registry.yarnpkg.com/@types/gulp-if/-/gulp-if-0.0.33.tgz#edece22b7925d9a6db5f9c8c0d7882aa776fb678"
-  integrity sha512-J5lzff21X7r1x/4hSzn02GgIUEyjCqYIXZ9GgGBLhbsD3RiBdqwnkFWgF16/0jO5rcVZ52Zp+6MQMQdvIsWuKg==
-  dependencies:
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/html-minifier@^3.5.1":
-  version "3.5.3"
-  resolved "https://registry.yarnpkg.com/@types/html-minifier/-/html-minifier-3.5.3.tgz#5276845138db2cebc54c789e0aaf87621a21e84f"
-  integrity sha512-j1P/4PcWVVCPEy5lofcHnQ6BtXz9tHGiFPWzqm7TtGuWZEfCHEP446HlkSNc9fQgNJaJZ6ewPtp2aaFla/Uerg==
-  dependencies:
-    "@types/clean-css" "*"
-    "@types/relateurl" "*"
-    "@types/uglify-js" "*"
-
-"@types/http-cache-semantics@*":
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.0.tgz#9140779736aa2655635ee756e2467d787cfe8a2a"
-  integrity sha512-c3Xy026kOF7QOTn00hbIllV1dLR9hG9NkSrLQgCVs8NF6sBU+VGWjD3wLPhmh1TYAc7ugCFsvHYMN4VcBN1U1A==
-
-"@types/inquirer@*":
-  version "7.3.1"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-7.3.1.tgz#1f231224e7df11ccfaf4cf9acbcc3b935fea292d"
-  integrity sha512-osD38QVIfcdgsPCT0V3lD7eH0OFurX71Jft18bZrsVQWVRt6TuxRzlr0GJLrxoHZR2V5ph7/qP8se/dcnI7o0g==
-  dependencies:
-    "@types/through" "*"
-    rxjs "^6.4.0"
-
-"@types/inquirer@0.0.32":
-  version "0.0.32"
-  resolved "https://registry.yarnpkg.com/@types/inquirer/-/inquirer-0.0.32.tgz#a4a08e83741c500a7c3c8e7776014f7f8a65870d"
-  integrity sha1-pKCOg3QcUAp8PI53dgFPf4plhw0=
-  dependencies:
-    "@types/rx" "*"
-    "@types/through" "*"
-
-"@types/is-windows@^0.2.0":
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/@types/is-windows/-/is-windows-0.2.0.tgz#6f24ee48731d31168ea510610d6dd15e5fc9c6ff"
-  integrity sha1-byTuSHMdMRaOpRBhDW3RXl/Jxv8=
-
-"@types/json-schema@^7.0.3":
-  version "7.0.7"
-  resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.7.tgz"
-  integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
+  integrity sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==
 
 "@types/json5@^0.0.29":
   version "0.0.29"
-  resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
+  resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
-"@types/keyv@*":
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.1.tgz#e45a45324fca9dab716ab1230ee249c9fb52cfa7"
-  integrity sha512-MPtoySlAZQ37VoLaPcTHCu1RWJ4llDkULYZIzOYxlhxBqYPB0RsRlmMU0R6tahtFe27mIdkHV+551ZWV4PLmVw==
-  dependencies:
-    "@types/node" "*"
-
-"@types/launchpad@^0.6.0":
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/@types/launchpad/-/launchpad-0.6.0.tgz#37296109b7f277f6e6c5fd7e0c0706bc918fbb51"
-  integrity sha1-NylhCbfyd/bmxf1+DAcGvJGPu1E=
-
 "@types/long@^4.0.0":
   version "4.0.1"
-  resolved "https://registry.npmjs.org/@types/long/-/long-4.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.1.tgz#459c65fa1867dafe6a8f322c4c51695663cc55e9"
   integrity sha512-5tXH6Bx/kNGd3MgffdmP4dy2Z+G4eaXw0SE81Tq3BNadtnMR5/ySMzX4SLEzHJzSmPNn4HIdpQsBvXMUykr58w==
 
-"@types/merge-stream@^1.0.28":
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/@types/merge-stream/-/merge-stream-1.1.2.tgz#a880ff66b1fbbb5eef4958d015c5947a9334dbb1"
-  integrity sha512-7faLmaE99g/yX0Y9pF1neh2IUqOf/fXMOWCVzsXjqI1EJ91lrgXmaBKf6bRWM164lLyiHxHt6t/ZO/cIzq61XA==
-  dependencies:
-    "@types/node" "*"
-
-"@types/mime@^1":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
-  integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
-
-"@types/mime@^2.0.0":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
-  integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q==
-
-"@types/minimatch@*", "@types/minimatch@^3.0.1", "@types/minimatch@^3.0.3":
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.4.tgz#f0ec25dbf2f0e4b18647313ac031134ca5b24b21"
-  integrity sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==
+"@types/minimatch@3.0.3":
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d"
+  integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==
 
 "@types/minimist@^1.2.0":
-  version "1.2.1"
-  resolved "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.1.tgz"
-  integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg==
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
+  integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
 
-"@types/mz@0.0.29":
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.29.tgz#bc24728c649973f1c7851e9033f9ce525668c27b"
-  integrity sha1-vCRyjGSZc/HHhR6QM/nOUlZowns=
-  dependencies:
-    "@types/bluebird" "*"
-    "@types/node" "*"
-
-"@types/mz@0.0.31", "@types/mz@^0.0.31":
-  version "0.0.31"
-  resolved "https://registry.yarnpkg.com/@types/mz/-/mz-0.0.31.tgz#a4d80c082fefe71e40a7c0f07d1e6555bbbc7b52"
-  integrity sha1-pNgMCC/v5x5Ap8DwfR5lVbu8e1I=
-  dependencies:
-    "@types/node" "*"
-
-"@types/node@*", "@types/node@>= 8":
-  version "14.14.37"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.37.tgz#a3dd8da4eb84a996c36e331df98d82abd76b516e"
-  integrity sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
+"@types/node@*":
+  version "16.9.6"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-16.9.6.tgz#040a64d7faf9e5d9e940357125f0963012e66f04"
+  integrity sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==
 
 "@types/node@^10.1.0":
-  version "10.17.56"
-  resolved "https://registry.npmjs.org/@types/node/-/node-10.17.56.tgz"
-  integrity sha512-LuAa6t1t0Bfw4CuSR0UITsm1hP17YL+u82kfHGrHUWdhlBtH7sa7jGY5z7glGaIj/WDYDkRtgGd+KCjCzxBW1w==
-
-"@types/node@^4.0.30":
-  version "4.9.5"
-  resolved "https://registry.yarnpkg.com/@types/node/-/node-4.9.5.tgz#a3785db96b07a4b56466cc99fd624838746f2e25"
-  integrity sha512-+8fpgbXsbATKRF2ayAlYhPl2E9MPdLjrnK/79ZEpyPJ+k7dZwJm9YM8FK+l4rqL//xHk7PgQhGwz6aA2ckxbCQ==
+  version "10.17.60"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.60.tgz#35f3d6213daed95da7f0f73e75bcc6980e90597b"
+  integrity sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==
 
 "@types/normalize-package-data@^2.4.0":
-  version "2.4.0"
-  resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz"
-  integrity sha512-f5j5b/Gf71L+dbqxIpQ4Z2WlmI/mPJ0fOkGGmFgtb6sAu97EPczzbS3/tJKxmcYDj55OX6ssqwDAWOHIYDRDGA==
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
+  integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
 
-"@types/opn@^3.0.28":
-  version "3.0.28"
-  resolved "https://registry.yarnpkg.com/@types/opn/-/opn-3.0.28.tgz#097d0d1c9b5749573a5d96df132387bb6d02118a"
-  integrity sha1-CX0NHJtXSVc6XZbfEyOHu20CEYo=
+"@typescript-eslint/eslint-plugin@^4.2.0", "@typescript-eslint/eslint-plugin@^4.29.0":
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.30.0.tgz#4a0c1ae96b953f4e67435e20248d812bfa55e4fb"
+  integrity sha512-NgAnqk55RQ/SD+tZFD9aPwNSeHmDHHe5rtUyhIq0ZeCWZEvo4DK9rYz7v9HDuQZFvn320Ot+AikaCKMFKLlD0g==
   dependencies:
-    "@types/node" "*"
-
-"@types/parse5@^2.2.34":
-  version "2.2.34"
-  resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-2.2.34.tgz#e3870a10e82735a720f62d71dcd183ba78ef3a9d"
-  integrity sha1-44cKEOgnNacg9i1x3NGDunjvOp0=
-  dependencies:
-    "@types/node" "*"
-
-"@types/path-is-inside@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@types/path-is-inside/-/path-is-inside-1.0.0.tgz#02d6ff38975d684bdec96204494baf9f29f0e17f"
-  integrity sha512-hfnXRGugz+McgX2jxyy5qz9sB21LRzlGn24zlwN2KEgoPtEvjzNRrLtUkOOebPDPZl3Rq7ywKxYvylVcEZDnEw==
-
-"@types/pem@^1.8.1":
-  version "1.9.5"
-  resolved "https://registry.yarnpkg.com/@types/pem/-/pem-1.9.5.tgz#cd5548b5e0acb4b41a9e21067e9fcd8c57089c99"
-  integrity sha512-C0txxEw8B7DCoD85Ko7SEvzUogNd5VDJ5/YBG8XUcacsOGqxr5Oo4g3OUAfdEDUbhXanwUoVh/ZkMFw77FGPQQ==
-  dependencies:
-    "@types/node" "*"
-
-"@types/qs@*":
-  version "6.9.6"
-  resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.6.tgz#df9c3c8b31a247ec315e6996566be3171df4b3b1"
-  integrity sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==
-
-"@types/range-parser@*":
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c"
-  integrity sha512-ewFXqrQHlFsgc09MK5jP5iR7vumV/BYayNC6PgJO2LPe8vrnNFyjQjSppfEngITi0qvfKtzFvgKymGheFM9UOA==
-
-"@types/relateurl@*":
-  version "0.2.28"
-  resolved "https://registry.yarnpkg.com/@types/relateurl/-/relateurl-0.2.28.tgz#6bda7db8653fa62643f5ee69e9f69c11a392e3a6"
-  integrity sha1-a9p9uGU/piZD9e5p6facEaOS46Y=
-
-"@types/request@2.0.3":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/request/-/request-2.0.3.tgz#bdf0fba9488c822f77e97de3dd8fe357b2fb8c06"
-  integrity sha512-cIvnyFRARxwE4OHpCyYue7H+SxaKFPpeleRCHJicft8QhyTNbVYsMwjvEzEPqG06D2LGHZ+sN5lXc8+bTu6D8A==
-  dependencies:
-    "@types/form-data" "*"
-    "@types/node" "*"
-
-"@types/resolve@0.0.4":
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.4.tgz#9b586d65a947dea88c4bc24da0b905fe9520a0d5"
-  integrity sha1-m1htZalH3qiMS8JNoLkF/pUgoNU=
-  dependencies:
-    "@types/node" "*"
-
-"@types/resolve@0.0.6":
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.6.tgz#0bd2f236c2e1cebb98b79885df57edd71a8d770e"
-  integrity sha512-g+Rg8uMWY76oYTyaL+m7ZcblqF/oj7pE6uEUyACluJx4zcop1Lk14qQiocdEkEVMDFm6DmKpxJhsER+ZuTwG3g==
-  dependencies:
-    "@types/node" "*"
-
-"@types/resolve@0.0.7":
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-0.0.7.tgz#b299c13be8d712b1b502fb14a084252acef84f4d"
-  integrity sha512-GPewdjkb0Q76o459qgp6pBLzJj/bD3oveS2kfLhIkZ9U3t3AFKtl5DlFB6lGTw0iZmcmxoGC8lpLW3NNJKrN9A==
-  dependencies:
-    "@types/node" "*"
-
-"@types/responselike@*", "@types/responselike@^1.0.0":
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"
-  integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==
-  dependencies:
-    "@types/node" "*"
-
-"@types/rimraf@^0.0.28":
-  version "0.0.28"
-  resolved "https://registry.yarnpkg.com/@types/rimraf/-/rimraf-0.0.28.tgz#5562519bc7963caca8abf7f128cae3b594d41d06"
-  integrity sha1-VWJRm8eWPKyoq/fxKMrjtZTUHQY=
-
-"@types/rx-core-binding@*":
-  version "4.0.4"
-  resolved "https://registry.yarnpkg.com/@types/rx-core-binding/-/rx-core-binding-4.0.4.tgz#d969d32f15a62b89e2862c17b3ee78fe329818d3"
-  integrity sha512-5pkfxnC4w810LqBPUwP5bg7SFR/USwhMSaAeZQQbEHeBp57pjKXRlXmqpMrLJB4y1oglR/c2502853uN0I+DAQ==
-  dependencies:
-    "@types/rx-core" "*"
-
-"@types/rx-core@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-core/-/rx-core-4.0.3.tgz#0b3354b1238cedbe2b74f6326f139dbc7a591d60"
-  integrity sha1-CzNUsSOM7b4rdPYybxOdvHpZHWA=
-
-"@types/rx-lite-aggregates@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-aggregates/-/rx-lite-aggregates-4.0.3.tgz#6efb2b7f3d5f07183a1cb2bd4b1371d7073384c2"
-  integrity sha512-MAGDAHy8cRatm94FDduhJF+iNS5//jrZ/PIfm+QYw9OCeDgbymFHChM8YVIvN2zArwsRftKgE33QfRWvQk4DPg==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-async@*":
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-async/-/rx-lite-async-4.0.2.tgz#27fbf0caeff029f41e2d2aae638b05e91ceb600c"
-  integrity sha512-vTEv5o8l6702ZwfAM5aOeVDfUwBSDOs+ARoGmWAKQ6LOInQ8J4/zjM7ov12fuTpktUKdMQjkeCp07Vd73mPkxw==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-backpressure@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-backpressure/-/rx-lite-backpressure-4.0.3.tgz#05abb19bdf87cc740196c355e5d0b37bb50b5d56"
-  integrity sha512-Y6aIeQCtNban5XSAF4B8dffhIKu6aAy/TXFlScHzSxh6ivfQBQw6UjxyEJxIOt3IT49YkS+siuayM2H/Q0cmgA==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-coincidence@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-coincidence/-/rx-lite-coincidence-4.0.3.tgz#80bd69acc4054a15cdc1638e2dc8843498cd85c0"
-  integrity sha512-1VNJqzE9gALUyMGypDXZZXzR0Tt7LC9DdAZQ3Ou/Q0MubNU35agVUNXKGHKpNTba+fr8GdIdkC26bRDqtCQBeQ==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-experimental@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-experimental/-/rx-lite-experimental-4.0.1.tgz#c532f5cbdf3f2c15da16ded8930d1b2984023cbd"
-  integrity sha1-xTL1y98/LBXaFt7Ykw0bKYQCPL0=
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-joinpatterns@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-joinpatterns/-/rx-lite-joinpatterns-4.0.1.tgz#f70fe370518a8432f29158cc92ffb56b4e4afc3e"
-  integrity sha1-9w/jcFGKhDLykVjMkv+1a05K/D4=
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-testing@*":
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-testing/-/rx-lite-testing-4.0.1.tgz#21b19d11f4dfd6ffef5a9d1648e9c8879bfe21e9"
-  integrity sha1-IbGdEfTf1v/vWp0WSOnIh5v+Iek=
-  dependencies:
-    "@types/rx-lite-virtualtime" "*"
-
-"@types/rx-lite-time@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-time/-/rx-lite-time-4.0.3.tgz#0eda65474570237598f3448b845d2696f2dbb1c4"
-  integrity sha512-ukO5sPKDRwCGWRZRqPlaAU0SKVxmWwSjiOrLhoQDoWxZWg6vyB9XLEZViKOzIO6LnTIQBlk4UylYV0rnhJLxQw==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite-virtualtime@*":
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite-virtualtime/-/rx-lite-virtualtime-4.0.3.tgz#4b30cacd0fe2e53af29f04f7438584c7d3959537"
-  integrity sha512-3uC6sGmjpOKatZSVHI2xB1+dedgml669ZRvqxy+WqmGJDVusOdyxcKfyzjW0P3/GrCiN4nmRkLVMhPwHCc5QLg==
-  dependencies:
-    "@types/rx-lite" "*"
-
-"@types/rx-lite@*":
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/@types/rx-lite/-/rx-lite-4.0.6.tgz#3c02921c4244074234f26b772241bcc20c18c253"
-  integrity sha512-oYiDrFIcor9zDm0VDUca1UbROiMYBxMLMaM6qzz4ADAfOmA9r1dYEcAFH+2fsPI5BCCjPvV9pWC3X3flbrvs7w==
-  dependencies:
-    "@types/rx-core" "*"
-    "@types/rx-core-binding" "*"
-
-"@types/rx@*":
-  version "4.1.2"
-  resolved "https://registry.yarnpkg.com/@types/rx/-/rx-4.1.2.tgz#a4061b3d72b03cf11a38d69e2022a17334c54dc0"
-  integrity sha512-1r8ZaT26Nigq7o4UBGl+aXB2UMFUIdLPP/8bLIP0x3d0pZL46ybKKjhWKaJQWIkLl5QCLD0nK3qTOO1QkwdFaA==
-  dependencies:
-    "@types/rx-core" "*"
-    "@types/rx-core-binding" "*"
-    "@types/rx-lite" "*"
-    "@types/rx-lite-aggregates" "*"
-    "@types/rx-lite-async" "*"
-    "@types/rx-lite-backpressure" "*"
-    "@types/rx-lite-coincidence" "*"
-    "@types/rx-lite-experimental" "*"
-    "@types/rx-lite-joinpatterns" "*"
-    "@types/rx-lite-testing" "*"
-    "@types/rx-lite-time" "*"
-    "@types/rx-lite-virtualtime" "*"
-
-"@types/semver@^5.3.30":
-  version "5.5.0"
-  resolved "https://registry.yarnpkg.com/@types/semver/-/semver-5.5.0.tgz#146c2a29ee7d3bae4bf2fcb274636e264c813c45"
-  integrity sha512-41qEJgBH/TWgo5NFSvBCJ1qkoi3Q6ONSF2avrHq1LVEZfYpdHmj0y9SuTK+u9ZhG1sYQKBL1AWXKyLWP4RaUoQ==
-
-"@types/serve-static@*", "@types/serve-static@^1.7.31":
-  version "1.13.9"
-  resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.9.tgz#aacf28a85a05ee29a11fb7c3ead935ac56f33e4e"
-  integrity sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==
-  dependencies:
-    "@types/mime" "^1"
-    "@types/node" "*"
-
-"@types/spdy@^3.4.1":
-  version "3.4.4"
-  resolved "https://registry.yarnpkg.com/@types/spdy/-/spdy-3.4.4.tgz#3282fd4ad8c4603aa49f7017dd520a08a345b2bc"
-  integrity sha512-N9LBlbVRRYq6HgYpPkqQc3a9HJ/iEtVZToW6xlTtJiMhmRJ7jJdV7TaZQJw/Ve/1ePUsQiCTDc4JMuzzag94GA==
-  dependencies:
-    "@types/node" "*"
-
-"@types/temp@^0.8.28":
-  version "0.8.34"
-  resolved "https://registry.yarnpkg.com/@types/temp/-/temp-0.8.34.tgz#03e4b3cb67cbb48c425bbf54b12230fef85540ac"
-  integrity sha512-oLa9c5LHXgS6UimpEVp08De7QvZ+Dfu5bMQuWyMhf92Z26Q10ubEMOWy9OEfUdzW7Y/sDWVHmUaLFtmnX/2j0w==
-  dependencies:
-    "@types/node" "*"
-
-"@types/through@*":
-  version "0.0.30"
-  resolved "https://registry.yarnpkg.com/@types/through/-/through-0.0.30.tgz#e0e42ce77e897bd6aead6f6ea62aeb135b8a3895"
-  integrity sha512-FvnCJljyxhPM3gkRgWmxmDZyAQSiBQQWLI0A0VFL0K7W1oRUrPJSqNO0NvTnLkBcotdlp3lKvaT0JrnyRDkzOg==
-  dependencies:
-    "@types/node" "*"
-
-"@types/ua-parser-js@^0.7.31":
-  version "0.7.35"
-  resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.35.tgz#cca67a95deb9165e4b1f449471801e6489d3fe93"
-  integrity sha512-PsPx0RLbo2Un8+ff2buzYJnZjzwhD3jQHPOG2PtVIeOhkRDddMcKU8vJtHpzzfLB95dkUi0qAkfLg2l2Fd0yrQ==
-
-"@types/uglify-js@*":
-  version "3.13.0"
-  resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.13.0.tgz#1cad8df1fb0b143c5aba08de5712ea9d1ff71124"
-  integrity sha512-EGkrJD5Uy+Pg0NUR8uA4bJ5WMfljyad0G+784vLCNUkD+QwOJXUbBYExXfVGf7YtyzdQp3L/XMYcliB987kL5Q==
-  dependencies:
-    source-map "^0.6.1"
-
-"@types/update-notifier@^1.0.0":
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/@types/update-notifier/-/update-notifier-1.0.3.tgz#3c7ee1921af6f16149cdcaef356baf57d7a0b806"
-  integrity sha512-BLStNhP2DFF7funARwTcoD6tetRte8NK3Sc59mn7GNALCN975jOlKX3dGvsFxXr/HwQMxxCuRn9IWB3WQ7odHQ==
-
-"@types/uuid@^3.4.3":
-  version "3.4.9"
-  resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-3.4.9.tgz#fcf01997bbc9f7c09ae5f91383af076d466594e1"
-  integrity sha512-XDwyIlt/47l2kWLTzw/mtrpLdB+GPSskR2n/PIcPn+VYhVO77rGhRncIR5GPU0KRzXuqkDO+J5qqrG0Y8P6jzQ==
-
-"@types/vinyl-fs@0.0.28":
-  version "0.0.28"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-0.0.28.tgz#4663017bc802c6570eae4f3409fd5cabf97cbfde"
-  integrity sha1-RmMBe8gCxlcOrk80Cf1cq/l8v94=
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl-fs@^2.4.8":
-  version "2.4.11"
-  resolved "https://registry.yarnpkg.com/@types/vinyl-fs/-/vinyl-fs-2.4.11.tgz#b98119b8bb2494141eaf649b09fbfeb311161206"
-  integrity sha512-2OzQSfIr9CqqWMGqmcERE6Hnd2KY3eBVtFaulVo3sJghplUcaeMdL9ZjEiljcQQeHjheWY9RlNmumjIAvsBNaA==
-  dependencies:
-    "@types/glob-stream" "*"
-    "@types/node" "*"
-    "@types/vinyl" "*"
-
-"@types/vinyl@*", "@types/vinyl@^2.0.0":
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/@types/vinyl/-/vinyl-2.0.4.tgz#9a7a8071c8d14d3a95d41ebe7135babe4ad5995a"
-  integrity sha512-2o6a2ixaVI2EbwBPg1QYLGQoHK56p/8X/sGfKbFC8N6sY9lfjsMf/GprtkQkSya0D4uRiutRZ2BWj7k3JvLsAQ==
-  dependencies:
-    "@types/expect" "^1.20.4"
-    "@types/node" "*"
-
-"@types/whatwg-url@^6.4.0":
-  version "6.4.0"
-  resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-6.4.0.tgz#1e59b8c64bc0dbdf66d037cf8449d1c3d5270237"
-  integrity sha512-tonhlcbQ2eho09am6RHnHOgvtDfDYINd5rgxD+2YSkKENooVCFsWizJz139MQW/PV8FfClyKrNe9ZbdHrSCxGg==
-  dependencies:
-    "@types/node" "*"
-
-"@types/which@^1.3.1":
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/@types/which/-/which-1.3.2.tgz#9c246fc0c93ded311c8512df2891fb41f6227fdf"
-  integrity sha512-8oDqyLC7eD4HM307boe2QWKyuzdzWBj56xI/imSl2cpL+U3tCMaTAkMJ4ee5JBZ/FsOJlvRGeIShiZDAl1qERA==
-
-"@types/yeoman-generator@^2.0.3":
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/@types/yeoman-generator/-/yeoman-generator-2.0.3.tgz#f4b161ee354078b526e0901a5a5f87d4f8e085f6"
-  integrity sha512-vch2UFd6k7DdfWEv/alRwZIRXQoxZNUDpfLOK24+005dzE1HVnwSWfETF3WxJnWlsOcH87wU4uzldAE/7F/6Lw==
-  dependencies:
-    "@types/events" "*"
-    "@types/inquirer" "*"
-
-"@typescript-eslint/eslint-plugin@^4.2.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.21.0.tgz"
-  integrity sha512-FPUyCPKZbVGexmbCFI3EQHzCZdy2/5f+jv6k2EDljGdXSRc0cKvbndd2nHZkSLqCNOPk0jB6lGzwIkglXcYVsQ==
-  dependencies:
-    "@typescript-eslint/experimental-utils" "4.21.0"
-    "@typescript-eslint/scope-manager" "4.21.0"
-    debug "^4.1.1"
+    "@typescript-eslint/experimental-utils" "4.30.0"
+    "@typescript-eslint/scope-manager" "4.30.0"
+    debug "^4.3.1"
     functional-red-black-tree "^1.0.1"
-    lodash "^4.17.15"
-    regexpp "^3.0.0"
-    semver "^7.3.2"
-    tsutils "^3.17.1"
+    regexpp "^3.1.0"
+    semver "^7.3.5"
+    tsutils "^3.21.0"
 
-"@typescript-eslint/eslint-plugin@^4.22.0":
-  version "4.22.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.22.0.tgz#3d5f29bb59e61a9dba1513d491b059e536e16dbc"
-  integrity sha512-U8SP9VOs275iDXaL08Ln1Fa/wLXfj5aTr/1c0t0j6CdbOnxh+TruXu1p4I0NAvdPBQgoPjHsgKn28mOi0FzfoA==
+"@typescript-eslint/experimental-utils@4.30.0":
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.30.0.tgz#9e49704fef568432ae16fc0d6685c13d67db0fd5"
+  integrity sha512-K8RNIX9GnBsv5v4TjtwkKtqMSzYpjqAQg/oSphtxf3xxdt6T0owqnpojztjjTcatSteH3hLj3t/kklKx87NPqw==
   dependencies:
-    "@typescript-eslint/experimental-utils" "4.22.0"
-    "@typescript-eslint/scope-manager" "4.22.0"
-    debug "^4.1.1"
-    functional-red-black-tree "^1.0.1"
-    lodash "^4.17.15"
-    regexpp "^3.0.0"
-    semver "^7.3.2"
-    tsutils "^3.17.1"
-
-"@typescript-eslint/experimental-utils@4.21.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/experimental-utils/-/experimental-utils-4.21.0.tgz"
-  integrity sha512-cEbgosW/tUFvKmkg3cU7LBoZhvUs+ZPVM9alb25XvR0dal4qHL3SiUqHNrzoWSxaXA9gsifrYrS1xdDV6w/gIA==
-  dependencies:
-    "@types/json-schema" "^7.0.3"
-    "@typescript-eslint/scope-manager" "4.21.0"
-    "@typescript-eslint/types" "4.21.0"
-    "@typescript-eslint/typescript-estree" "4.21.0"
-    eslint-scope "^5.0.0"
-    eslint-utils "^2.0.0"
-
-"@typescript-eslint/experimental-utils@4.22.0":
-  version "4.22.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-4.22.0.tgz#68765167cca531178e7b650a53456e6e0bef3b1f"
-  integrity sha512-xJXHHl6TuAxB5AWiVrGhvbGL8/hbiCQ8FiWwObO3r0fnvBdrbWEDy1hlvGQOAWc6qsCWuWMKdVWlLAEMpxnddg==
-  dependencies:
-    "@types/json-schema" "^7.0.3"
-    "@typescript-eslint/scope-manager" "4.22.0"
-    "@typescript-eslint/types" "4.22.0"
-    "@typescript-eslint/typescript-estree" "4.22.0"
-    eslint-scope "^5.0.0"
-    eslint-utils "^2.0.0"
+    "@types/json-schema" "^7.0.7"
+    "@typescript-eslint/scope-manager" "4.30.0"
+    "@typescript-eslint/types" "4.30.0"
+    "@typescript-eslint/typescript-estree" "4.30.0"
+    eslint-scope "^5.1.1"
+    eslint-utils "^3.0.0"
 
 "@typescript-eslint/parser@^4.2.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.21.0.tgz"
-  integrity sha512-eyNf7QmE5O/l1smaQgN0Lj2M/1jOuNg2NrBm1dqqQN0sVngTLyw8tdCbih96ixlhbF1oINoN8fDCyEH9SjLeIA==
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.30.0.tgz#6abd720f66bd790f3e0e80c3be77180c8fcb192d"
+  integrity sha512-HJ0XuluSZSxeboLU7Q2VQ6eLlCwXPBOGnA7CqgBnz2Db3JRQYyBDJgQnop6TZ+rsbSx5gEdWhw4rE4mDa1FnZg==
   dependencies:
-    "@typescript-eslint/scope-manager" "4.21.0"
-    "@typescript-eslint/types" "4.21.0"
-    "@typescript-eslint/typescript-estree" "4.21.0"
-    debug "^4.1.1"
+    "@typescript-eslint/scope-manager" "4.30.0"
+    "@typescript-eslint/types" "4.30.0"
+    "@typescript-eslint/typescript-estree" "4.30.0"
+    debug "^4.3.1"
 
-"@typescript-eslint/scope-manager@4.21.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-4.21.0.tgz"
-  integrity sha512-kfOjF0w1Ix7+a5T1knOw00f7uAP9Gx44+OEsNQi0PvvTPLYeXJlsCJ4tYnDj5PQEYfpcgOH5yBlw7K+UEI9Agw==
+"@typescript-eslint/scope-manager@4.30.0":
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.30.0.tgz#1a3ffbb385b1a06be85cd5165a22324f069a85ee"
+  integrity sha512-VJ/jAXovxNh7rIXCQbYhkyV2Y3Ac/0cVHP/FruTJSAUUm4Oacmn/nkN5zfWmWFEanN4ggP0vJSHOeajtHq3f8A==
   dependencies:
-    "@typescript-eslint/types" "4.21.0"
-    "@typescript-eslint/visitor-keys" "4.21.0"
+    "@typescript-eslint/types" "4.30.0"
+    "@typescript-eslint/visitor-keys" "4.30.0"
 
-"@typescript-eslint/scope-manager@4.22.0":
-  version "4.22.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.22.0.tgz#ed411545e61161a8d702e703a4b7d96ec065b09a"
-  integrity sha512-OcCO7LTdk6ukawUM40wo61WdeoA7NM/zaoq1/2cs13M7GyiF+T4rxuA4xM+6LeHWjWbss7hkGXjFDRcKD4O04Q==
+"@typescript-eslint/types@4.30.0":
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.30.0.tgz#fb9d9b0358426f18687fba82eb0b0f869780204f"
+  integrity sha512-YKldqbNU9K4WpTNwBqtAerQKLLW/X2A/j4yw92e3ZJYLx+BpKLeheyzoPfzIXHfM8BXfoleTdiYwpsvVPvHrDw==
+
+"@typescript-eslint/typescript-estree@4.30.0":
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.30.0.tgz#ae57833da72a753f4846cd3053758c771670c2ac"
+  integrity sha512-6WN7UFYvykr/U0Qgy4kz48iGPWILvYL34xXJxvDQeiRE018B7POspNRVtAZscWntEPZpFCx4hcz/XBT+erenfg==
   dependencies:
-    "@typescript-eslint/types" "4.22.0"
-    "@typescript-eslint/visitor-keys" "4.22.0"
-
-"@typescript-eslint/types@4.21.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-4.21.0.tgz"
-  integrity sha512-+OQaupjGVVc8iXbt6M1oZMwyKQNehAfLYJJ3SdvnofK2qcjfor9pEM62rVjBknhowTkh+2HF+/KdRAc/wGBN2w==
-
-"@typescript-eslint/types@4.22.0":
-  version "4.22.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.22.0.tgz#0ca6fde5b68daf6dba133f30959cc0688c8dd0b6"
-  integrity sha512-sW/BiXmmyMqDPO2kpOhSy2Py5w6KvRRsKZnV0c4+0nr4GIcedJwXAq+RHNK4lLVEZAJYFltnnk1tJSlbeS9lYA==
-
-"@typescript-eslint/typescript-estree@4.21.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-4.21.0.tgz"
-  integrity sha512-ZD3M7yLaVGVYLw4nkkoGKumb7Rog7QID9YOWobFDMQKNl+vPxqVIW/uDk+MDeGc+OHcoG2nJ2HphwiPNajKw3w==
-  dependencies:
-    "@typescript-eslint/types" "4.21.0"
-    "@typescript-eslint/visitor-keys" "4.21.0"
-    debug "^4.1.1"
-    globby "^11.0.1"
+    "@typescript-eslint/types" "4.30.0"
+    "@typescript-eslint/visitor-keys" "4.30.0"
+    debug "^4.3.1"
+    globby "^11.0.3"
     is-glob "^4.0.1"
-    semver "^7.3.2"
-    tsutils "^3.17.1"
+    semver "^7.3.5"
+    tsutils "^3.21.0"
 
-"@typescript-eslint/typescript-estree@4.22.0":
-  version "4.22.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.22.0.tgz#b5d95d6d366ff3b72f5168c75775a3e46250d05c"
-  integrity sha512-TkIFeu5JEeSs5ze/4NID+PIcVjgoU3cUQUIZnH3Sb1cEn1lBo7StSV5bwPuJQuoxKXlzAObjYTilOEKRuhR5yg==
+"@typescript-eslint/visitor-keys@4.30.0":
+  version "4.30.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.30.0.tgz#a47c6272fc71b0c627d1691f68eaecf4ad71445e"
+  integrity sha512-pNaaxDt/Ol/+JZwzP7MqWc8PJQTUhZwoee/PVlQ+iYoYhagccvoHnC9e4l+C/krQYYkENxznhVSDwClIbZVxRw==
   dependencies:
-    "@typescript-eslint/types" "4.22.0"
-    "@typescript-eslint/visitor-keys" "4.22.0"
-    debug "^4.1.1"
-    globby "^11.0.1"
-    is-glob "^4.0.1"
-    semver "^7.3.2"
-    tsutils "^3.17.1"
-
-"@typescript-eslint/visitor-keys@4.21.0":
-  version "4.21.0"
-  resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-4.21.0.tgz"
-  integrity sha512-dH22dROWGi5Z6p+Igc8bLVLmwy7vEe8r+8c+raPQU0LxgogPUrRAtRGtvBWmlr9waTu3n+QLt/qrS/hWzk1x5w==
-  dependencies:
-    "@typescript-eslint/types" "4.21.0"
+    "@typescript-eslint/types" "4.30.0"
     eslint-visitor-keys "^2.0.0"
 
-"@typescript-eslint/visitor-keys@4.22.0":
-  version "4.22.0"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.22.0.tgz#169dae26d3c122935da7528c839f42a8a42f6e47"
-  integrity sha512-nnMu4F+s4o0sll6cBSsTeVsT4cwxB7zECK3dFxzEjPBii9xLpq4yqqsy/FU5zMfan6G60DKZSCXAa3sHJZrcYw==
-  dependencies:
-    "@typescript-eslint/types" "4.22.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"
-  integrity sha512-eLH04VBMpuZGzBIhOnUjECcQPEPcmfhWEijW9u1B5I+2PPYdWf3vWUExdDxu4Y3GljRSTCOlWnGtS9tpzmXMyQ==
-
-JSONStream@^1.2.1, JSONStream@^1.3.5:
-  version "1.3.5"
-  resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
-  integrity sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==
-  dependencies:
-    jsonparse "^1.2.0"
-    through ">=2.2.7 <3"
-
-accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
-  version "1.3.7"
-  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
-  integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA==
-  dependencies:
-    mime-types "~2.1.24"
-    negotiator "0.6.2"
-
-accessibility-developer-tools@^2.12.0:
-  version "2.12.0"
-  resolved "https://registry.yarnpkg.com/accessibility-developer-tools/-/accessibility-developer-tools-2.12.0.tgz#3da0cce9d6ec6373964b84f35db7cfc3df7ab514"
-  integrity sha1-PaDM6dbsY3OWS4TzXbfPw996tRQ=
-
-acorn-jsx@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-3.0.1.tgz#afdf9488fb1ecefc8348f6fb22f464e32a58b36b"
-  integrity sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=
-  dependencies:
-    acorn "^3.0.4"
-
 acorn-jsx@^5.3.1:
-  version "5.3.1"
-  resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz"
-  integrity sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
+  integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-acorn@^3.0.4:
-  version "3.3.0"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a"
-  integrity sha1-ReN/s56No/JbruP/U2niu18iAXo=
-
-acorn@^5.5.0:
-  version "5.7.4"
-  resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
-  integrity sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==
-
-acorn@^7.1.0, acorn@^7.4.0:
+acorn@^7.4.0:
   version "7.4.1"
-  resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz"
+  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.16"
-  resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.4.16.tgz#cf4c508fdffab02c269cbc7f471a875f05570365"
-  integrity sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==
-
-after@0.8.2:
-  version "0.8.2"
-  resolved "https://registry.yarnpkg.com/after/-/after-0.8.2.tgz#fedb394f9f0e02aa9768e702bda23b505fae7e1f"
-  integrity sha1-/ts5T58OAqqXaOcCvaI7UF+ufh8=
-
-agent-base@6:
-  version "6.0.2"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
-  integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
-  dependencies:
-    debug "4"
-
-agent-base@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee"
-  integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg==
-  dependencies:
-    es6-promisify "^5.0.0"
-
-ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4:
+ajv@^6.10.0, ajv@^6.12.4:
   version "6.12.6"
-  resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"
+  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"
@@ -1644,164 +334,71 @@
     uri-js "^4.2.2"
 
 ajv@^8.0.1:
-  version "8.0.5"
-  resolved "https://registry.npmjs.org/ajv/-/ajv-8.0.5.tgz"
-  integrity sha512-RkiLa/AeJx7+9OvniQ/qeWu0w74A8DiPPBclQ6ji3ZQkv5KamO+QGpqmi7O4JIw3rHGUXZ6CoP9tsAkn3gyazg==
+  version "8.6.2"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.6.2.tgz#2fb45e0e5fcbc0813326c1c3da535d1881bb0571"
+  integrity sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==
   dependencies:
     fast-deep-equal "^3.1.1"
     json-schema-traverse "^1.0.0"
     require-from-string "^2.0.2"
     uri-js "^4.2.2"
 
-ansi-align@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba"
-  integrity sha1-LwwWWIKXOa3V67FeawxuNCPwFro=
-  dependencies:
-    string-width "^1.0.1"
-
-ansi-align@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-2.0.0.tgz#c36aeccba563b89ceb556f3690f0b1d9e3547f7f"
-  integrity sha1-w2rsy6VjuJzrVW82kPCx2eNUf38=
-  dependencies:
-    string-width "^2.0.0"
-
 ansi-align@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.0.tgz#b536b371cf687caaef236c18d3e21fe3797467cb"
   integrity sha512-ZpClVKqXN3RGBmKibdfWzqCY4lnjEuoNzU5T0oEFpfd/z5qJHVarukridD4juLO2FXMiwUQxr9WqQtaYa8XRYw==
   dependencies:
     string-width "^3.0.0"
 
 ansi-colors@^4.1.1:
   version "4.1.1"
-  resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz"
+  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"
-  integrity sha1-06ioOzGapneTZisT52HHkRQiMG4=
-
 ansi-escapes@^4.2.1:
   version "4.3.2"
-  resolved "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz"
+  resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e"
   integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==
   dependencies:
     type-fest "^0.21.3"
 
-ansi-regex@^2.0.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-2.1.1.tgz#c3b33ab5ee360d86e0e628f0468ae7ef27d654df"
-  integrity sha1-w7M6te42DYbg5ijwRorn7yfWVN8=
-
-ansi-regex@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998"
-  integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=
-
 ansi-regex@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997"
   integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==
 
 ansi-regex@^5.0.0:
   version "5.0.0"
-  resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
   integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
 
-ansi-styles@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe"
-  integrity sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=
-
 ansi-styles@^3.2.1:
   version "3.2.1"
-  resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz"
+  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, ansi-styles@^4.1.0:
   version "4.3.0"
-  resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"
+  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@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-1.0.0.tgz#cb102df1c56f5123eab8b67cd7b98027a0279178"
-  integrity sha1-yxAt8cVvUSPquLZ817mAJ6AnkXg=
-
-any-promise@^1.0.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
-  integrity sha1-q8av7tzqUugJzcA3au0845Y10X8=
-
-anymatch@^1.3.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-1.3.2.tgz#553dcb8f91e3c889845dfdba34c77721b90b9d7a"
-  integrity sha512-0XNayC8lTHQ2OI8aljNCN3sSx6hsr/1+rlcDAotXJR7C1oZZHCNsfpbKwMjRA3Uqb5tF1Rae2oloTr4xpq+WjA==
-  dependencies:
-    micromatch "^2.1.5"
-    normalize-path "^2.0.0"
-
-append-field@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56"
-  integrity sha1-HjRA6RXwsSA9I3SOeO3XubW0PlY=
-
-archiver-utils@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-2.1.0.tgz#e8a460e94b693c3e3da182a098ca6285ba9249e2"
-  integrity sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==
-  dependencies:
-    glob "^7.1.4"
-    graceful-fs "^4.2.0"
-    lazystream "^1.0.0"
-    lodash.defaults "^4.2.0"
-    lodash.difference "^4.5.0"
-    lodash.flatten "^4.4.0"
-    lodash.isplainobject "^4.0.6"
-    lodash.union "^4.6.0"
-    normalize-path "^3.0.0"
-    readable-stream "^2.0.0"
-
-archiver@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/archiver/-/archiver-3.1.1.tgz#9db7819d4daf60aec10fe86b16cb9258ced66ea0"
-  integrity sha512-5Hxxcig7gw5Jod/8Gq0OneVgLYET+oNHcxgWItq4TbhOzRLKNAFUb9edAftiMKXvXfCB0vbGrJdZDNq0dWMsxg==
-  dependencies:
-    archiver-utils "^2.1.0"
-    async "^2.6.3"
-    buffer-crc32 "^0.2.1"
-    glob "^7.1.4"
-    readable-stream "^3.4.0"
-    tar-stream "^2.1.0"
-    zip-stream "^2.1.2"
-
 argparse@^1.0.7:
   version "1.0.10"
-  resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
   integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==
   dependencies:
     sprintf-js "~1.0.2"
 
-arr-diff@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-2.0.0.tgz#8f3b827f955a8bd669697e4a4256ac3ceae356cf"
-  integrity sha1-jzuCf5Vai9ZpaX5KQlasPOrjVs8=
-  dependencies:
-    arr-flatten "^1.0.1"
-
 arr-diff@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
   integrity sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=
 
-arr-flatten@^1.0.1, arr-flatten@^1.1.0:
+arr-flatten@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/arr-flatten/-/arr-flatten-1.1.0.tgz#36048bbff4e7b47e136644316c99669ea5ae91f1"
   integrity sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==
@@ -1811,41 +408,9 @@
   resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
   integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
 
-array-back@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022"
-  integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw==
-  dependencies:
-    typical "^2.6.1"
-
-array-back@^3.0.1:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/array-back/-/array-back-3.1.0.tgz#b8859d7a508871c9a7b2cf42f99428f65e96bfb0"
-  integrity sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q==
-
-array-differ@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-1.0.0.tgz#eff52e3758249d33be402b8bb8e564bb2b5d4031"
-  integrity sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=
-
-array-differ@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/array-differ/-/array-differ-3.0.0.tgz#3cbb3d0f316810eafcc47624734237d6aee4ae6b"
-  integrity sha512-THtfYS6KtME/yIAhKjZ2ul7XI96lQGHRputJQHO80LAWQnuGP4iCIN8vdMRboGbIEYBwU33q8Tch1os2+X0kMg==
-
-array-find-index@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-find-index/-/array-find-index-1.0.2.tgz#df010aa1287e164bbda6f9723b0a96a1ec4187a1"
-  integrity sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=
-
-array-flatten@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
-  integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=
-
-array-includes@^3.1.1:
+array-includes@^3.1.3:
   version "3.1.3"
-  resolved "https://registry.npmjs.org/array-includes/-/array-includes-3.1.3.tgz"
+  resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.3.tgz#c7f619b382ad2afaf5326cddfdc0afc61af7690a"
   integrity sha512-gcem1KlBU7c9rB+Rq8/3PPKsK2kjqeEBa3bD5kkQo4nYlOHQCJqIJFqBXDEfwaRuYTT4E+FxA9xez7Gf/e3Q7A==
   dependencies:
     call-bind "^1.0.2"
@@ -1854,69 +419,30 @@
     get-intrinsic "^1.1.1"
     is-string "^1.0.5"
 
-array-union@^1.0.1, array-union@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/array-union/-/array-union-1.0.2.tgz#9a34410e4f4e3da23dea375be5be70f24778ec39"
-  integrity sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=
-  dependencies:
-    array-uniq "^1.0.1"
-
 array-union@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz"
+  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"
-  integrity sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=
-
-array-unique@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.2.1.tgz#a1d97ccafcbc2625cc70fadceb36a50c58b01a53"
-  integrity sha1-odl8yvy8JiXMcPrc6zalDFiwGlM=
-
 array-unique@^0.3.2:
   version "0.3.2"
   resolved "https://registry.yarnpkg.com/array-unique/-/array-unique-0.3.2.tgz#a894b75d4bc4f6cd679ef3244a9fd8f46ae2d428"
   integrity sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=
 
-array.prototype.flat@^1.2.3:
+array.prototype.flat@^1.2.4:
   version "1.2.4"
-  resolved "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.4.tgz"
+  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.18.0-next.1"
 
-arraybuffer.slice@~0.0.7:
-  version "0.0.7"
-  resolved "https://registry.yarnpkg.com/arraybuffer.slice/-/arraybuffer.slice-0.0.7.tgz#3bbc4275dd584cc1b10809b89d4e8b63a69e7675"
-  integrity sha512-wGUIVQXuehL5TCqQun8OW81jGzAWycqzFF8lFp+GOM5BXLYj3bKNsYC4daB7n6XjCqxQA/qgTJ+8ANR3acjrog==
-
-arrify@^1.0.0, arrify@^1.0.1:
+arrify@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz"
+  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"
-  integrity sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==
-  dependencies:
-    safer-buffer "~2.1.0"
-
-assert-plus@1.0.0, assert-plus@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
-  integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
-
 assign-symbols@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/assign-symbols/-/assign-symbols-1.0.0.tgz#59667f41fadd4f20ccbc2bb96b8d4f7f78ec0367"
@@ -1924,377 +450,19 @@
 
 astral-regex@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz"
+  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"
-  resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf"
-  integrity sha512-z/WhQ5FPySLdvREByI2vZiTWwCnF0moMJ1hK9YQwDTHKh6I7/uSckMetoRGb5UBZPC1z0jlw+n/XCgjeH7y1AQ==
-
-async@0.9.x:
-  version "0.9.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
-  integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
-
-async@^2.0.0, async@^2.0.1, async@^2.1.2, async@^2.4.1, async@^2.6.0, async@^2.6.2, async@^2.6.3:
-  version "2.6.3"
-  resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
-  integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
-  dependencies:
-    lodash "^4.17.14"
-
-async@^3.1.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
-  integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
-
-async@~0.2.9:
-  version "0.2.10"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.2.10.tgz#b6bbe0b0674b9d719708ca38de8c237cb526c3d1"
-  integrity sha1-trvgsGdLnXGXCMo43owjfLUmw9E=
-
-asynckit@^0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
-  integrity sha1-x57Zf380y48robyXkLzDZkdLS3k=
-
-atob-lite@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/atob-lite/-/atob-lite-2.0.0.tgz#0fef5ad46f1bd7a8502c65727f0367d5ee43d696"
-  integrity sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY=
-
 atob@^2.1.2:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
   integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
 
-aws-sign2@~0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
-  integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=
-
-aws4@^1.8.0:
-  version "1.11.0"
-  resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
-  integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
-
-axios@^0.21.1:
-  version "0.21.1"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
-  integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
-  dependencies:
-    follow-redirects "^1.10.0"
-
-babel-code-frame@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
-  integrity sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=
-  dependencies:
-    chalk "^1.1.3"
-    esutils "^2.0.2"
-    js-tokens "^3.0.2"
-
-babel-generator@^6.26.1:
-  version "6.26.1"
-  resolved "https://registry.yarnpkg.com/babel-generator/-/babel-generator-6.26.1.tgz#1844408d3b8f0d35a404ea7ac180f087a601bd90"
-  integrity sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==
-  dependencies:
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    detect-indent "^4.0.0"
-    jsesc "^1.3.0"
-    lodash "^4.17.4"
-    source-map "^0.5.7"
-    trim-right "^1.0.1"
-
-babel-helper-evaluate-path@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-evaluate-path/-/babel-helper-evaluate-path-0.5.0.tgz#a62fa9c4e64ff7ea5cea9353174ef023a900a67c"
-  integrity sha512-mUh0UhS607bGh5wUMAQfOpt2JX2ThXMtppHRdRU1kL7ZLRWIXxoV2UIV1r2cAeeNeU1M5SB5/RSUgUxrK8yOkA==
-
-babel-helper-flip-expressions@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-flip-expressions/-/babel-helper-flip-expressions-0.4.3.tgz#3696736a128ac18bc25254b5f40a22ceb3c1d3fd"
-  integrity sha1-NpZzahKKwYvCUlS19AoizrPB0/0=
-
-babel-helper-is-nodes-equiv@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-nodes-equiv/-/babel-helper-is-nodes-equiv-0.0.1.tgz#34e9b300b1479ddd98ec77ea0bbe9342dfe39684"
-  integrity sha1-NOmzALFHnd2Y7HfqC76TQt/jloQ=
-
-babel-helper-is-void-0@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-is-void-0/-/babel-helper-is-void-0-0.4.3.tgz#7d9c01b4561e7b95dbda0f6eee48f5b60e67313e"
-  integrity sha1-fZwBtFYee5Xb2g9u7kj1tg5nMT4=
-
-babel-helper-mark-eval-scopes@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-mark-eval-scopes/-/babel-helper-mark-eval-scopes-0.4.3.tgz#d244a3bef9844872603ffb46e22ce8acdf551562"
-  integrity sha1-0kSjvvmESHJgP/tG4izorN9VFWI=
-
-babel-helper-remove-or-void@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-helper-remove-or-void/-/babel-helper-remove-or-void-0.4.3.tgz#a4f03b40077a0ffe88e45d07010dee241ff5ae60"
-  integrity sha1-pPA7QAd6D/6I5F0HAQ3uJB/1rmA=
-
-babel-helper-to-multiple-sequence-expressions@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-helper-to-multiple-sequence-expressions/-/babel-helper-to-multiple-sequence-expressions-0.5.0.tgz#a3f924e3561882d42fcf48907aa98f7979a4588d"
-  integrity sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==
-
-babel-messages@^6.23.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/babel-messages/-/babel-messages-6.23.0.tgz#f3cdf4703858035b2a2951c6ec5edf6c62f2630e"
-  integrity sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=
-  dependencies:
-    babel-runtime "^6.22.0"
-
-babel-plugin-dynamic-import-node@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3"
-  integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==
-  dependencies:
-    object.assign "^4.1.0"
-
-babel-plugin-minify-builtins@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-builtins/-/babel-plugin-minify-builtins-0.5.0.tgz#31eb82ed1a0d0efdc31312f93b6e4741ce82c36b"
-  integrity sha512-wpqbN7Ov5hsNwGdzuzvFcjgRlzbIeVv1gMIlICbPj0xkexnfoIDe7q+AZHMkQmAE/F9R5jkrB6TLfTegImlXag==
-
-babel-plugin-minify-constant-folding@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-constant-folding/-/babel-plugin-minify-constant-folding-0.5.0.tgz#f84bc8dbf6a561e5e350ff95ae216b0ad5515b6e"
-  integrity sha512-Vj97CTn/lE9hR1D+jKUeHfNy+m1baNiJ1wJvoGyOBUx7F7kJqDZxr9nCHjO/Ad+irbR3HzR6jABpSSA29QsrXQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-minify-dead-code-elimination@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-dead-code-elimination/-/babel-plugin-minify-dead-code-elimination-0.5.1.tgz#1a0c68e44be30de4976ca69ffc535e08be13683f"
-  integrity sha512-x8OJOZIrRmQBcSqxBcLbMIK8uPmTvNWPXH2bh5MDCW1latEqYiRMuUkPImKcfpo59pTUB2FT7HfcgtG8ZlR5Qg==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-mark-eval-scopes "^0.4.3"
-    babel-helper-remove-or-void "^0.4.3"
-    lodash "^4.17.11"
-
-babel-plugin-minify-flip-comparisons@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-flip-comparisons/-/babel-plugin-minify-flip-comparisons-0.4.3.tgz#00ca870cb8f13b45c038b3c1ebc0f227293c965a"
-  integrity sha1-AMqHDLjxO0XAOLPB68DyJyk8llo=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-minify-guarded-expressions@^0.4.3, babel-plugin-minify-guarded-expressions@^0.4.4:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-guarded-expressions/-/babel-plugin-minify-guarded-expressions-0.4.4.tgz#818960f64cc08aee9d6c75bec6da974c4d621135"
-  integrity sha512-RMv0tM72YuPPfLT9QLr3ix9nwUIq+sHT6z8Iu3sLbqldzC1Dls8DPCywzUIzkTx9Zh1hWX4q/m9BPoPed9GOfA==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-
-babel-plugin-minify-infinity@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-infinity/-/babel-plugin-minify-infinity-0.4.3.tgz#dfb876a1b08a06576384ef3f92e653ba607b39ca"
-  integrity sha1-37h2obCKBldjhO8/kuZTumB7Oco=
-
-babel-plugin-minify-mangle-names@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-mangle-names/-/babel-plugin-minify-mangle-names-0.5.0.tgz#bcddb507c91d2c99e138bd6b17a19c3c271e3fd3"
-  integrity sha512-3jdNv6hCAw6fsX1p2wBGPfWuK69sfOjfd3zjUXkbq8McbohWy23tpXfy5RnToYWggvqzuMOwlId1PhyHOfgnGw==
-  dependencies:
-    babel-helper-mark-eval-scopes "^0.4.3"
-
-babel-plugin-minify-numeric-literals@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-numeric-literals/-/babel-plugin-minify-numeric-literals-0.4.3.tgz#8e4fd561c79f7801286ff60e8c5fd9deee93c0bc"
-  integrity sha1-jk/VYcefeAEob/YOjF/Z3u6TwLw=
-
-babel-plugin-minify-replace@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-replace/-/babel-plugin-minify-replace-0.5.0.tgz#d3e2c9946c9096c070efc96761ce288ec5c3f71c"
-  integrity sha512-aXZiaqWDNUbyNNNpWs/8NyST+oU7QTpK7J9zFEFSA0eOmtUNMU3fczlTTTlnCxHmq/jYNFEmkkSG3DDBtW3Y4Q==
-
-babel-plugin-minify-simplify@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-simplify/-/babel-plugin-minify-simplify-0.5.1.tgz#f21613c8b95af3450a2ca71502fdbd91793c8d6a"
-  integrity sha512-OSYDSnoCxP2cYDMk9gxNAed6uJDiDz65zgL6h8d3tm8qXIagWGMLWhqysT6DY3Vs7Fgq7YUDcjOomhVUb+xX6A==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-    babel-helper-flip-expressions "^0.4.3"
-    babel-helper-is-nodes-equiv "^0.0.1"
-    babel-helper-to-multiple-sequence-expressions "^0.5.0"
-
-babel-plugin-minify-type-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-minify-type-constructors/-/babel-plugin-minify-type-constructors-0.4.3.tgz#1bc6f15b87f7ab1085d42b330b717657a2156500"
-  integrity sha1-G8bxW4f3qxCF1CszC3F2V6IVZQA=
-  dependencies:
-    babel-helper-is-void-0 "^0.4.3"
-
-babel-plugin-transform-inline-consecutive-adds@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-inline-consecutive-adds/-/babel-plugin-transform-inline-consecutive-adds-0.4.3.tgz#323d47a3ea63a83a7ac3c811ae8e6941faf2b0d1"
-  integrity sha1-Mj1Ho+pjqDp6w8gRro5pQfrysNE=
-
-babel-plugin-transform-member-expression-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-member-expression-literals/-/babel-plugin-transform-member-expression-literals-6.9.4.tgz#37039c9a0c3313a39495faac2ff3a6b5b9d038bf"
-  integrity sha1-NwOcmgwzE6OUlfqsL/OmtbnQOL8=
-
-babel-plugin-transform-merge-sibling-variables@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-merge-sibling-variables/-/babel-plugin-transform-merge-sibling-variables-6.9.4.tgz#85b422fc3377b449c9d1cde44087203532401dae"
-  integrity sha1-hbQi/DN3tEnJ0c3kQIcgNTJAHa4=
-
-babel-plugin-transform-minify-booleans@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-minify-booleans/-/babel-plugin-transform-minify-booleans-6.9.4.tgz#acbb3e56a3555dd23928e4b582d285162dd2b198"
-  integrity sha1-rLs+VqNVXdI5KOS1gtKFFi3SsZg=
-
-babel-plugin-transform-property-literals@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-property-literals/-/babel-plugin-transform-property-literals-6.9.4.tgz#98c1d21e255736573f93ece54459f6ce24985d39"
-  integrity sha1-mMHSHiVXNlc/k+zlRFn2ziSYXTk=
-  dependencies:
-    esutils "^2.0.2"
-
-babel-plugin-transform-regexp-constructors@^0.4.3:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-regexp-constructors/-/babel-plugin-transform-regexp-constructors-0.4.3.tgz#58b7775b63afcf33328fae9a5f88fbd4fb0b4965"
-  integrity sha1-WLd3W2OvzzMyj66aX4j71PsLSWU=
-
-babel-plugin-transform-remove-console@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz#b980360c067384e24b357a588d807d3c83527780"
-  integrity sha1-uYA2DAZzhOJLNXpYjYB9PINSd4A=
-
-babel-plugin-transform-remove-debugger@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-debugger/-/babel-plugin-transform-remove-debugger-6.9.4.tgz#42b727631c97978e1eb2d199a7aec84a18339ef2"
-  integrity sha1-QrcnYxyXl44estGZp67IShgznvI=
-
-babel-plugin-transform-remove-undefined@^0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-remove-undefined/-/babel-plugin-transform-remove-undefined-0.5.0.tgz#80208b31225766c630c97fa2d288952056ea22dd"
-  integrity sha512-+M7fJYFaEE/M9CXa0/IRkDbiV3wRELzA1kKQFCJ4ifhrzLKn/9VCCgj9OFmYWwBd8IB48YdgPkHYtbYq+4vtHQ==
-  dependencies:
-    babel-helper-evaluate-path "^0.5.0"
-
-babel-plugin-transform-simplify-comparison-operators@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-simplify-comparison-operators/-/babel-plugin-transform-simplify-comparison-operators-6.9.4.tgz#f62afe096cab0e1f68a2d753fdf283888471ceb9"
-  integrity sha1-9ir+CWyrDh9ootdT/fKDiIRxzrk=
-
-babel-plugin-transform-undefined-to-void@^6.9.4:
-  version "6.9.4"
-  resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280"
-  integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA=
-
-babel-preset-minify@^0.5.0:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/babel-preset-minify/-/babel-preset-minify-0.5.1.tgz#25f5d0bce36ec818be80338d0e594106e21eaa9f"
-  integrity sha512-1IajDumYOAPYImkHbrKeiN5AKKP9iOmRoO2IPbIuVp0j2iuCcj0n7P260z38siKMZZ+85d3mJZdtW8IgOv+Tzg==
-  dependencies:
-    babel-plugin-minify-builtins "^0.5.0"
-    babel-plugin-minify-constant-folding "^0.5.0"
-    babel-plugin-minify-dead-code-elimination "^0.5.1"
-    babel-plugin-minify-flip-comparisons "^0.4.3"
-    babel-plugin-minify-guarded-expressions "^0.4.4"
-    babel-plugin-minify-infinity "^0.4.3"
-    babel-plugin-minify-mangle-names "^0.5.0"
-    babel-plugin-minify-numeric-literals "^0.4.3"
-    babel-plugin-minify-replace "^0.5.0"
-    babel-plugin-minify-simplify "^0.5.1"
-    babel-plugin-minify-type-constructors "^0.4.3"
-    babel-plugin-transform-inline-consecutive-adds "^0.4.3"
-    babel-plugin-transform-member-expression-literals "^6.9.4"
-    babel-plugin-transform-merge-sibling-variables "^6.9.4"
-    babel-plugin-transform-minify-booleans "^6.9.4"
-    babel-plugin-transform-property-literals "^6.9.4"
-    babel-plugin-transform-regexp-constructors "^0.4.3"
-    babel-plugin-transform-remove-console "^6.9.4"
-    babel-plugin-transform-remove-debugger "^6.9.4"
-    babel-plugin-transform-remove-undefined "^0.5.0"
-    babel-plugin-transform-simplify-comparison-operators "^6.9.4"
-    babel-plugin-transform-undefined-to-void "^6.9.4"
-    lodash "^4.17.11"
-
-babel-runtime@^6.22.0, babel-runtime@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.26.0.tgz#965c7058668e82b55d7bfe04ff2337bc8b5647fe"
-  integrity sha1-llxwWGaOgrVde/4E/yM3vItWR/4=
-  dependencies:
-    core-js "^2.4.0"
-    regenerator-runtime "^0.11.0"
-
-babel-traverse@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-traverse/-/babel-traverse-6.26.0.tgz#46a9cbd7edcc62c8e5c064e2d2d8d0f4035766ee"
-  integrity sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=
-  dependencies:
-    babel-code-frame "^6.26.0"
-    babel-messages "^6.23.0"
-    babel-runtime "^6.26.0"
-    babel-types "^6.26.0"
-    babylon "^6.18.0"
-    debug "^2.6.8"
-    globals "^9.18.0"
-    invariant "^2.2.2"
-    lodash "^4.17.4"
-
-babel-types@^6.26.0:
-  version "6.26.0"
-  resolved "https://registry.yarnpkg.com/babel-types/-/babel-types-6.26.0.tgz#a3b073f94ab49eb6fa55cd65227a334380632497"
-  integrity sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=
-  dependencies:
-    babel-runtime "^6.26.0"
-    esutils "^2.0.2"
-    lodash "^4.17.4"
-    to-fast-properties "^1.0.3"
-
-babylon@^6.18.0:
-  version "6.18.0"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.18.0.tgz#af2f3b88fa6f5c1e4c634d1a0f8eac4f55b395e3"
-  integrity sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==
-
-babylon@^7.0.0-beta.42:
-  version "7.0.0-beta.47"
-  resolved "https://registry.yarnpkg.com/babylon/-/babylon-7.0.0-beta.47.tgz#6d1fa44f0abec41ab7c780481e62fd9aafbdea80"
-  integrity sha512-+rq2cr4GDhtToEzKFD6KZZMDBXhjFAr9JjPw9pAppZACeEWqNM294j+NdBzkSHYXwzzBmVjZ3nEVJlOhbR2gOQ==
-
-backo2@1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
-  integrity sha1-MasayLEpNjRj41s+u2n038+6eUc=
-
 balanced-match@^1.0.0:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
   integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
-base64-arraybuffer@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz#9818c79e059b1355f97e0428a017c838e90ba812"
-  integrity sha1-mBjHngWbE1X5fgQooBfIOOkLqBI=
-
-base64-js@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.2.0.tgz#a39992d723584811982be5e290bb6a53d86700f1"
-  integrity sha1-o5mS1yNYSBGYK+XikLtqU9hnAPE=
-
-base64-js@^1.3.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
-  integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
-
-base64id@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
-  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
-
 base@^0.11.1:
   version "0.11.2"
   resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
@@ -2308,138 +476,14 @@
     mixin-deep "^1.2.0"
     pascalcase "^0.1.1"
 
-bcrypt-pbkdf@^1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e"
-  integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
-  dependencies:
-    tweetnacl "^0.14.3"
-
-before-after-hook@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.2.1.tgz#73540563558687586b52ed217dad6a802ab1549c"
-  integrity sha512-/6FKxSTWoJdbsLDF8tdIjaRiFXiE6UHsEHE3OPI/cwPURCVi1ukP0gmLn7XWEiFk5TcwQjjY5PWsU+j+tgXgmw==
-
-binary-extensions@^1.0.0:
-  version "1.13.1"
-  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65"
-  integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==
-
-binaryextensions@^2.1.2:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.3.0.tgz#1d269cbf7e6243ea886aa41453c3651ccbe13c22"
-  integrity sha512-nAihlQsYGyc5Bwq6+EsubvANYGExeJKHDO3RjnvwU042fawQTQfM3Kxn7IHUXQOz4bzfwsGYYHGSvXyW4zOGLg==
-
-bindings@^1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df"
-  integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==
-  dependencies:
-    file-uri-to-path "1.0.0"
-
-bl@^1.0.0:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.3.tgz#1e8dd80142eac80d7158c9dccc047fb620e035e7"
-  integrity sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==
-  dependencies:
-    readable-stream "^2.3.5"
-    safe-buffer "^5.1.1"
-
-bl@^4.0.3:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a"
-  integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==
-  dependencies:
-    buffer "^5.5.0"
-    inherits "^2.0.4"
-    readable-stream "^3.4.0"
-
-blob@0.0.5:
-  version "0.0.5"
-  resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.5.tgz#d680eeef25f8cd91ad533f5b01eed48e64caf683"
-  integrity sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==
-
-body-parser@1.19.0, body-parser@^1.17.2:
-  version "1.19.0"
-  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
-  integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==
-  dependencies:
-    bytes "3.1.0"
-    content-type "~1.0.4"
-    debug "2.6.9"
-    depd "~1.1.2"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    on-finished "~2.3.0"
-    qs "6.7.0"
-    raw-body "2.4.0"
-    type-is "~1.6.17"
-
-bower-config@^1.4.0, bower-config@^1.4.1:
-  version "1.4.3"
-  resolved "https://registry.yarnpkg.com/bower-config/-/bower-config-1.4.3.tgz#3454fecdc5f08e7aa9cc6d556e492be0669689ae"
-  integrity sha512-MVyyUk3d1S7d2cl6YISViwJBc2VXCkxF5AUFykvN0PQj5FsUiMNSgAYTso18oRFfyZ6XEtjrgg9MAaufHbOwNw==
-  dependencies:
-    graceful-fs "^4.1.3"
-    minimist "^0.2.1"
-    mout "^1.0.0"
-    osenv "^0.1.3"
-    untildify "^2.1.0"
-    wordwrap "^0.0.3"
-
-bower-json@^0.8.1:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/bower-json/-/bower-json-0.8.4.tgz#9c3b375870dcd9581350c1f403f6383dbf6a18b1"
-  integrity sha512-mMKghvq9ivbuzSsY5nrOLnDtZIJMUCpysqbGaGW3mj88JAcuSi8ZAzIt34vNZjohy0aR9VXLwgPTZGnBX2Vpjg==
-  dependencies:
-    deep-extend "^0.5.1"
-    ends-with "^0.2.0"
-    ext-list "^2.0.0"
-    graceful-fs "^4.1.3"
-    intersect "^1.0.1"
-    sort-keys-length "^1.0.0"
-
-bower-logger@^0.2.2:
-  version "0.2.2"
-  resolved "https://registry.yarnpkg.com/bower-logger/-/bower-logger-0.2.2.tgz#39be07e979b2fc8e03a94634205ed9422373d381"
-  integrity sha1-Ob4H6Xmy/I4DqUY0IF7ZQiNz04E=
-
-bower@^1.8.8:
-  version "1.8.12"
-  resolved "https://registry.yarnpkg.com/bower/-/bower-1.8.12.tgz#44cfca2a5e04b8d9a066621e24c8b179d8ac321e"
-  integrity sha512-u1xy9SrwwoPlgjuHNjhV+YUPVdqyBj2ALBxuzeIUKXaPI2i2xypGgxqXkuHcITGdi5yBj5JuXgyMvgiWiS1S3Q==
-
-boxen@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-0.6.0.tgz#8364d4248ac34ff0ef1b2f2bf49a6c60ce0d81b6"
-  integrity sha1-g2TUJIrDT/DvGy8r9JpsYM4NgbY=
-  dependencies:
-    ansi-align "^1.1.0"
-    camelcase "^2.1.0"
-    chalk "^1.1.1"
-    cli-boxes "^1.0.0"
-    filled-array "^1.0.0"
-    object-assign "^4.0.1"
-    repeating "^2.0.0"
-    string-width "^1.0.1"
-    widest-line "^1.0.0"
-
-boxen@^1.2.1:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/boxen/-/boxen-1.3.0.tgz#55c6c39a8ba58d9c61ad22cd877532deb665a20b"
-  integrity sha512-TNPjfTr432qx7yOjQyaXm3dSR0MH9vXp7eT1BFSl/C51g+EFnOR9hTg1IreahGBmDNCehscshe45f+C1TBZbLw==
-  dependencies:
-    ansi-align "^2.0.0"
-    camelcase "^4.0.0"
-    chalk "^2.0.1"
-    cli-boxes "^1.0.0"
-    string-width "^2.0.0"
-    term-size "^1.2.0"
-    widest-line "^2.0.0"
+boolbase@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e"
+  integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
 
 boxen@^5.0.0:
   version "5.0.1"
-  resolved "https://registry.npmjs.org/boxen/-/boxen-5.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.0.1.tgz#657528bdd3f59a772b8279b831f27ec2c744664b"
   integrity sha512-49VBlw+PrWEF51aCmy7QIteYPIFZxSpvqBdP/2itCPPlJ49kj9zg/XPRFrdkne2W+CfwXUls8exMvu1RysZpKA==
   dependencies:
     ansi-align "^3.0.0"
@@ -2451,23 +495,14 @@
     widest-line "^3.1.0"
     wrap-ansi "^7.0.0"
 
-brace-expansion@^1.1.7:
+brace-expansion@^1.0.0, brace-expansion@^1.1.7:
   version "1.1.11"
-  resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
   integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
   dependencies:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
-braces@^1.8.2:
-  version "1.8.5"
-  resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7"
-  integrity sha1-uneWLhLf+WnWt2cR6RS3N4V79qc=
-  dependencies:
-    expand-range "^1.8.1"
-    preserve "^0.2.0"
-    repeat-element "^1.1.2"
-
 braces@^2.3.1:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-2.3.2.tgz#5979fd3f14cd531565e5fa2df1abfff1dfaee729"
@@ -2486,102 +521,15 @@
 
 braces@^3.0.1:
   version "3.0.2"
-  resolved "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz"
+  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"
-  integrity sha512-BezMQhbQklxjRQpZZQ8tnbzEo6AldUwMh8/PeWt5/CTBSwByQRXZEAK2fbnEahQ4poeeaI0suAYRq25A1YGOmw==
-  dependencies:
-    "@types/ua-parser-js" "^0.7.31"
-    ua-parser-js "^0.7.15"
-
-browserify-zlib@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.1.4.tgz#bb35f8a519f600e0fa6b8485241c979d0141fb2d"
-  integrity sha1-uzX4pRn2AOD6a4SFJByXnQFB+y0=
-  dependencies:
-    pako "~0.2.0"
-
-browserslist@^4.14.5:
-  version "4.16.3"
-  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717"
-  integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==
-  dependencies:
-    caniuse-lite "^1.0.30001181"
-    colorette "^1.2.1"
-    electron-to-chromium "^1.3.649"
-    escalade "^3.1.1"
-    node-releases "^1.1.70"
-
-browserstack@^1.2.0:
-  version "1.6.1"
-  resolved "https://registry.yarnpkg.com/browserstack/-/browserstack-1.6.1.tgz#e051f9733ec3b507659f395c7a4765a1b1e358b3"
-  integrity sha512-GxtFjpIaKdbAyzHfFDKixKO8IBT7wR3NjbzrGc78nNs/Ciys9wU3/nBtsqsWv5nDSrdI5tz0peKuzCPuNXNUiw==
-  dependencies:
-    https-proxy-agent "^2.2.1"
-
-btoa-lite@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
-  integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
-
-buffer-alloc-unsafe@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0"
-  integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==
-
-buffer-alloc@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec"
-  integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==
-  dependencies:
-    buffer-alloc-unsafe "^1.1.0"
-    buffer-fill "^1.0.0"
-
-buffer-crc32@^0.2.1, buffer-crc32@^0.2.13, buffer-crc32@~0.2.3:
-  version "0.2.13"
-  resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
-  integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
-
-buffer-fill@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c"
-  integrity sha1-+PeLdniYiO858gXNY39o5wISKyw=
-
 buffer-from@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz"
-  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
-
-buffer@^5.1.0, buffer@^5.5.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0"
-  integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==
-  dependencies:
-    base64-js "^1.3.1"
-    ieee754 "^1.1.13"
-
-busboy@^0.2.11:
-  version "0.2.14"
-  resolved "https://registry.yarnpkg.com/busboy/-/busboy-0.2.14.tgz#6c2a622efcf47c57bbbe1e2a9c37ad36c7925453"
-  integrity sha1-bCpiLvz0fFe7vh4qnDetNseSVFM=
-  dependencies:
-    dicer "0.2.5"
-    readable-stream "1.1.x"
-
-bytes@3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048"
-  integrity sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=
-
-bytes@3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6"
-  integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+  integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
 
 cache-base@^1.0.1:
   version "1.0.1"
@@ -2598,14 +546,9 @@
     union-value "^1.0.0"
     unset-value "^1.0.0"
 
-cacheable-lookup@^5.0.3:
-  version "5.0.4"
-  resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005"
-  integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==
-
 cacheable-request@^6.0.0:
   version "6.1.0"
-  resolved "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-6.1.0.tgz#20ffb8bd162ba4be11e9567d823db651052ca912"
   integrity sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==
   dependencies:
     clone-response "^1.0.2"
@@ -2616,22 +559,9 @@
     normalize-url "^4.1.0"
     responselike "^1.0.2"
 
-cacheable-request@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.1.tgz#062031c2856232782ed694a257fa35da93942a58"
-  integrity sha512-lt0mJ6YAnsrBErpTMWeu5kl/tg9xMAWjavYTN6VQXM1A/teBITuNcccXsCxF0tDQQJf9DfAaX5O4e0zp0KlfZw==
-  dependencies:
-    clone-response "^1.0.2"
-    get-stream "^5.1.0"
-    http-cache-semantics "^4.0.0"
-    keyv "^4.0.0"
-    lowercase-keys "^2.0.0"
-    normalize-url "^4.1.0"
-    responselike "^2.0.0"
-
 call-bind@^1.0.0, call-bind@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c"
   integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==
   dependencies:
     function-bind "^1.1.1"
@@ -2644,152 +574,65 @@
 
 callsites@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
   integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
 
-camel-case@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73"
-  integrity sha1-yjw2iKTpzzpM2nd9xNy8cTJJz3M=
-  dependencies:
-    no-case "^2.2.0"
-    upper-case "^1.1.1"
-
-camelcase-keys@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-2.1.0.tgz#308beeaffdf28119051efa1d932213c91b8f92e7"
-  integrity sha1-MIvur/3ygRkFHvodkyITyRuPkuc=
-  dependencies:
-    camelcase "^2.0.0"
-    map-obj "^1.0.0"
-
 camelcase-keys@^6.2.2:
   version "6.2.2"
-  resolved "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz"
+  resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0"
   integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==
   dependencies:
     camelcase "^5.3.1"
     map-obj "^4.0.0"
     quick-lru "^4.0.1"
 
-camelcase@^2.0.0, camelcase@^2.1.0:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f"
-  integrity sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=
-
-camelcase@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-4.1.0.tgz#d545635be1e33c542649c69173e5de6acfae34dd"
-  integrity sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=
-
-camelcase@^5.3.1:
+camelcase@^5.0.0, camelcase@^5.3.1:
   version "5.3.1"
-  resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320"
   integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==
 
 camelcase@^6.2.0:
   version "6.2.0"
-  resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809"
   integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg==
 
-cancel-token@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/cancel-token/-/cancel-token-0.1.1.tgz#c18197674bb1c84c1d6933ebf15d8d5a5ce79b4f"
-  integrity sha1-wYGXZ0uxyEwdaTPr8V2NWlznm08=
-  dependencies:
-    "@types/node" "^4.0.30"
-
-caniuse-lite@^1.0.30001181:
-  version "1.0.30001208"
-  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001208.tgz#a999014a35cebd4f98c405930a057a0d75352eb9"
-  integrity sha512-OE5UE4+nBOro8Dyvv0lfx+SRtfVIOM9uhKqFmJeUbGriqhhStgp1A0OyBpgy3OUF8AhYCT+PVwPC1gMl2ZcQMA==
-
-capture-stack-trace@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/capture-stack-trace/-/capture-stack-trace-1.0.1.tgz#a6c0bbe1f38f3aa0b92238ecb6ff42c344d4135d"
-  integrity sha512-mYQLZnx5Qt1JgB1WEiMCf2647plpGeQ2NMR/5L0HNZzGQo4fuSPnK+wjfPnKZV0aiJDgzmWqqkV/g7JD+DW0qw==
-
-caseless@~0.12.0:
-  version "0.12.0"
-  resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
-  integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
-
-chalk@*, chalk@^4.0.0, chalk@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz"
-  integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
-  dependencies:
-    ansi-styles "^4.1.0"
-    supports-color "^7.1.0"
-
-chalk@^1.0.0, chalk@^1.1.1, chalk@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
-  integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
-  dependencies:
-    ansi-styles "^2.2.1"
-    escape-string-regexp "^1.0.2"
-    has-ansi "^2.0.0"
-    strip-ansi "^3.0.0"
-    supports-color "^2.0.0"
-
-chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@^2.0.0, chalk@^2.4.2:
   version "2.4.2"
-  resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==
   dependencies:
     ansi-styles "^3.2.1"
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@~0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/chalk/-/chalk-0.4.0.tgz#5199a3ddcd0c1efe23bc08c1b027b06176e0c64f"
-  integrity sha1-UZmj3c0MHv4jvAjBsCewYXbgxk8=
+chalk@^4.0.0, chalk@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
   dependencies:
-    ansi-styles "~1.0.0"
-    has-color "~0.1.0"
-    strip-ansi "~0.1.0"
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
 
 chardet@^0.7.0:
   version "0.7.0"
-  resolved "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz"
+  resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
   integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
 
-charenc@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667"
-  integrity sha1-wKHS86cJLgN3S/qD8UwPxXkKhmc=
-
-chokidar@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468"
-  integrity sha1-eY5ol3gVHIB2tLNg5e3SjNortGg=
+cheerio@1.0.0-rc.2:
+  version "1.0.0-rc.2"
+  resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.2.tgz#4b9f53a81b27e4d5dac31c0ffd0cfa03cc6830db"
+  integrity sha1-S59TqBsn5NXawxwP/Qz6A8xoMNs=
   dependencies:
-    anymatch "^1.3.0"
-    async-each "^1.0.0"
-    glob-parent "^2.0.0"
-    inherits "^2.0.1"
-    is-binary-path "^1.0.0"
-    is-glob "^2.0.0"
-    path-is-absolute "^1.0.0"
-    readdirp "^2.0.0"
-  optionalDependencies:
-    fsevents "^1.0.0"
-
-chownr@^1.0.1:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
-  integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
-
-ci-info@^1.5.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-1.6.0.tgz#2ca20dbb9ceb32d4524a683303313f0304b1e497"
-  integrity sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==
+    css-select "~1.2.0"
+    dom-serializer "~0.1.0"
+    entities "~1.1.1"
+    htmlparser2 "^3.9.1"
+    lodash "^4.15.0"
+    parse5 "^3.0.1"
 
 ci-info@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
   integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==
 
 class-utils@^0.3.5:
@@ -2802,114 +645,39 @@
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-clean-css@4.2.x:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.3.tgz#507b5de7d97b48ee53d84adb0160ff6216380f78"
-  integrity sha512-VcMWDN54ZN/DS+g58HYL5/n4Zrqe8vHJpGA8KdgUXFU4fuP/aHNw8eld9SyEIyabIMJX/0RaY/fplOo5hYLSFA==
-  dependencies:
-    source-map "~0.6.0"
-
-cleankill@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/cleankill/-/cleankill-2.0.0.tgz#59830dfc8b411d53dc72ad09d45a78ea33161a91"
-  integrity sha1-WYMN/ItBHVPccq0J1Fp46jMWGpE=
-
-cli-boxes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-1.0.0.tgz#4fa917c3e59c94a004cd61f8ee509da651687143"
-  integrity sha1-T6kXw+WclKAEzWH47lCdplFocUM=
-
 cli-boxes@^2.2.1:
   version "2.2.1"
-  resolved "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz"
+  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"
-  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
-  integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
-  dependencies:
-    restore-cursor "^1.0.1"
-
 cli-cursor@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307"
   integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==
   dependencies:
     restore-cursor "^3.1.0"
 
-cli-table@^0.3.1:
-  version "0.3.6"
-  resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc"
-  integrity sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==
-  dependencies:
-    colors "1.0.3"
-
-cli-width@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
-  integrity sha512-GRMWDxpOB6Dgk2E5Uo+3eEBvtOOlimMmpbFiKuLFnQzYDavtLFY3K5ona41jgN/WdRZtG7utuVSVTL4HbZHGkw==
-
 cli-width@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz"
+  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"
-  integrity sha1-4+JbIHrE5wGvch4staFnksrD3Fg=
-
-clone-deep@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
-  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
+cliui@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1"
+  integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==
   dependencies:
-    is-plain-object "^2.0.4"
-    kind-of "^6.0.2"
-    shallow-clone "^3.0.0"
+    string-width "^4.2.0"
+    strip-ansi "^6.0.0"
+    wrap-ansi "^6.2.0"
 
 clone-response@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/clone-response/-/clone-response-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.2.tgz#d1dc973920314df67fbeb94223b4ee350239e96b"
   integrity sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=
   dependencies:
     mimic-response "^1.0.0"
 
-clone-stats@^0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-0.0.1.tgz#b88f94a82cf38b8791d58046ea4029ad88ca99d1"
-  integrity sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=
-
-clone-stats@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/clone-stats/-/clone-stats-1.0.0.tgz#b3782dff8bb5474e18b9b6bf0fdfe782f8777680"
-  integrity sha1-s3gt/4u1R04Yuba/D9/ngvh3doA=
-
-clone@^1.0.0:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e"
-  integrity sha1-2jCcwmPfFZlMaIypAheco8fNfH4=
-
-clone@^2.0.0, clone@^2.1.0, clone@^2.1.1:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
-  integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
-
-cloneable-readable@^1.0.0:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/cloneable-readable/-/cloneable-readable-1.1.3.tgz#120a00cb053bfb63a222e709f9683ea2e11d8cec"
-  integrity sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==
-  dependencies:
-    inherits "^2.0.1"
-    process-nextick-args "^2.0.0"
-    readable-stream "^2.3.5"
-
-code-point-at@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
-  integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
-
 collection-visit@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/collection-visit/-/collection-visit-1.0.0.tgz#4bc0373c164bc3291b4d368c829cf1a80a59dca0"
@@ -2918,223 +686,53 @@
     map-visit "^1.0.0"
     object-visit "^1.0.0"
 
-color-convert@^1.9.0, color-convert@^1.9.1:
+color-convert@^1.9.0:
   version "1.9.3"
-  resolved "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
   integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
   dependencies:
     color-name "1.1.3"
 
 color-convert@^2.0.1:
   version "2.0.1"
-  resolved "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
   integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
   dependencies:
     color-name "~1.1.4"
 
 color-name@1.1.3:
   version "1.1.3"
-  resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
   integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
 
-color-name@^1.0.0, color-name@~1.1.4:
+color-name@~1.1.4:
   version "1.1.4"
-  resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
   integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
 
-color-string@^1.5.2:
-  version "1.5.5"
-  resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.5.tgz#65474a8f0e7439625f3d27a6a19d89fc45223014"
-  integrity sha512-jgIoum0OfQfq9Whcfc2z/VhCNcmQjWbey6qBX0vqt7YICflUmBCh9E9CiQD5GSJ+Uehixm3NUwHVhqUAWRivZg==
-  dependencies:
-    color-name "^1.0.0"
-    simple-swizzle "^0.2.2"
-
-color@3.0.x:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/color/-/color-3.0.0.tgz#d920b4328d534a3ac8295d68f7bd4ba6c427be9a"
-  integrity sha512-jCpd5+s0s0t7p3pHQKpnJ0TpQKKdleP71LWcA0aqiljpiuAkOSUFN/dyH8ZwF0hRmFlrIuRhufds1QyEP9EB+w==
-  dependencies:
-    color-convert "^1.9.1"
-    color-string "^1.5.2"
-
-colorette@^1.2.1:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
-  integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
-
-colors@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.0.3.tgz#0433f44d809680fdeb60ed260f1b0c262e82a40b"
-  integrity sha1-BDP0TYCWgP3rYO0mDxsMJi6CpAs=
-
-colors@^1.2.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/colors/-/colors-1.4.0.tgz#c50491479d4c1bdaed2c9ced32cf7c7dc2360f78"
-  integrity sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==
-
-colorspace@1.1.x:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/colorspace/-/colorspace-1.1.2.tgz#e0128950d082b86a2168580796a0aa5d6c68d8c5"
-  integrity sha512-vt+OoIP2d76xLhjwbBaucYlNSpPsrJWPlBTtwCpQKIu6/CSMutyzX93O/Do0qzpH3YoHEes8YEFXyZ797rEhzQ==
-  dependencies:
-    color "3.0.x"
-    text-hex "1.0.x"
-
-combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6:
-  version "1.0.8"
-  resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
-  integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
-  dependencies:
-    delayed-stream "~1.0.0"
-
-command-line-args@^5.0.2:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-5.1.1.tgz#88e793e5bb3ceb30754a86863f0401ac92fd369a"
-  integrity sha512-hL/eG8lrll1Qy1ezvkant+trihbGnaKaeEjj6Scyr3DN+RC7iQ5Rz84IeLERfAWDGo0HBSNAakczwgCilDXnWg==
-  dependencies:
-    array-back "^3.0.1"
-    find-replace "^3.0.0"
-    lodash.camelcase "^4.3.0"
-    typical "^4.0.0"
-
-command-line-commands@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/command-line-commands/-/command-line-commands-2.0.1.tgz#c58aa13dc78c06038ed67077e57ad09a6f858f46"
-  integrity sha512-m8c2p1DrNd2ruIAggxd/y6DgygQayf6r8RHwchhXryaLF8I6koYjoYroVP+emeROE9DXN5b9sP1Gh+WtvTTdtQ==
-  dependencies:
-    array-back "^2.0.0"
-
-command-line-usage@^5.0.5:
-  version "5.0.5"
-  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-5.0.5.tgz#5f25933ffe6dedd983c635d38a21d7e623fda357"
-  integrity sha512-d8NrGylA5oCXSbGoKz05FkehDAzSmIm4K03S5VDh4d5lZAtTWfc3D1RuETtuQCn8129nYfJfDdF7P/lwcz1BlA==
-  dependencies:
-    array-back "^2.0.0"
-    chalk "^2.4.1"
-    table-layout "^0.4.3"
-    typical "^2.6.1"
-
-commander@2.17.x:
-  version "2.17.1"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf"
-  integrity sha512-wPMUt6FnH2yzG95SA6mzjQOEKUU3aLaDEmzs1ti+1E9h+CsrZghRlqEM/EJ4KscsQVG8uNN4uVreUeT8+drlgg==
-
-commander@^2.20.0, commander@^2.20.3:
+commander@^2.20.0:
   version "2.20.3"
   resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
   integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
 
-commander@~2.19.0:
-  version "2.19.0"
-  resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a"
-  integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg==
+comment-parser@1.1.5:
+  version "1.1.5"
+  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.1.5.tgz#453627ef8f67dbcec44e79a9bd5baa37f0bce9b2"
+  integrity sha512-RePCE4leIhBlmrqiYTvaqEeGYg7qpSl4etaIabKtdOQVi+mSTIBBklGUwIr79GXYnl3LpMwmDw4KeR2stNc6FA==
 
-comment-parser@1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.1.2.tgz#e5317d7a2ec22b470dcb54a29b25426c30bf39d8"
-  integrity sha512-AOdq0i8ghZudnYv8RUnHrhTgafUGs61Rdz9jemU5x2lnZwAWyOq7vySo626K59e1fVKH1xSRorJwPVRLSWOoAQ==
-
-commondir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
-  integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=
-
-component-bind@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1"
-  integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E=
-
-component-emitter@1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6"
-  integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=
-
-component-emitter@^1.2.1, component-emitter@~1.3.0:
+component-emitter@^1.2.1:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"
   integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg==
 
-component-inherit@0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143"
-  integrity sha1-ZF/ErfWLcrZJ1crmUTVhnbJv8UM=
-
-compress-commons@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-2.1.1.tgz#9410d9a534cf8435e3fbbb7c6ce48de2dc2f0610"
-  integrity sha512-eVw6n7CnEMFzc3duyFVrQEuY1BlHR3rYsSztyG32ibGMW722i3C6IizEGMFmfMU+A+fALvBIwxN3czffTcdA+Q==
-  dependencies:
-    buffer-crc32 "^0.2.13"
-    crc32-stream "^3.0.1"
-    normalize-path "^3.0.0"
-    readable-stream "^2.3.6"
-
-compressible@~2.0.16:
-  version "2.0.18"
-  resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
-  integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
-  dependencies:
-    mime-db ">= 1.43.0 < 2"
-
-compression@^1.6.2:
-  version "1.7.4"
-  resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f"
-  integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==
-  dependencies:
-    accepts "~1.3.5"
-    bytes "3.0.0"
-    compressible "~2.0.16"
-    debug "2.6.9"
-    on-headers "~1.0.2"
-    safe-buffer "5.1.2"
-    vary "~1.1.2"
-
 concat-map@0.0.1:
   version "0.0.1"
-  resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
   integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
 
-concat-stream@^1.4.7, concat-stream@^1.5.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
-  integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
-  dependencies:
-    buffer-from "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^2.2.2"
-    typedarray "^0.0.6"
-
-configstore@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-2.1.0.tgz#737a3a7036e9886102aa6099e47bb33ab1aba1a1"
-  integrity sha1-c3o6cDbpiGECqmCZ5HuzOrGroaE=
-  dependencies:
-    dot-prop "^3.0.0"
-    graceful-fs "^4.1.2"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.1"
-    os-tmpdir "^1.0.0"
-    osenv "^0.1.0"
-    uuid "^2.0.1"
-    write-file-atomic "^1.1.2"
-    xdg-basedir "^2.0.0"
-
-configstore@^3.0.0:
-  version "3.1.5"
-  resolved "https://registry.yarnpkg.com/configstore/-/configstore-3.1.5.tgz#e9af331fadc14dabd544d3e7e76dc446a09a530f"
-  integrity sha512-nlOhI4+fdzoK5xmJ+NY+1gZK56bwEaWZr8fYuXohZ9Vkc1o3a4T/R3M+yE/w7x/ZVJ1zF8c+oaOvF0dztdUgmA==
-  dependencies:
-    dot-prop "^4.2.1"
-    graceful-fs "^4.1.2"
-    make-dir "^1.0.0"
-    unique-string "^1.0.0"
-    write-file-atomic "^2.0.0"
-    xdg-basedir "^3.0.0"
-
 configstore@^5.0.1:
   version "5.0.1"
-  resolved "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96"
   integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==
   dependencies:
     dot-prop "^5.2.0"
@@ -3144,225 +742,72 @@
     write-file-atomic "^3.0.0"
     xdg-basedir "^4.0.0"
 
-contains-path@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.npmjs.org/contains-path/-/contains-path-0.1.0.tgz"
-  integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo=
-
-content-disposition@0.5.3:
-  version "0.5.3"
-  resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd"
-  integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g==
-  dependencies:
-    safe-buffer "5.1.2"
-
-content-type@^1.0.2, content-type@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
-  integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==
-
-convert-source-map@^1.1.1, convert-source-map@^1.7.0:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442"
-  integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA==
-  dependencies:
-    safe-buffer "~5.1.1"
-
-cookie-signature@1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
-  integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
-
-cookie@0.4.0:
-  version "0.4.0"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
-  integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==
-
-cookie@~0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.1.tgz#afd713fe26ebd21ba95ceb61f9a8116e50a537d1"
-  integrity sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==
-
 copy-descriptor@^0.1.0:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
   integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
 
-core-js@^2.4.0:
-  version "2.6.12"
-  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
-  integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
-
-core-util-is@1.0.2, core-util-is@~1.0.0:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
-  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
-
-cors@^2.8.4:
-  version "2.8.5"
-  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
-  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
-  dependencies:
-    object-assign "^4"
-    vary "^1"
-
-crc32-stream@^3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/crc32-stream/-/crc32-stream-3.0.1.tgz#cae6eeed003b0e44d739d279de5ae63b171b4e85"
-  integrity sha512-mctvpXlbzsvK+6z8kJwSJ5crm7yBwrQMTybJzMw1O4lLGJqjlDCXY2Zw7KheiA6XBEcBmfLx1D88mjRGVJtY9w==
-  dependencies:
-    crc "^3.4.4"
-    readable-stream "^3.4.0"
-
-crc@^3.4.4:
-  version "3.8.0"
-  resolved "https://registry.yarnpkg.com/crc/-/crc-3.8.0.tgz#ad60269c2c856f8c299e2c4cc0de4556914056c6"
-  integrity sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==
-  dependencies:
-    buffer "^5.1.0"
-
-create-error-class@^3.0.0, create-error-class@^3.0.1:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/create-error-class/-/create-error-class-3.0.2.tgz#06be7abef947a3f14a30fd610671d401bca8b7b6"
-  integrity sha1-Br56vvlHo/FKMP1hBnHUAbyot7Y=
-  dependencies:
-    capture-stack-trace "^1.0.0"
-
-cross-spawn@^5.0.1:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449"
-  integrity sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=
-  dependencies:
-    lru-cache "^4.0.1"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-cross-spawn@^6.0.0, cross-spawn@^6.0.5:
-  version "6.0.5"
-  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
-  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
-  dependencies:
-    nice-try "^1.0.4"
-    path-key "^2.0.1"
-    semver "^5.5.0"
-    shebang-command "^1.2.0"
-    which "^1.2.9"
-
-cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
+cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
-  resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
   integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
   dependencies:
     path-key "^3.1.0"
     shebang-command "^2.0.0"
     which "^2.0.1"
 
-crypt@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b"
-  integrity sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=
-
-crypto-random-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
-  integrity sha1-ojD2T1aDEOFJgAmUB5DsmVRbyn4=
-
 crypto-random-string@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5"
   integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==
 
-css-slam@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/css-slam/-/css-slam-2.1.2.tgz#3d35b1922cb3e0002a45c89ab189492508c493e5"
-  integrity sha512-cObrY+mhFEmepWpua6EpMrgRNTQ0eeym+kvR0lukI6hDEzK7F8himEDS4cJ9+fPHCoArTzVrrR0d+oAUbTR1NA==
+css-select@~1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858"
+  integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg=
   dependencies:
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    parse5 "^4.0.0"
-    shady-css-parser "^0.1.0"
+    boolbase "~1.0.0"
+    css-what "2.1"
+    domutils "1.5.1"
+    nth-check "~1.0.1"
 
-css-what@^2.1.0:
+css-what@2.1:
   version "2.1.3"
   resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2"
   integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==
 
-cssbeautify@^0.3.1:
-  version "0.3.1"
-  resolved "https://registry.yarnpkg.com/cssbeautify/-/cssbeautify-0.3.1.tgz#12dd1f734035c2e6faca67dcbdcef74e42811397"
-  integrity sha1-Et0fc0A1wub6ymfcvc73TkKBE5c=
-
-currently-unhandled@^0.4.1:
-  version "0.4.1"
-  resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
-  integrity sha1-mI3zP+qxke95mmE2nddsF635V+o=
-  dependencies:
-    array-find-index "^1.0.1"
-
-dargs@^6.0.0, dargs@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/dargs/-/dargs-6.1.0.tgz#1f3b9b56393ecf8caa7cbfd6c31496ffcfb9b272"
-  integrity sha512-5dVBvpBLBnPwSsYXqfybFyehMmC/EenKEcf23AhCTgTf48JFBbmJKqoZBsERDnjL0FyiVTYWdFsRfTLHxLyKdQ==
-
-dashdash@^1.12.0:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0"
-  integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=
-  dependencies:
-    assert-plus "^1.0.0"
-
-dateformat@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae"
-  integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q==
-
-debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, debug@^2.6.9:
+debug@^2.2.0, debug@^2.3.3, debug@^2.6.9:
   version "2.6.9"
-  resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
   integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
   dependencies:
     ms "2.0.0"
 
-debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1:
-  version "4.3.1"
-  resolved "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz"
-  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
-  dependencies:
-    ms "2.1.2"
-
-debug@^3.1.0:
+debug@^3.2.7:
   version "3.2.7"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a"
   integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==
   dependencies:
     ms "^2.1.1"
 
-debug@~3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
-  integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
+debug@^4.0.1, debug@^4.1.1, debug@^4.3.1:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b"
+  integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==
   dependencies:
-    ms "2.0.0"
-
-debug@~4.1.0:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791"
-  integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==
-  dependencies:
-    ms "^2.1.1"
+    ms "2.1.2"
 
 decamelize-keys@^1.1.0:
   version "1.1.0"
-  resolved "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9"
   integrity sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=
   dependencies:
     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.2.0:
   version "1.2.0"
-  resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290"
   integrity sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=
 
 decode-uri-component@^0.2.0:
@@ -3370,48 +815,31 @@
   resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545"
   integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=
 
-decompress-response@^3.2.0, decompress-response@^3.3.0:
+decompress-response@^3.3.0:
   version "3.3.0"
-  resolved "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-3.3.0.tgz#80a4dd323748384bfa248083622aedec982adff3"
   integrity sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=
   dependencies:
     mimic-response "^1.0.0"
 
-decompress-response@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc"
-  integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==
-  dependencies:
-    mimic-response "^3.1.0"
-
-deep-extend@^0.5.1:
-  version "0.5.1"
-  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f"
-  integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==
-
-deep-extend@^0.6.0, deep-extend@~0.6.0:
+deep-extend@^0.6.0:
   version "0.6.0"
-  resolved "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz"
+  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:
   version "0.1.3"
-  resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz"
+  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
   integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=
 
 defer-to-connect@^1.0.1:
   version "1.1.3"
-  resolved "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz"
+  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-1.1.3.tgz#331ae050c08dcf789f8c83a7b81f0ed94f4ac591"
   integrity sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==
 
-defer-to-connect@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587"
-  integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==
-
 define-properties@^1.1.3:
   version "1.1.3"
-  resolved "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz"
+  resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1"
   integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==
   dependencies:
     object-keys "^1.0.12"
@@ -3438,121 +866,23 @@
     is-descriptor "^1.0.2"
     isobject "^3.0.1"
 
-del@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/del/-/del-3.0.0.tgz#53ecf699ffcbcb39637691ab13baf160819766e5"
-  integrity sha1-U+z2mf/LyzljdpGrE7rxYIGXZuU=
+didyoumean2@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/didyoumean2/-/didyoumean2-4.1.0.tgz#f813cb7c82c249443e599be077f76e88f24b85e4"
+  integrity sha512-qTBmfQoXvhKO75D/05C8m+fteQmn4U46FWYiLhXtZQInzitXLWY0EQ/2oKnpAz9g2lQWW8jYcLcT+hPJGT+kig==
   dependencies:
-    globby "^6.1.0"
-    is-path-cwd "^1.0.0"
-    is-path-in-cwd "^1.0.0"
-    p-map "^1.1.1"
-    pify "^3.0.0"
-    rimraf "^2.2.8"
-
-delayed-stream@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
-  integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
-
-depd@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
-  integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak=
-
-deprecation@^2.0.0, deprecation@^2.3.1:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
-  integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
-
-destroy@~1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
-  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
-
-detect-conflict@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/detect-conflict/-/detect-conflict-1.0.1.tgz#088657a66a961c05019db7c4230883b1c6b4176e"
-  integrity sha1-CIZXpmqWHAUBnbfEIwiDsca0F24=
-
-detect-file@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-0.1.0.tgz#4935dedfd9488648e006b0129566e9386711ea63"
-  integrity sha1-STXe39lIhkjgBrASlWbpOGcR6mM=
-  dependencies:
-    fs-exists-sync "^0.1.0"
-
-detect-file@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
-  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
-
-detect-indent@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-4.0.0.tgz#f76d064352cdf43a1cb6ce619c4ee3a9475de208"
-  integrity sha1-920GQ1LN9Docts5hnE7jqUdd4gg=
-  dependencies:
-    repeating "^2.0.0"
-
-detect-node@^2.0.3:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.5.tgz#9d270aa7eaa5af0b72c4c9d9b814e7f4ce738b79"
-  integrity sha512-qi86tE6hRcFHy8jI1m2VG+LaPUR1LhqDa5G8tVjuUXmOrpuAgqsA1pN0+ldgr3aKUH+QLI9hCY/OcRYisERejw==
-
-dicer@0.2.5:
-  version "0.2.5"
-  resolved "https://registry.yarnpkg.com/dicer/-/dicer-0.2.5.tgz#5996c086bb33218c812c090bddc09cd12facb70f"
-  integrity sha1-WZbAhrszIYyBLAkL3cCc0S+stw8=
-  dependencies:
-    readable-stream "1.1.x"
-    streamsearch "0.1.2"
-
-diff@^2.1.2:
-  version "2.2.3"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-2.2.3.tgz#60eafd0d28ee906e4e8ff0a52c1229521033bf99"
-  integrity sha1-YOr9DSjukG5Oj/ClLBIpUhAzv5k=
-
-diff@^3.1.0, diff@^3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
-  integrity sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==
-
-diff@^4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
-  integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
-
-dir-glob@2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.0.0.tgz#0b205d2b6aef98238ca286598a8204d29d0a0034"
-  integrity sha512-37qirFDz8cA5fimp9feo43fSuRo2gHwaIn6dXL8Ber1dGwUosDrGZeCCXq57WnIqE4aQ+u3eQZzsk1yOzhdwag==
-  dependencies:
-    arrify "^1.0.1"
-    path-type "^3.0.0"
-
-dir-glob@^2.2.2:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-2.2.2.tgz#fa09f0694153c8918b18ba0deafae94769fc50c4"
-  integrity sha512-f9LBi5QWzIW3I6e//uxZoLBlUt9kcp66qo0sSCxL6YZKc75R1c4MFCoe/LaZiBGmgujvQdxc5Bn3QhfyvK5Hsw==
-  dependencies:
-    path-type "^3.0.0"
+    "@babel/runtime" "^7.10.2"
+    leven "^3.1.0"
+    lodash.deburr "^4.1.0"
 
 dir-glob@^3.0.1:
   version "3.0.1"
-  resolved "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz"
+  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.npmjs.org/doctrine/-/doctrine-1.5.0.tgz"
-  integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo=
-  dependencies:
-    esutils "^2.0.2"
-    isarray "^1.0.0"
-
-doctrine@^2.0.2:
+doctrine@^2.1.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d"
   integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==
@@ -3561,259 +891,142 @@
 
 doctrine@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
   integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
   dependencies:
     esutils "^2.0.2"
 
-dom-serializer@^1.0.1:
-  version "1.3.1"
-  resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.3.1.tgz"
-  integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==
+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==
   dependencies:
     domelementtype "^2.0.1"
-    domhandler "^4.0.0"
     entities "^2.0.0"
 
-dom-urls@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/dom-urls/-/dom-urls-1.1.0.tgz#001ddf81628cd1e706125c7176f53ccec55d918e"
-  integrity sha1-AB3fgWKM0ecGElxxdvU8zsVdkY4=
+dom-serializer@^1.0.1:
+  version "1.3.2"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.2.tgz#6206437d32ceefaec7161803230c7a20bc1b4d91"
+  integrity sha512-5c54Bk5Dw4qAxNOI1pFEizPSjVsx5+bpJKmL2kPn8JhBUq2q09tTCa3mjijun2NfK78NMouDYNMBkOrPZiS+ig==
   dependencies:
-    urijs "^1.16.1"
+    domelementtype "^2.0.1"
+    domhandler "^4.2.0"
+    entities "^2.0.0"
 
-dom5@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/dom5/-/dom5-3.0.1.tgz#cdfc7331f376e284bf379e6ea054afc136702944"
-  integrity sha512-JPFiouQIr16VQ4dX6i0+Hpbg3H2bMKPmZ+WZgBOSSvOPx9QHwwY8sPzeM2baUtViESYto6wC2nuZOMC/6gulcA==
+dom-serializer@~0.1.0:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0"
+  integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==
   dependencies:
-    "@types/parse5" "^2.2.34"
-    clone "^2.1.0"
-    parse5 "^4.0.0"
+    domelementtype "^1.3.0"
+    entities "^1.1.1"
+
+domelementtype@1, domelementtype@^1.3.0, 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.2.0:
   version "2.2.0"
-  resolved "https://registry.npmjs.org/domelementtype/-/domelementtype-2.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
   integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
 
-domhandler@^4.0.0, domhandler@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.npmjs.org/domhandler/-/domhandler-4.1.0.tgz"
-  integrity sha512-/6/kmsGlMY4Tup/nGVutdrK9yQi4YjWVcVeoQmixpzjOUK1U7pQkvAPHBJeUxOgxF0J8f8lwCJSlCfD0V4CMGQ==
+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==
+  dependencies:
+    domelementtype "1"
+
+domhandler@^4.0.0, domhandler@^4.2.0:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.2.tgz#e825d721d19a86b8c201a35264e226c678ee755f"
+  integrity sha512-PzE9aBMsdZO8TK4BnuJwH0QT41wgMbRzuZrHUcpYncEjmQazq8QEaBWgLG7ZyC/DAZKEgglpIA6j4Qn/HmxS3w==
   dependencies:
     domelementtype "^2.2.0"
 
+domutils@1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf"
+  integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8=
+  dependencies:
+    dom-serializer "0"
+    domelementtype "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==
+  dependencies:
+    dom-serializer "0"
+    domelementtype "1"
+
 domutils@^2.5.2:
-  version "2.5.2"
-  resolved "https://registry.npmjs.org/domutils/-/domutils-2.5.2.tgz"
-  integrity sha512-MHTthCb1zj8f1GVfRpeZUbohQf/HdBos0oX5gZcQFepOZPLLRyj6Wn7XS7EMnY7CVpwv8863u2vyE83Hfu28HQ==
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135"
+  integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==
   dependencies:
     dom-serializer "^1.0.1"
     domelementtype "^2.2.0"
-    domhandler "^4.1.0"
-
-dot-prop@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-3.0.0.tgz#1b708af094a49c9a0e7dbcad790aba539dac1177"
-  integrity sha1-G3CK8JSknJoOfbyteQq6U52sEXc=
-  dependencies:
-    is-obj "^1.0.0"
-
-dot-prop@^4.2.1:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-4.2.1.tgz#45884194a71fc2cda71cbb4bceb3a4dd2f433ba4"
-  integrity sha512-l0p4+mIuJIua0mhxGoh4a+iNL9bmeK5DvnSVQa6T0OhrVmaEa1XScX5Etc673FePCJOArq/4Pa2cLGODUWTPOQ==
-  dependencies:
-    is-obj "^1.0.0"
+    domhandler "^4.2.0"
 
 dot-prop@^5.2.0:
   version "5.3.0"
-  resolved "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88"
   integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==
   dependencies:
     is-obj "^2.0.0"
 
-download-stats@^0.3.4:
-  version "0.3.4"
-  resolved "https://registry.yarnpkg.com/download-stats/-/download-stats-0.3.4.tgz#67ea0c32f14acd9f639da704eef509684ba2dae7"
-  integrity sha512-ic2BigbyUWx7/CBbsfGjf71zUNZB4edBGC3oRliSzsoNmvyVx3Ycfp1w3vp2Y78Ee0eIIkjIEO5KzW0zThDGaA==
-  dependencies:
-    JSONStream "^1.2.1"
-    lazy-cache "^2.0.1"
-    moment "^2.15.1"
-
-duplexer2@^0.1.2, duplexer2@^0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/duplexer2/-/duplexer2-0.1.4.tgz#8b12dab878c0d69e3e7891051662a32fc6bddcc1"
-  integrity sha1-ixLauHjA1p4+eJEFFmKjL8a93ME=
-  dependencies:
-    readable-stream "^2.0.2"
-
 duplexer3@^0.1.4:
   version "0.1.4"
-  resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz"
+  resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
   integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
 
-duplexify@^3.2.0, duplexify@^3.5.0, duplexify@^3.6.0:
-  version "3.7.1"
-  resolved "https://registry.yarnpkg.com/duplexify/-/duplexify-3.7.1.tgz#2a4df5317f6ccfd91f86d6fd25d8d8a103b88309"
-  integrity sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==
-  dependencies:
-    end-of-stream "^1.0.0"
-    inherits "^2.0.1"
-    readable-stream "^2.0.0"
-    stream-shift "^1.0.0"
-
-ecc-jsbn@~0.1.1:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9"
-  integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=
-  dependencies:
-    jsbn "~0.1.0"
-    safer-buffer "^2.1.0"
-
-editions@^2.2.0:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/editions/-/editions-2.3.1.tgz#3bc9962f1978e801312fbd0aebfed63b49bfe698"
-  integrity sha512-ptGvkwTvGdGfC0hfhKg0MT+TRLRKGtUiWGBInxOm5pz7ssADezahjCUaYuZ8Dr+C05FW0AECIIPt4WBxVINEhA==
-  dependencies:
-    errlop "^2.0.0"
-    semver "^6.3.0"
-
-ee-first@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
-  integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
-
-ejs@^2.5.9, ejs@^2.6.1:
-  version "2.7.4"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.7.4.tgz#48661287573dcc53e366c7a1ae52c3a120eec9ba"
-  integrity sha512-7vmuyh5+kuUyJKePhQfRQBhXV5Ce+RnaeeQArKu1EAMpL3WbgMt5WG6uQZpEVvYSSsxMXRKOewtDk9RaTKXRlA==
-
-ejs@^3.1.5:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a"
-  integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==
-  dependencies:
-    jake "^10.6.1"
-
-electron-to-chromium@^1.3.649:
-  version "1.3.712"
-  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.712.tgz#ae467ffe5f95961c6d41ceefe858fc36eb53b38f"
-  integrity sha512-3kRVibBeCM4vsgoHHGKHmPocLqtFAGTrebXxxtgKs87hNUzXrX2NuS3jnBys7IozCnw7viQlozxKkmty2KNfrw==
-
-emitter-component@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/emitter-component/-/emitter-component-1.1.1.tgz#065e2dbed6959bf470679edabeaf7981d1003ab6"
-  integrity sha1-Bl4tvtaVm/RwZ57avq95gdEAOrY=
-
 emoji-regex@^7.0.1:
   version "7.0.3"
-  resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156"
   integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==
 
 emoji-regex@^8.0.0:
   version "8.0.0"
-  resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
   integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
 
-enabled@2.0.x:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/enabled/-/enabled-2.0.0.tgz#f9dd92ec2d6f4bbc0d5d1e64e21d61cd4665e7c2"
-  integrity sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==
-
-encodeurl@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
-  integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=
-
-end-of-stream@^1.0.0, end-of-stream@^1.1.0, end-of-stream@^1.4.1:
+end-of-stream@^1.1.0:
   version "1.4.4"
-  resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz"
+  resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0"
   integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==
   dependencies:
     once "^1.4.0"
 
-ends-with@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/ends-with/-/ends-with-0.2.0.tgz#2f9da98d57a50cfda4571ce4339000500f4e6b8a"
-  integrity sha1-L52pjVelDP2kVxzkM5AAUA9Oa4o=
-
-engine.io-client@~3.5.0:
-  version "3.5.1"
-  resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-3.5.1.tgz#b500458a39c0cd197a921e0e759721a746d0bdb9"
-  integrity sha512-oVu9kBkGbcggulyVF0kz6BV3ganqUeqXvD79WOFKa+11oK692w1NyFkuEj4xrkFRpZhn92QOqTk4RQq5LiBXbQ==
-  dependencies:
-    component-emitter "~1.3.0"
-    component-inherit "0.0.3"
-    debug "~3.1.0"
-    engine.io-parser "~2.2.0"
-    has-cors "1.1.0"
-    indexof "0.0.1"
-    parseqs "0.0.6"
-    parseuri "0.0.6"
-    ws "~7.4.2"
-    xmlhttprequest-ssl "~1.5.4"
-    yeast "0.1.2"
-
-engine.io-parser@~2.2.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-2.2.1.tgz#57ce5611d9370ee94f99641b589f94c97e4f5da7"
-  integrity sha512-x+dN/fBH8Ro8TFwJ+rkB2AmuVw9Yu2mockR/p3W8f8YtExwFgDvBDi0GWyb4ZLkpahtDGZgtr3zLovanJghPqg==
-  dependencies:
-    after "0.8.2"
-    arraybuffer.slice "~0.0.7"
-    base64-arraybuffer "0.1.4"
-    blob "0.0.5"
-    has-binary2 "~1.0.2"
-
-engine.io@~3.5.0:
-  version "3.5.0"
-  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-3.5.0.tgz#9d6b985c8a39b1fe87cd91eb014de0552259821b"
-  integrity sha512-21HlvPUKaitDGE4GXNtQ7PLP0Sz4aWLddMPw2VTyFz1FVZqu/kZsJUO8WNpKuE/OCL7nkfRaOui2ZCJloGznGA==
-  dependencies:
-    accepts "~1.3.4"
-    base64id "2.0.0"
-    cookie "~0.4.1"
-    debug "~4.1.0"
-    engine.io-parser "~2.2.0"
-    ws "~7.4.2"
-
 enquirer@^2.3.5:
   version "2.3.6"
-  resolved "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz"
+  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@^1.1.1, 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==
+
 entities@^2.0.0:
   version "2.2.0"
-  resolved "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55"
   integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
 
-errlop@^2.0.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/errlop/-/errlop-2.2.0.tgz#1ff383f8f917ae328bebb802d6ca69666a42d21b"
-  integrity sha512-e64Qj9+4aZzjzzFpZC7p5kmm/ccCrbLhAJplhsDXQFs87XTsXwOpH4s1Io2s90Tau/8r2j9f4l/thhDevRjzxw==
-
-error-ex@^1.2.0, error-ex@^1.3.1:
+error-ex@^1.3.1:
   version "1.3.2"
-  resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz"
+  resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf"
   integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==
   dependencies:
     is-arrayish "^0.2.1"
 
-error@^7.0.2:
-  version "7.2.1"
-  resolved "https://registry.yarnpkg.com/error/-/error-7.2.1.tgz#eab21a4689b5f684fc83da84a0e390de82d94894"
-  integrity sha512-fo9HBvWnx3NGUKMvMwB/CBCMMrfEJgbDTVDEkPygA3Bdd3lM1OyCd+rbQ8BwnpF6GdVeOLDNmyL4N5Bg80ZvdA==
-  dependencies:
-    string-template "~0.2.1"
-
-es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2:
-  version "1.18.0"
-  resolved "https://registry.npmjs.org/es-abstract/-/es-abstract-1.18.0.tgz"
-  integrity sha512-LJzK7MrQa8TS0ja2w3YNLzUgJCGPdPOV1yVvezjNnS89D+VR08+Szt2mz3YB2Dck/+w5tfIq/RoUAFqJJGM2yw==
+es-abstract@^1.18.0-next.1, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2:
+  version "1.18.5"
+  resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.5.tgz#9b10de7d4c206a3581fd5b2124233e04db49ae19"
+  integrity sha512-DDggyJLoS91CkJjgauM5c0yZMjiD1uK3KcaCeAmffGwZ+ODWzOkPN4QwRbsK5DOFf06fywmyLci3ZD8jLGhVYA==
   dependencies:
     call-bind "^1.0.2"
     es-to-primitive "^1.2.1"
@@ -3821,92 +1034,71 @@
     get-intrinsic "^1.1.1"
     has "^1.0.3"
     has-symbols "^1.0.2"
+    internal-slot "^1.0.3"
     is-callable "^1.2.3"
     is-negative-zero "^2.0.1"
-    is-regex "^1.1.2"
-    is-string "^1.0.5"
-    object-inspect "^1.9.0"
+    is-regex "^1.1.3"
+    is-string "^1.0.6"
+    object-inspect "^1.11.0"
     object-keys "^1.1.1"
     object.assign "^4.1.2"
     string.prototype.trimend "^1.0.4"
     string.prototype.trimstart "^1.0.4"
-    unbox-primitive "^1.0.0"
+    unbox-primitive "^1.0.1"
 
 es-to-primitive@^1.2.1:
   version "1.2.1"
-  resolved "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz"
+  resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a"
   integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==
   dependencies:
     is-callable "^1.1.4"
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
-es6-promise@^4.0.3, es6-promise@^4.0.5:
-  version "4.2.8"
-  resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a"
-  integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==
-
-es6-promisify@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203"
-  integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM=
-  dependencies:
-    es6-promise "^4.0.3"
-
-es6-promisify@^6.0.0:
-  version "6.1.1"
-  resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-6.1.1.tgz#46837651b7b06bf6fff893d03f29393668d01621"
-  integrity sha512-HBL8I3mIki5C1Cc9QjKUenHtnG0A5/xA8Q/AllRcfiwl2CZFXGK7ddBiCoRwAix4i2KxcQfjtIVcrVbB3vbmwg==
-
-escalade@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
-  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
-
 escape-goat@^2.0.0:
   version "2.1.1"
-  resolved "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675"
   integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==
 
-escape-html@^1.0.3, escape-html@~1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
-  integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=
-
-escape-string-regexp@^1.0.2, escape-string-regexp@^1.0.4, escape-string-regexp@^1.0.5:
+escape-string-regexp@^1.0.5:
   version "1.0.5"
-  resolved "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4"
   integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=
 
+escape-string-regexp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
 eslint-config-google@^0.14.0:
   version "0.14.0"
-  resolved "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz"
+  resolved "https://registry.yarnpkg.com/eslint-config-google/-/eslint-config-google-0.14.0.tgz#4f5f8759ba6e11b424294a219dbfa18c508bcc1a"
   integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
 
 eslint-config-prettier@^7.0.0:
   version "7.2.0"
-  resolved "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz#f4a4bd2832e810e8cc7c1411ec85b3e85c0c53f9"
   integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg==
 
-eslint-import-resolver-node@^0.3.4:
-  version "0.3.4"
-  resolved "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.4.tgz"
-  integrity sha512-ogtf+5AB/O+nM6DIeBUNr2fuT7ot9Qg/1harBfBtaP13ekEWFQEEMP94BCB7zaNW3gyY+8SHYF00rnqYwXKWOA==
+eslint-import-resolver-node@^0.3.6:
+  version "0.3.6"
+  resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz#4048b958395da89668252001dbd9eca6b83bacbd"
+  integrity sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==
   dependencies:
-    debug "^2.6.9"
-    resolve "^1.13.1"
+    debug "^3.2.7"
+    resolve "^1.20.0"
 
-eslint-module-utils@^2.6.0:
-  version "2.6.0"
-  resolved "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.6.0.tgz"
-  integrity sha512-6j9xxegbqe8/kZY8cYpcp0xhbK0EgJlg3g9mib3/miLaExuuwc3n5UEfSnU6hWMbT0FAYVvDbL9RrRgpUeQIvA==
+eslint-module-utils@^2.6.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.6.2.tgz#94e5540dd15fe1522e8ffa3ec8db3b7fa7e7a534"
+  integrity sha512-QG8pcgThYOuqxupd06oYTZoNOGaUdTY1PqK+oS6ElF6vs4pBdk/aYxFVQQXzcrAqp9m7cl7lb2ubazX+g16k2Q==
   dependencies:
-    debug "^2.6.9"
+    debug "^3.2.7"
     pkg-dir "^2.0.0"
 
 eslint-plugin-es@^3.0.0:
   version "3.0.1"
-  resolved "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz#75a7cdfdccddc0589934aeeb384175f221c57893"
   integrity sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==
   dependencies:
     eslint-utils "^2.0.0"
@@ -3920,40 +1112,51 @@
     htmlparser2 "^6.0.1"
 
 eslint-plugin-import@^2.22.1:
-  version "2.22.1"
-  resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.22.1.tgz"
-  integrity sha512-8K7JjINHOpH64ozkAhpT3sd+FswIZTfMZTjdx052pnWrgRCVfp8op9tbjpAk3DdUeI/Ba4C8OjdC0r90erHEOw==
+  version "2.24.2"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.24.2.tgz#2c8cd2e341f3885918ee27d18479910ade7bb4da"
+  integrity sha512-hNVtyhiEtZmpsabL4neEj+6M5DCLgpYyG9nzJY8lZQeQXEn5UPW1DpUdsMHMXsq98dbNm7nt1w9ZMSVpfJdi8Q==
   dependencies:
-    array-includes "^3.1.1"
-    array.prototype.flat "^1.2.3"
-    contains-path "^0.1.0"
+    array-includes "^3.1.3"
+    array.prototype.flat "^1.2.4"
     debug "^2.6.9"
-    doctrine "1.5.0"
-    eslint-import-resolver-node "^0.3.4"
-    eslint-module-utils "^2.6.0"
+    doctrine "^2.1.0"
+    eslint-import-resolver-node "^0.3.6"
+    eslint-module-utils "^2.6.2"
+    find-up "^2.0.0"
     has "^1.0.3"
+    is-core-module "^2.6.0"
     minimatch "^3.0.4"
-    object.values "^1.1.1"
-    read-pkg-up "^2.0.0"
-    resolve "^1.17.0"
-    tsconfig-paths "^3.9.0"
+    object.values "^1.1.4"
+    pkg-up "^2.0.0"
+    read-pkg-up "^3.0.0"
+    resolve "^1.20.0"
+    tsconfig-paths "^3.11.0"
 
 eslint-plugin-jsdoc@^32.3.0:
-  version "32.3.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-32.3.0.tgz#7c9fa5da8c72bd6ad7d97bbf8dee8bc29bec3f9e"
-  integrity sha512-zyx7kajDK+tqS1bHuY5sapkad8P8KT0vdd/lE55j47VPG2MeenSYuIY/M/Pvmzq5g0+3JB+P3BJGUXmHxtuKPQ==
+  version "32.3.4"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-32.3.4.tgz#6888f3b2dbb9f73fb551458c639a4e8c84fe9ddc"
+  integrity sha512-xSWfsYvffXnN0OkwLnB7MoDDDDjqcp46W7YlY1j7JyfAQBQ+WnGCfLov3gVNZjUGtK9Otj8mEhTZTqJu4QtIGA==
   dependencies:
-    comment-parser "1.1.2"
+    comment-parser "1.1.5"
     debug "^4.3.1"
     jsdoctypeparser "^9.0.0"
-    lodash "^4.17.20"
+    lodash "^4.17.21"
     regextras "^0.7.1"
-    semver "^7.3.4"
+    semver "^7.3.5"
     spdx-expression-parse "^3.0.1"
 
+eslint-plugin-lit@^1.5.1:
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-lit/-/eslint-plugin-lit-1.5.1.tgz#e5b86fee4aeb6023ad4bb90b3d9e462ca8eff755"
+  integrity sha512-pYB0QM11uyOk5L55QfGhBmWi8a56PkNsnx+zVpY4bxz9YVquEo4BeRnFmf9AwFyT89rhGud9QruFhM2xJ4piwg==
+  dependencies:
+    parse5 "^6.0.1"
+    parse5-htmlparser2-tree-adapter "^6.0.1"
+    requireindex "^1.2.0"
+
 eslint-plugin-node@^11.1.0:
   version "11.1.0"
-  resolved "https://registry.npmjs.org/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-11.1.0.tgz#c95544416ee4ada26740a30474eefc5402dc671d"
   integrity sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==
   dependencies:
     eslint-plugin-es "^3.0.0"
@@ -3963,23 +1166,21 @@
     resolve "^1.10.1"
     semver "^6.1.0"
 
-eslint-plugin-prettier@^3.1.4:
-  version "3.3.1"
-  resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-3.3.1.tgz"
-  integrity sha512-Rq3jkcFY8RYeQLgk2cCwuc0P7SEFwDravPhsJZOQ5N4YI4DSg50NyqJ/9gdZHzQlHf8MvafSesbNJCcP/FF6pQ==
+eslint-plugin-prettier@^3.1.4, eslint-plugin-prettier@^3.4.0:
+  version "3.4.1"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.1.tgz#e9ddb200efb6f3d05ffe83b1665a716af4a387e5"
+  integrity sha512-htg25EUYUeIhKHXjOinK4BgCcDwtLHjqaxCDsMy5nbnUMkKFvIhMVCp+5GFUXQ4Nr8lBsPqtGAqBenbpFqAA2g==
   dependencies:
     prettier-linter-helpers "^1.0.0"
 
-eslint-plugin-prettier@^3.4.0:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.0.tgz#cdbad3bf1dbd2b177e9825737fe63b476a08f0c7"
-  integrity sha512-UDK6rJT6INSfcOo545jiaOwB701uAIt2/dR7WnFQoGCVl1/EMqdANBmwUaqqQ45aXprsTGzSa39LI1PyuRBxxw==
-  dependencies:
-    prettier-linter-helpers "^1.0.0"
+eslint-plugin-regex@^1.8.0:
+  version "1.8.0"
+  resolved "https://registry.yarnpkg.com/eslint-plugin-regex/-/eslint-plugin-regex-1.8.0.tgz#4bd111cf5235fb76a4a7f77d7ffcb7b3777b8a77"
+  integrity sha512-rmzVvpoxHKgvcYDo9d1X9RMFOtyOV3A6taD3KWE6gIID2dHoc8RPd0YAjDSJ0LG35wnehQBfsNB+F7q4eYqXqw==
 
-eslint-scope@^5.0.0, eslint-scope@^5.1.1:
+eslint-scope@^5.1.1:
   version "5.1.1"
-  resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
   integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
   dependencies:
     esrecurse "^4.3.0"
@@ -3987,43 +1188,53 @@
 
 eslint-utils@^2.0.0, eslint-utils@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27"
   integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==
   dependencies:
     eslint-visitor-keys "^1.1.0"
 
+eslint-utils@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672"
+  integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==
+  dependencies:
+    eslint-visitor-keys "^2.0.0"
+
 eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0:
   version "1.3.0"
-  resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz"
+  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.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.0.0.tgz"
-  integrity sha512-QudtT6av5WXels9WjIM7qz1XD1cWGvX4gGXvp/zBn9nXG02D0utdU3Em2m/QjTnrsk6bBjmCygl3rmj118msQQ==
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
+  integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
 
-eslint@^7.10.0:
-  version "7.23.0"
-  resolved "https://registry.npmjs.org/eslint/-/eslint-7.23.0.tgz"
-  integrity sha512-kqvNVbdkjzpFy0XOszNwjkKzZ+6TcwCQ/h+ozlcIWwaimBBuhlQ4nN6kbiM2L+OjDcznkTJxzYfRFH92sx4a0Q==
+eslint@^7.10.0, eslint@^7.24.0:
+  version "7.32.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
+  integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==
   dependencies:
     "@babel/code-frame" "7.12.11"
-    "@eslint/eslintrc" "^0.4.0"
+    "@eslint/eslintrc" "^0.4.3"
+    "@humanwhocodes/config-array" "^0.5.0"
     ajv "^6.10.0"
     chalk "^4.0.0"
     cross-spawn "^7.0.2"
     debug "^4.0.1"
     doctrine "^3.0.0"
     enquirer "^2.3.5"
+    escape-string-regexp "^4.0.0"
     eslint-scope "^5.1.1"
     eslint-utils "^2.1.0"
     eslint-visitor-keys "^2.0.0"
     espree "^7.3.1"
     esquery "^1.4.0"
     esutils "^2.0.2"
+    fast-deep-equal "^3.1.3"
     file-entry-cache "^6.0.1"
     functional-red-black-tree "^1.0.1"
-    glob-parent "^5.0.0"
+    glob-parent "^5.1.2"
     globals "^13.6.0"
     ignore "^4.0.6"
     import-fresh "^3.0.0"
@@ -4032,7 +1243,7 @@
     js-yaml "^3.13.1"
     json-stable-stringify-without-jsonify "^1.0.1"
     levn "^0.4.1"
-    lodash "^4.17.21"
+    lodash.merge "^4.6.2"
     minimatch "^3.0.4"
     natural-compare "^1.4.0"
     optionator "^0.9.1"
@@ -4041,64 +1252,13 @@
     semver "^7.2.1"
     strip-ansi "^6.0.0"
     strip-json-comments "^3.1.0"
-    table "^6.0.4"
+    table "^6.0.9"
     text-table "^0.2.0"
     v8-compile-cache "^2.0.3"
 
-eslint@^7.24.0:
-  version "7.24.0"
-  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.24.0.tgz#2e44fa62d93892bfdb100521f17345ba54b8513a"
-  integrity sha512-k9gaHeHiFmGCDQ2rEfvULlSLruz6tgfA8DEn+rY9/oYPFFTlz55mM/Q/Rij1b2Y42jwZiK3lXvNTw6w6TXzcKQ==
-  dependencies:
-    "@babel/code-frame" "7.12.11"
-    "@eslint/eslintrc" "^0.4.0"
-    ajv "^6.10.0"
-    chalk "^4.0.0"
-    cross-spawn "^7.0.2"
-    debug "^4.0.1"
-    doctrine "^3.0.0"
-    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.4.0"
-    esutils "^2.0.2"
-    file-entry-cache "^6.0.1"
-    functional-red-black-tree "^1.0.1"
-    glob-parent "^5.0.0"
-    globals "^13.6.0"
-    ignore "^4.0.6"
-    import-fresh "^3.0.0"
-    imurmurhash "^0.1.4"
-    is-glob "^4.0.0"
-    js-yaml "^3.13.1"
-    json-stable-stringify-without-jsonify "^1.0.1"
-    levn "^0.4.1"
-    lodash "^4.17.21"
-    minimatch "^3.0.4"
-    natural-compare "^1.4.0"
-    optionator "^0.9.1"
-    progress "^2.0.0"
-    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"
-
-espree@^3.5.2:
-  version "3.5.4"
-  resolved "https://registry.yarnpkg.com/espree/-/espree-3.5.4.tgz#b0f447187c8a8bed944b815a660bddf5deb5d1a7"
-  integrity sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==
-  dependencies:
-    acorn "^5.5.0"
-    acorn-jsx "^3.0.0"
-
 espree@^7.3.0, espree@^7.3.1:
   version "7.3.1"
-  resolved "https://registry.npmjs.org/espree/-/espree-7.3.1.tgz"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6"
   integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g==
   dependencies:
     acorn "^7.4.0"
@@ -4107,93 +1267,42 @@
 
 esprima@^4.0.0:
   version "4.0.1"
-  resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71"
   integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==
 
 esquery@^1.4.0:
   version "1.4.0"
-  resolved "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5"
   integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==
   dependencies:
     estraverse "^5.1.0"
 
 esrecurse@^4.3.0:
   version "4.3.0"
-  resolved "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz"
+  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.1:
   version "4.3.0"
-  resolved "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz"
+  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.npmjs.org/estraverse/-/estraverse-5.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
   integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
 
 esutils@^2.0.2:
   version "2.0.3"
-  resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
   integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
 
-etag@~1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
-  integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
-
-eventemitter3@^4.0.0:
-  version "4.0.7"
-  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
-  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
-
-execa@^0.7.0:
-  version "0.7.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-0.7.0.tgz#944becd34cc41ee32a63a9faf27ad5a65fc59777"
-  integrity sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=
-  dependencies:
-    cross-spawn "^5.0.1"
-    get-stream "^3.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-1.0.0.tgz#c6236a5bb4df6d6f15e88e7f017798216749ddd8"
-  integrity sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==
-  dependencies:
-    cross-spawn "^6.0.0"
-    get-stream "^4.0.0"
-    is-stream "^1.1.0"
-    npm-run-path "^2.0.0"
-    p-finally "^1.0.0"
-    signal-exit "^3.0.0"
-    strip-eof "^1.0.0"
-
-execa@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
-  integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==
-  dependencies:
-    cross-spawn "^7.0.0"
-    get-stream "^5.0.0"
-    human-signals "^1.1.1"
-    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"
-    strip-final-newline "^2.0.0"
-
 execa@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz"
-  integrity sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
+  integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==
   dependencies:
     cross-spawn "^7.0.3"
     get-stream "^6.0.0"
@@ -4205,18 +1314,6 @@
     signal-exit "^3.0.3"
     strip-final-newline "^2.0.0"
 
-exit-hook@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
-  integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
-
-expand-brackets@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-0.1.5.tgz#df07284e342a807cd733ac5af72411e581d1177b"
-  integrity sha1-3wcoTjQqgHzXM6xa9yQR5YHRF3s=
-  dependencies:
-    is-posix-bracket "^0.1.0"
-
 expand-brackets@^2.1.4:
   version "2.1.4"
   resolved "https://registry.yarnpkg.com/expand-brackets/-/expand-brackets-2.1.4.tgz#b77735e315ce30f6b6eff0f83b04151a22449622"
@@ -4230,70 +1327,6 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
-expand-range@^1.8.1:
-  version "1.8.2"
-  resolved "https://registry.yarnpkg.com/expand-range/-/expand-range-1.8.2.tgz#a299effd335fe2721ebae8e257ec79644fc85337"
-  integrity sha1-opnv/TNf4nIeuujiV+x5ZE/IUzc=
-  dependencies:
-    fill-range "^2.1.0"
-
-expand-tilde@^1.2.2:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-1.2.2.tgz#0b81eba897e5a3d31d1c3d102f8f01441e559449"
-  integrity sha1-C4HrqJflo9MdHD0QL48BRB5VlEk=
-  dependencies:
-    os-homedir "^1.0.1"
-
-expand-tilde@^2.0.0, expand-tilde@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
-  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
-  dependencies:
-    homedir-polyfill "^1.0.1"
-
-express@^4.15.3, express@^4.8.5:
-  version "4.17.1"
-  resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134"
-  integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g==
-  dependencies:
-    accepts "~1.3.7"
-    array-flatten "1.1.1"
-    body-parser "1.19.0"
-    content-disposition "0.5.3"
-    content-type "~1.0.4"
-    cookie "0.4.0"
-    cookie-signature "1.0.6"
-    debug "2.6.9"
-    depd "~1.1.2"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    finalhandler "~1.1.2"
-    fresh "0.5.2"
-    merge-descriptors "1.0.1"
-    methods "~1.1.2"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    path-to-regexp "0.1.7"
-    proxy-addr "~2.0.5"
-    qs "6.7.0"
-    range-parser "~1.2.1"
-    safe-buffer "5.1.2"
-    send "0.17.1"
-    serve-static "1.14.1"
-    setprototypeof "1.1.1"
-    statuses "~1.5.0"
-    type-is "~1.6.18"
-    utils-merge "1.0.1"
-    vary "~1.1.2"
-
-ext-list@^2.0.0:
-  version "2.2.2"
-  resolved "https://registry.yarnpkg.com/ext-list/-/ext-list-2.2.2.tgz#0b98e64ed82f5acf0f2931babf69212ef52ddd37"
-  integrity sha512-u+SQgsubraE6zItfVA0tBuCBhfU9ogSRnsvygI7wht9TS510oLkBRXBsqopeUG/GBOIQyKZO9wjTqIu/sf5zFA==
-  dependencies:
-    mime-db "^1.28.0"
-
 extend-shallow@^2.0.1:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f"
@@ -4309,36 +1342,15 @@
     assign-symbols "^1.0.0"
     is-extendable "^1.0.1"
 
-extend@^3.0.0, extend@~3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
-  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
-
-external-editor@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-1.1.1.tgz#12d7b0db850f7ff7e7081baf4005700060c4600b"
-  integrity sha1-Etew24UPf/fnCBuvQAVwAGDEYAs=
-  dependencies:
-    extend "^3.0.0"
-    spawn-sync "^1.0.15"
-    tmp "^0.0.29"
-
 external-editor@^3.0.3:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495"
   integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==
   dependencies:
     chardet "^0.7.0"
     iconv-lite "^0.4.24"
     tmp "^0.0.33"
 
-extglob@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1"
-  integrity sha1-Lhj/PS9JqydlzskCPwEdqo2DSaE=
-  dependencies:
-    is-extglob "^1.0.0"
-
 extglob@^2.0.4:
   version "2.0.4"
   resolved "https://registry.yarnpkg.com/extglob/-/extglob-2.0.4.tgz#ad00fe4dc612a9232e8718711dc5cb5ab0285543"
@@ -4353,27 +1365,17 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
-extsprintf@1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
-  integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=
-
-extsprintf@^1.2.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f"
-  integrity sha1-4mifjzVvrWLMplo6kcXfX5VRaS8=
-
-fast-deep-equal@^3.1.1:
+fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
   version "3.1.3"
-  resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"
+  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.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03"
   integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==
 
-fast-glob@^2.0.2, fast-glob@^2.2.6:
+fast-glob@^2.2.6:
   version "2.2.7"
   resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-2.2.7.tgz#6953857c3afa475fff92ee6015d52da70a4cd39d"
   integrity sha512-g1KuQwHOZAmOZMuBtHdxDtju+T2RT8jgCC9aANsbpdiDDTSnjgfuVsIBNKbUeJI3oKMRExcfNDtJl4OhbffMsw==
@@ -4385,107 +1387,48 @@
     merge2 "^1.2.3"
     micromatch "^3.1.10"
 
-fast-glob@^3.1.1:
-  version "3.2.5"
-  resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz"
-  integrity sha512-2DtFcgT68wiTTiwZ2hNdJfcHNke9XOfnwmBRWXhmeKM8rF0TGwmC/Qto3S7RoZKp5cilZbxzO5iTNTQsJ+EeDg==
+fast-glob@^3.1.1, fast-glob@^3.2.2:
+  version "3.2.7"
+  resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.7.tgz#fd6cb7a2d7e9aa7a7846111e85a196d6b2f766a1"
+  integrity sha512-rYGMRwip6lUMvYD3BTScMwT1HtAs2d71SMv66Vrxs0IekGZEjhM0pcMfjQPnknBt2zeCwQMEupiN02ZP4DiT1Q==
   dependencies:
     "@nodelib/fs.stat" "^2.0.2"
     "@nodelib/fs.walk" "^1.2.3"
-    glob-parent "^5.1.0"
+    glob-parent "^5.1.2"
     merge2 "^1.3.0"
-    micromatch "^4.0.2"
-    picomatch "^2.2.1"
+    micromatch "^4.0.4"
 
 fast-json-stable-stringify@^2.0.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
   integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
 
 fast-levenshtein@^2.0.6:
   version "2.0.6"
-  resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"
+  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
-fast-safe-stringify@^2.0.4:
-  version "2.0.7"
-  resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.0.7.tgz#124aa885899261f68aedb42a7c080de9da608743"
-  integrity sha512-Utm6CdzT+6xsDk2m8S6uL8VHxNwI6Jub+e9NYTcAms28T84pTa25GJQV9j0CY0N1rM8hK4x6grpF2BQf+2qwVA==
-
 fastq@^1.6.0:
-  version "1.11.0"
-  resolved "https://registry.npmjs.org/fastq/-/fastq-1.11.0.tgz"
-  integrity sha512-7Eczs8gIPDrVzT+EksYBcupqMyxSHXXrHOLRRxU2/DicV8789MRBRR8+Hc2uWzUupOs4YS4JzBmBxjjCVBxD/g==
+  version "1.12.0"
+  resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.12.0.tgz#ed7b6ab5d62393fb2cc591c853652a5c318bf794"
+  integrity sha512-VNX0QkHK3RsXVKr9KrlUv/FoTa0NdbYoHHl7uXHv2rzyHSlxjdNAKug2twd9luJxpcyNeAgf5iPPMutJO67Dfg==
   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"
-  integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
-  dependencies:
-    pend "~1.2.0"
-
-fecha@^2.3.3:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-2.3.3.tgz#948e74157df1a32fd1b12c3a3c3cdcb6ec9d96cd"
-  integrity sha512-lUGBnIamTAwk4znq5BcqsDaxSmZ9nDVJaij6NvRt/Tg4R69gERA+otPKbS86ROw9nxVMw2/mp1fnaiWqbs6Sdg==
-
-fecha@^4.2.0:
-  version "4.2.1"
-  resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.1.tgz#0a83ad8f86ef62a091e22bb5a039cd03d23eecce"
-  integrity sha512-MMMQ0ludy/nBs1/o0zVOiKTpG7qMbonKUzjJgQFEuvq6INZ1OraKPRAWkBq5vlKLOUMpmNYG1JoN3oDPUQ9m3Q==
-
-figures@^1.3.5:
-  version "1.7.0"
-  resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
-  integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
-  dependencies:
-    escape-string-regexp "^1.0.5"
-    object-assign "^4.1.0"
-
 figures@^3.0.0:
   version "3.2.0"
-  resolved "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz"
+  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@^6.0.1:
   version "6.0.1"
-  resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
   integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==
   dependencies:
     flat-cache "^3.0.4"
 
-file-uri-to-path@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
-  integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==
-
-filelist@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
-  integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==
-  dependencies:
-    minimatch "^3.0.4"
-
-filename-regex@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
-  integrity sha1-wcS5vuPglyXdsQa3XB4wH+LxiyY=
-
-fill-range@^2.1.0:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-2.2.4.tgz#eb1e773abb056dcd8df2bfdf6af59b8b3a936565"
-  integrity sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==
-  dependencies:
-    is-number "^2.1.0"
-    isobject "^2.0.0"
-    randomatic "^3.0.0"
-    repeat-element "^1.1.2"
-    repeat-string "^1.5.2"
-
 fill-range@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
@@ -4498,180 +1441,44 @@
 
 fill-range@^7.0.1:
   version "7.0.1"
-  resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz"
+  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"
-  integrity sha1-w8T2xmO5I0WamqKZEtLQMfFQf4Q=
-
-finalhandler@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
-  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
-  dependencies:
-    debug "2.6.9"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    on-finished "~2.3.0"
-    parseurl "~1.3.3"
-    statuses "~1.5.0"
-    unpipe "~1.0.0"
-
-find-port@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/find-port/-/find-port-1.0.1.tgz#db084a6cbf99564d99869ae79fbdecf66e8a185c"
-  integrity sha1-2whKbL+ZVk2Zhprnn73s9m6KGFw=
-  dependencies:
-    async "~0.2.9"
-
-find-replace@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38"
-  integrity sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ==
-  dependencies:
-    array-back "^3.0.1"
-
-find-up@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-1.1.2.tgz#6b2e9822b1a2ce0a60ab64d610eccad53cb24d0f"
-  integrity sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=
-  dependencies:
-    path-exists "^2.0.0"
-    pinkie-promise "^2.0.0"
-
 find-up@^2.0.0, find-up@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7"
   integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c=
   dependencies:
     locate-path "^2.0.0"
 
-find-up@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73"
-  integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==
-  dependencies:
-    locate-path "^3.0.0"
-
 find-up@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
   integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
   dependencies:
     locate-path "^5.0.0"
     path-exists "^4.0.0"
 
-findup-sync@^0.4.2:
-  version "0.4.3"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-0.4.3.tgz#40043929e7bc60adf0b7f4827c4c6e75a0deca12"
-  integrity sha1-QAQ5Kee8YK3wt/SCfExudaDeyhI=
-  dependencies:
-    detect-file "^0.1.0"
-    is-glob "^2.0.1"
-    micromatch "^2.3.7"
-    resolve-dir "^0.1.0"
-
-findup-sync@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc"
-  integrity sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=
-  dependencies:
-    detect-file "^1.0.0"
-    is-glob "^3.1.0"
-    micromatch "^3.0.4"
-    resolve-dir "^1.0.1"
-
-first-chunk-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz#59bfb50cd905f60d7c394cd3d9acaab4e6ad934e"
-  integrity sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=
-
-first-chunk-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/first-chunk-stream/-/first-chunk-stream-2.0.0.tgz#1bdecdb8e083c0664b91945581577a43a9f31d70"
-  integrity sha1-G97NuOCDwGZLkZRVgVd6Q6nzHXA=
-  dependencies:
-    readable-stream "^2.0.2"
-
 flat-cache@^3.0.4:
   version "3.0.4"
-  resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
   integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
   dependencies:
     flatted "^3.1.0"
     rimraf "^3.0.2"
 
 flatted@^3.1.0:
-  version "3.1.1"
-  resolved "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz"
-  integrity sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.2.tgz#64bfed5cb68fe3ca78b3eb214ad97b63bedce561"
+  integrity sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==
 
-fn.name@1.x.x:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/fn.name/-/fn.name-1.1.0.tgz#26cad8017967aea8731bc42961d04a3d5988accc"
-  integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==
-
-follow-redirects@^1.0.0, follow-redirects@^1.10.0:
-  version "1.13.3"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.3.tgz#e5598ad50174c1bc4e872301e82ac2cd97f90267"
-  integrity sha512-DUgl6+HDzB0iEptNQEXLx/KhTmDb8tZUHSeLqpnjpknR70H0nC2t9N73BK6fN4hOvJ84pKlIQVQ4k5FFlBedKA==
-
-for-in@^1.0.1, for-in@^1.0.2:
+for-in@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"
   integrity sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=
 
-for-own@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/for-own/-/for-own-0.1.5.tgz#5265c681a4f294dabbf17c9509b6763aa84510ce"
-  integrity sha1-UmXGgaTylNq78XyVCbZ2OqhFEM4=
-  dependencies:
-    for-in "^1.0.1"
-
-forever-agent@~0.6.1:
-  version "0.6.1"
-  resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91"
-  integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=
-
-fork-stream@^0.0.4:
-  version "0.0.4"
-  resolved "https://registry.yarnpkg.com/fork-stream/-/fork-stream-0.0.4.tgz#db849fce77f6708a5f8f386ae533a0907b54ae70"
-  integrity sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=
-
-form-data@*:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452"
-  integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.8"
-    mime-types "^2.1.12"
-
-form-data@~2.3.2:
-  version "2.3.3"
-  resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6"
-  integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==
-  dependencies:
-    asynckit "^0.4.0"
-    combined-stream "^1.0.6"
-    mime-types "^2.1.12"
-
-formatio@1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/formatio/-/formatio-1.2.0.tgz#f3b2167d9068c4698a8d51f4f760a39a54d818eb"
-  integrity sha1-87IWfZBoxGmKjVH092CjmlTYGOs=
-  dependencies:
-    samsam "1.x"
-
-forwarded@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84"
-  integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ=
-
 fragment-cache@^0.2.1:
   version "0.2.1"
   resolved "https://registry.yarnpkg.com/fragment-cache/-/fragment-cache-0.2.1.tgz#4290fad27f13e89be7f33799c6bc5a0abfff0d19"
@@ -4679,155 +1486,65 @@
   dependencies:
     map-cache "^0.2.2"
 
-freeport@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/freeport/-/freeport-1.0.5.tgz#255e8ab84170c33ba85d990e821ae5f4a1a9bc5d"
-  integrity sha1-JV6KuEFwwzuoXZkOghrl9KGpvF0=
-
-fresh@0.5.2:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
-  integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=
-
-fs-constants@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad"
-  integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==
-
-fs-exists-sync@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
-  integrity sha1-mC1ok6+RjnLQjeyehnP/K1qNat0=
-
 fs.realpath@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
-fsevents@^1.0.0:
-  version "1.2.13"
-  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
-  integrity sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==
-  dependencies:
-    bindings "^1.5.0"
-    nan "^2.12.1"
-
-fsevents@~2.3.1:
+fsevents@~2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
   integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
 
 function-bind@^1.1.1:
   version "1.1.1"
-  resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
   integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
 
 functional-red-black-tree@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
   integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
 
-gensync@^1.0.0-beta.2:
-  version "1.0.0-beta.2"
-  resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
-  integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
+get-caller-file@^2.0.1:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+  integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
 
-get-intrinsic@^1.0.2, get-intrinsic@^1.1.1:
+get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
   version "1.1.1"
-  resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
   integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
   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"
-  integrity sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=
-
-get-stream@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
-  integrity sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=
-
-get-stream@^4.0.0, get-stream@^4.1.0:
+get-stream@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5"
   integrity sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==
   dependencies:
     pump "^3.0.0"
 
-get-stream@^5.0.0, get-stream@^5.1.0:
+get-stream@^5.1.0:
   version "5.2.0"
-  resolved "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz"
+  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.npmjs.org/get-stream/-/get-stream-6.0.0.tgz"
-  integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
+  integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
 
 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"
   integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
 
-getpass@^0.1.1:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
-  integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=
-  dependencies:
-    assert-plus "^1.0.0"
-
-gh-got@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-5.0.0.tgz#ee95be37106fd8748a96f8d1db4baea89e1bfa8a"
-  integrity sha1-7pW+NxBv2HSKlvjR20uuqJ4b+oo=
-  dependencies:
-    got "^6.2.0"
-    is-plain-obj "^1.1.0"
-
-gh-got@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/gh-got/-/gh-got-6.0.0.tgz#d74353004c6ec466647520a10bd46f7299d268d0"
-  integrity sha512-F/mS+fsWQMo1zfgG9MD8KWvTWPPzzhuVwY++fhQ5Ggd+0P+CAMHtzMZhNxG+TqGfHDChJKsbh6otfMGqO2AKBw==
-  dependencies:
-    got "^7.0.0"
-    is-plain-obj "^1.1.0"
-
-github-username@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/github-username/-/github-username-3.0.0.tgz#0a772219b3130743429f2456d0bdd3db55dce7b1"
-  integrity sha1-CnciGbMTB0NCnyRW0L3T21Xc57E=
-  dependencies:
-    gh-got "^5.0.0"
-
-github-username@^4.0.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/github-username/-/github-username-4.1.0.tgz#cbe280041883206da4212ae9e4b5f169c30bf417"
-  integrity sha1-y+KABBiDIG2kISrp5LXxacML9Bc=
-  dependencies:
-    gh-got "^6.0.0"
-
-glob-base@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/glob-base/-/glob-base-0.3.0.tgz#dbb164f6221b1c0b1ccf82aea328b497df0ea3c4"
-  integrity sha1-27Fk9iIbHAscz4Kuoyi0l98Oo8Q=
-  dependencies:
-    glob-parent "^2.0.0"
-    is-glob "^2.0.0"
-
-glob-parent@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-2.0.0.tgz#81383d72db054fcccf5336daa902f182f6edbb28"
-  integrity sha1-gTg9ctsFT8zPUzbaqQLxgvbtuyg=
-  dependencies:
-    is-glob "^2.0.0"
-
-glob-parent@^3.0.0, glob-parent@^3.1.0:
+glob-parent@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-3.1.0.tgz#9e6af6299d8d3bd2bd40430832bd113df906c5ae"
   integrity sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=
@@ -4835,58 +1552,22 @@
     is-glob "^3.1.0"
     path-dirname "^1.0.0"
 
-glob-parent@^5.0.0, glob-parent@^5.1.0:
+glob-parent@^5.1.2:
   version "5.1.2"
-  resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
   integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
   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"
-  integrity sha1-pVZlqajM3EGRWofHAeMtTgFvrSI=
-  dependencies:
-    extend "^3.0.0"
-    glob "^5.0.3"
-    glob-parent "^3.0.0"
-    micromatch "^2.3.7"
-    ordered-read-streams "^0.3.0"
-    through2 "^0.6.0"
-    to-absolute-glob "^0.1.1"
-    unique-stream "^2.0.2"
-
 glob-to-regexp@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab"
   integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs=
 
-glob@^5.0.3:
-  version "5.0.15"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-5.0.15.tgz#1bc936b9e02f4a603fcc222ecf7633d30b8b93b1"
-  integrity sha1-G8k2ueAvSmA/zCIuz3Yz0wuLk7E=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^6.0.1:
-  version "6.0.4"
-  resolved "https://registry.yarnpkg.com/glob/-/glob-6.0.4.tgz#0f08860f6a155127b2fadd4f9ce24b1aab6e4d22"
-  integrity sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=
-  dependencies:
-    inflight "^1.0.4"
-    inherits "2"
-    minimatch "2 || 3"
-    once "^1.3.0"
-    path-is-absolute "^1.0.0"
-
-glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4:
-  version "7.1.6"
-  resolved "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz"
-  integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
+glob@^7.1.3:
+  version "7.1.7"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90"
+  integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
   dependencies:
     fs.realpath "^1.0.0"
     inflight "^1.0.4"
@@ -4895,86 +1576,24 @@
     once "^1.3.0"
     path-is-absolute "^1.0.0"
 
-global-dirs@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445"
-  integrity sha1-sxnA3UYH81PzvpzKTHL8FIxJ9EU=
-  dependencies:
-    ini "^1.3.4"
-
 global-dirs@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686"
   integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==
   dependencies:
     ini "2.0.0"
 
-global-modules@^0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-0.2.3.tgz#ea5a3bed42c6d6ce995a4f8a1269b5dae223828d"
-  integrity sha1-6lo77ULG1s6ZWk+KEmm12uIjgo0=
-  dependencies:
-    global-prefix "^0.1.4"
-    is-windows "^0.2.0"
-
-global-modules@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
-  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
-  dependencies:
-    global-prefix "^1.0.1"
-    is-windows "^1.0.1"
-    resolve-dir "^1.0.0"
-
-global-prefix@^0.1.4:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-0.1.5.tgz#8d3bc6b8da3ca8112a160d8d496ff0462bfef78f"
-  integrity sha1-jTvGuNo8qBEqFg2NSW/wRiv+948=
-  dependencies:
-    homedir-polyfill "^1.0.0"
-    ini "^1.3.4"
-    is-windows "^0.2.0"
-    which "^1.2.12"
-
-global-prefix@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
-  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
-  dependencies:
-    expand-tilde "^2.0.2"
-    homedir-polyfill "^1.0.1"
-    ini "^1.3.4"
-    is-windows "^1.0.1"
-    which "^1.2.14"
-
-globals@^11.1.0:
-  version "11.12.0"
-  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
-  integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
-
-globals@^12.1.0:
-  version "12.4.0"
-  resolved "https://registry.npmjs.org/globals/-/globals-12.4.0.tgz"
-  integrity sha512-BWICuzzDvDoH54NHKCseDanAhE3CeDorgDL5MT6LMXXj2WCnd9UC2szdk4AWLfjdgNBCXLUanXYcpBBKOSWGwg==
-  dependencies:
-    type-fest "^0.8.1"
-
-globals@^13.6.0:
-  version "13.8.0"
-  resolved "https://registry.npmjs.org/globals/-/globals-13.8.0.tgz"
-  integrity sha512-rHtdA6+PDBIjeEvA91rpqzEvk/k3/i7EeNQiryiWuJH0Hw9cpyJMAt2jtbAwUaRdhD+573X4vWw6IcjKPasi9Q==
+globals@^13.6.0, globals@^13.9.0:
+  version "13.11.0"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.11.0.tgz#40ef678da117fe7bd2e28f1fab24951bd0255be7"
+  integrity sha512-08/xrJ7wQjK9kkkRoI3OFUBbLx4f+6x3SGwcPvQ0QH6goFDrOU2oyAWrmh3dJezu65buo+HBMzAMQy6rovVC3g==
   dependencies:
     type-fest "^0.20.2"
 
-globals@^9.18.0:
-  version "9.18.0"
-  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.3"
-  resolved "https://registry.npmjs.org/globby/-/globby-11.0.3.tgz"
-  integrity sha512-ffdmosjA807y7+lA1NM0jELARVmYul/715xiILEjo3hBLPTcirgQNnXECn5g3mtR8TOLCVbkfua1Hpen25/Xcg==
+globby@^11.0.3:
+  version "11.0.4"
+  resolved "https://registry.yarnpkg.com/globby/-/globby-11.0.4.tgz#2cbaff77c2f2a62e71e9b2813a67b97a3a3001a5"
+  integrity sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg==
   dependencies:
     array-union "^2.1.0"
     dir-glob "^3.0.1"
@@ -4983,139 +1602,14 @@
     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"
-  integrity sha1-CA9UVJ7BuCpsYOYx/ILhIR2+lfg=
-  dependencies:
-    array-union "^1.0.1"
-    arrify "^1.0.0"
-    glob "^6.0.1"
-    object-assign "^4.0.1"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-globby@^6.1.0:
-  version "6.1.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-6.1.0.tgz#f5a6d70e8395e21c858fb0489d64df02424d506c"
-  integrity sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=
-  dependencies:
-    array-union "^1.0.1"
-    glob "^7.0.3"
-    object-assign "^4.0.1"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-globby@^8.0.1:
-  version "8.0.2"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-8.0.2.tgz#5697619ccd95c5275dbb2d6faa42087c1a941d8d"
-  integrity sha512-yTzMmKygLp8RUpG1Ymu2VXPSJQZjNAZPD4ywgYEaG7e4tBJeUQBO8OpXrf1RCNcEs5alsoJYPAMiIHP0cmeC7w==
-  dependencies:
-    array-union "^1.0.1"
-    dir-glob "2.0.0"
-    fast-glob "^2.0.2"
-    glob "^7.1.2"
-    ignore "^3.3.5"
-    pify "^3.0.0"
-    slash "^1.0.0"
-
-globby@^9.2.0:
-  version "9.2.0"
-  resolved "https://registry.yarnpkg.com/globby/-/globby-9.2.0.tgz#fd029a706c703d29bdd170f4b6db3a3f7a7cb63d"
-  integrity sha512-ollPHROa5mcxDEkwg6bPt3QbEf4pDQSNtd6JPL1YvOvAo/7/0VAm9TccUeoTmarjPw4pfUthSCqcyfNB1I3ZSg==
-  dependencies:
-    "@types/glob" "^7.1.1"
-    array-union "^1.0.2"
-    dir-glob "^2.2.2"
-    fast-glob "^2.2.6"
-    glob "^7.1.3"
-    ignore "^4.0.3"
-    pify "^4.0.1"
-    slash "^2.0.0"
-
 google-protobuf@^3.6.1:
   version "3.19.4"
   resolved "https://registry.yarnpkg.com/google-protobuf/-/google-protobuf-3.19.4.tgz#8d32c3e34be9250956f28c0fb90955d13f311888"
   integrity sha512-OIPNCxsG2lkIvf+P5FNfJ/Km95CsXOBecS9ZcAU6m2Rq3svc0Apl9nB3GMDNKfQ9asNv4KjyAqGwPQFrVle3Yg==
 
-got@^11.8.0:
-  version "11.8.2"
-  resolved "https://registry.yarnpkg.com/got/-/got-11.8.2.tgz#7abb3959ea28c31f3576f1576c1effce23f33599"
-  integrity sha512-D0QywKgIe30ODs+fm8wMZiAcZjypcCodPNuMz5H9Mny7RJ+IjJ10BdmGW7OM7fHXP+O7r6ZwapQ/YQmMSvB0UQ==
-  dependencies:
-    "@sindresorhus/is" "^4.0.0"
-    "@szmarczak/http-timer" "^4.0.5"
-    "@types/cacheable-request" "^6.0.1"
-    "@types/responselike" "^1.0.0"
-    cacheable-lookup "^5.0.3"
-    cacheable-request "^7.0.1"
-    decompress-response "^6.0.0"
-    http2-wrapper "^1.0.0-beta.5.2"
-    lowercase-keys "^2.0.0"
-    p-cancelable "^2.0.0"
-    responselike "^2.0.0"
-
-got@^5.0.0:
-  version "5.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-5.7.1.tgz#5f81635a61e4a6589f180569ea4e381680a51f35"
-  integrity sha1-X4FjWmHkplifGAVp6k44FoClHzU=
-  dependencies:
-    create-error-class "^3.0.1"
-    duplexer2 "^0.1.4"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    node-status-codes "^1.0.0"
-    object-assign "^4.0.1"
-    parse-json "^2.1.0"
-    pinkie-promise "^2.0.0"
-    read-all-stream "^3.0.0"
-    readable-stream "^2.0.5"
-    timed-out "^3.0.0"
-    unzip-response "^1.0.2"
-    url-parse-lax "^1.0.0"
-
-got@^6.2.0, got@^6.7.1:
-  version "6.7.1"
-  resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
-  integrity sha1-JAzQV4WpoY5WHcG0S0HHY+8ejbA=
-  dependencies:
-    create-error-class "^3.0.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-redirect "^1.0.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    lowercase-keys "^1.0.0"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    unzip-response "^2.0.1"
-    url-parse-lax "^1.0.0"
-
-got@^7.0.0:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/got/-/got-7.1.0.tgz#05450fd84094e6bbea56f451a43a9c289166385a"
-  integrity sha512-Y5WMo7xKKq1muPsxD+KmrR8DH5auG7fBdDVueZwETwV6VytKyU9OX/ddpq2/1hp1vIPvVb4T81dKQz3BivkNLw==
-  dependencies:
-    decompress-response "^3.2.0"
-    duplexer3 "^0.1.4"
-    get-stream "^3.0.0"
-    is-plain-obj "^1.1.0"
-    is-retry-allowed "^1.0.0"
-    is-stream "^1.0.0"
-    isurl "^1.0.0-alpha5"
-    lowercase-keys "^1.0.0"
-    p-cancelable "^0.3.0"
-    p-timeout "^1.1.1"
-    safe-buffer "^5.0.1"
-    timed-out "^4.0.0"
-    url-parse-lax "^1.0.0"
-    url-to-options "^1.0.1"
-
 got@^9.6.0:
   version "9.6.0"
-  resolved "https://registry.npmjs.org/got/-/got-9.6.0.tgz"
+  resolved "https://registry.yarnpkg.com/got/-/got-9.6.0.tgz#edf45e7d67f99545705de1f7bbeeeb121765ed85"
   integrity sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==
   dependencies:
     "@sindresorhus/is" "^0.14.0"
@@ -5130,24 +1624,10 @@
     to-readable-stream "^1.0.0"
     url-parse-lax "^3.0.0"
 
-graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.2.0:
-  version "4.2.6"
-  resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz"
-  integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==
-
-grouped-queue@^0.3.0:
-  version "0.3.3"
-  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-0.3.3.tgz#c167d2a5319c5a0e0964ef6a25b7c2df8996c85c"
-  integrity sha1-wWfSpTGcWg4JZO9qJbfC34mWyFw=
-  dependencies:
-    lodash "^4.17.2"
-
-grouped-queue@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/grouped-queue/-/grouped-queue-1.1.0.tgz#63e3f9ca90af952269d1d40879e41221eacc74cb"
-  integrity sha512-rZOFKfCqLhsu5VqjBjEWiwrYqJR07KxIkH4mLZlNlGDfntbb4FbMyGFP14TlvRPrU9S3Hnn/sgxbC5ZeN0no3Q==
-  dependencies:
-    lodash "^4.17.15"
+graceful-fs@^4.1.2:
+  version "4.2.8"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
+  integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
 
 gts@^3.1.0:
   version "3.1.0"
@@ -5171,123 +1651,37 @@
     update-notifier "^5.0.0"
     write-file-atomic "^3.0.3"
 
-gulp-if@^2.0.2:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/gulp-if/-/gulp-if-2.0.2.tgz#a497b7e7573005041caa2bc8b7dda3c80444d629"
-  integrity sha1-pJe351cwBQQcqivIt92jyARE1ik=
-  dependencies:
-    gulp-match "^1.0.3"
-    ternary-stream "^2.0.1"
-    through2 "^2.0.1"
-
-gulp-match@^1.0.3:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/gulp-match/-/gulp-match-1.1.0.tgz#552b7080fc006ee752c90563f9fec9d61aafdf4f"
-  integrity sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==
-  dependencies:
-    minimatch "^3.0.3"
-
-gulp-sourcemaps@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/gulp-sourcemaps/-/gulp-sourcemaps-1.6.0.tgz#b86ff349d801ceb56e1d9e7dc7bbcb4b7dee600c"
-  integrity sha1-uG/zSdgBzrVuHZ59x7vLS33uYAw=
-  dependencies:
-    convert-source-map "^1.1.1"
-    graceful-fs "^4.1.2"
-    strip-bom "^2.0.0"
-    through2 "^2.0.0"
-    vinyl "^1.0.0"
-
-gunzip-maybe@^1.3.1:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz#b913564ae3be0eda6f3de36464837a9cd94b98ac"
-  integrity sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==
-  dependencies:
-    browserify-zlib "^0.1.4"
-    is-deflate "^1.0.0"
-    is-gzip "^1.0.0"
-    peek-stream "^1.1.0"
-    pumpify "^1.3.3"
-    through2 "^2.0.3"
-
-handle-thing@^1.2.5:
-  version "1.2.5"
-  resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-1.2.5.tgz#fd7aad726bf1a5fd16dfc29b2f7a6601d27139c4"
-  integrity sha1-/Xqtcmvxpf0W38KbL3pmAdJxOcQ=
-
-har-schema@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92"
-  integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=
-
-har-validator@~5.1.0, har-validator@~5.1.3:
-  version "5.1.5"
-  resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd"
-  integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==
-  dependencies:
-    ajv "^6.12.3"
-    har-schema "^2.0.0"
-
 hard-rejection@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
   integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
 
-has-ansi@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
-  integrity sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=
-  dependencies:
-    ansi-regex "^2.0.0"
-
 has-bigints@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.0.1.tgz#64fe6acb020673e3b78db035a5af69aa9d07b113"
   integrity sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==
 
-has-binary2@~1.0.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/has-binary2/-/has-binary2-1.0.3.tgz#7776ac627f3ea77250cfc332dab7ddf5e4f5d11d"
-  integrity sha512-G1LWKhDSvhGeAQ8mPVQlqNcOB2sJdwATtZKl2pDKKHfpf/rYj24lkinxf69blJbnsvtqqNU+L3SL50vzZhXOnw==
-  dependencies:
-    isarray "2.0.1"
-
-has-color@~0.1.0:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/has-color/-/has-color-0.1.7.tgz#67144a5260c34fc3cca677d041daf52fe7b78b2f"
-  integrity sha1-ZxRKUmDDT8PMpnfQQdr1L+e3iy8=
-
-has-cors@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/has-cors/-/has-cors-1.1.0.tgz#5e474793f7ea9843d1bb99c23eef49ff126fff39"
-  integrity sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk=
-
 has-flag@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd"
   integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0=
 
 has-flag@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
   integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
 
-has-symbol-support-x@^1.4.1:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/has-symbol-support-x/-/has-symbol-support-x-1.4.2.tgz#1409f98bc00247da45da67cee0a36f282ff26455"
-  integrity sha512-3ToOva++HaW+eCpgqZrCfN51IPB+7bJNVT6CUATzueB5Heb8o6Nam0V3HG5dlDvZU1Gn5QLcbahiKw/XVk5JJw==
-
 has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
 
-has-to-string-tag-x@^1.2.0:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/has-to-string-tag-x/-/has-to-string-tag-x-1.4.1.tgz#a045ab383d7b4b2012a00148ab0aa5f290044d4d"
-  integrity sha512-vdbKfmw+3LoOYVr+mtxHaX5a96+0f3DljYd8JOqvOLsf5mw2Otda2qCDT9qRqLAhrjyQ0h7ual5nOiASpsGNFw==
+has-tostringtag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
+  integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
   dependencies:
-    has-symbol-support-x "^1.4.1"
+    has-symbols "^1.0.2"
 
 has-value@^0.3.1:
   version "0.3.1"
@@ -5322,66 +1716,43 @@
 
 has-yarn@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77"
   integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==
 
 has@^1.0.3:
   version "1.0.3"
-  resolved "https://registry.npmjs.org/has/-/has-1.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
   integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
   dependencies:
     function-bind "^1.1.1"
 
-he@1.2.x:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
-  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
-
-homedir-polyfill@^1.0.0, homedir-polyfill@^1.0.1:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
-  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
-  dependencies:
-    parse-passwd "^1.0.0"
-
 hosted-git-info@^2.1.4:
   version "2.8.9"
-  resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
   integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
 hosted-git-info@^4.0.1:
   version "4.0.2"
-  resolved "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-4.0.2.tgz#5e425507eede4fea846b7262f0838456c4209961"
   integrity sha512-c9OGXbZ3guC/xOlCg1Ci/VgWlwsqDv1yMQL1CWqXDL0hDjXuNcq0zuR4xqPSuasI3kqFDhqSyTjREz5gzq0fXg==
   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"
-  integrity sha1-h3dMCUnlE/QuhFdbPEVoH63ioLI=
+htmlparser2@^3.9.1:
+  version "3.10.1"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
+  integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ==
   dependencies:
+    domelementtype "^1.3.1"
+    domhandler "^2.3.0"
+    domutils "^1.5.1"
+    entities "^1.1.1"
     inherits "^2.0.1"
-    obuf "^1.0.0"
-    readable-stream "^2.0.1"
-    wbuf "^1.1.0"
-
-html-minifier@^3.5.10:
-  version "3.5.21"
-  resolved "https://registry.yarnpkg.com/html-minifier/-/html-minifier-3.5.21.tgz#d0040e054730e354db008463593194015212d20c"
-  integrity sha512-LKUKwuJDhxNa3uf/LPR/KVjm/l3rBqtYeCOAekvG8F1vItxMUpueGd94i/asDDr8/1u7InxzFA5EeGjhhG5mMA==
-  dependencies:
-    camel-case "3.0.x"
-    clean-css "4.2.x"
-    commander "2.17.x"
-    he "1.2.x"
-    param-case "2.1.x"
-    relateurl "0.2.x"
-    uglify-js "3.4.x"
+    readable-stream "^3.1.1"
 
 htmlparser2@^6.0.1:
   version "6.1.0"
-  resolved "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
   integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==
   dependencies:
     domelementtype "^2.0.1"
@@ -5391,138 +1762,34 @@
 
 http-cache-semantics@^4.0.0:
   version "4.1.0"
-  resolved "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.0.tgz#49e91c5cbf36c9b94bcfcd71c23d5249ec74e390"
   integrity sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==
 
-http-deceiver@^1.2.7:
-  version "1.2.7"
-  resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87"
-  integrity sha1-+nFolEq5pRnTN8sL7HKE3D5yPYc=
-
-http-errors@1.7.2:
-  version "1.7.2"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f"
-  integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-errors@~1.6.2:
-  version "1.6.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d"
-  integrity sha1-i1VoC7S+KDoLW/TqLjhYC+HZMg0=
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.3"
-    setprototypeof "1.1.0"
-    statuses ">= 1.4.0 < 2"
-
-http-errors@~1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
-  integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
-  dependencies:
-    depd "~1.1.2"
-    inherits "2.0.4"
-    setprototypeof "1.1.1"
-    statuses ">= 1.5.0 < 2"
-    toidentifier "1.0.0"
-
-http-proxy-middleware@^0.17.2:
-  version "0.17.4"
-  resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833"
-  integrity sha1-ZC6ISIUdZvCdTxJJEoRtuutBuDM=
-  dependencies:
-    http-proxy "^1.16.2"
-    is-glob "^3.1.0"
-    lodash "^4.17.2"
-    micromatch "^2.3.11"
-
-http-proxy@^1.16.2:
-  version "1.18.1"
-  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
-  integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
-  dependencies:
-    eventemitter3 "^4.0.0"
-    follow-redirects "^1.0.0"
-    requires-port "^1.0.0"
-
-http-signature@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
-  integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=
-  dependencies:
-    assert-plus "^1.0.0"
-    jsprim "^1.2.2"
-    sshpk "^1.7.0"
-
-http2-wrapper@^1.0.0-beta.5.2:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d"
-  integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==
-  dependencies:
-    quick-lru "^5.1.1"
-    resolve-alpn "^1.0.0"
-
-https-proxy-agent@^2.2.1:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.4.tgz#4ee7a737abd92678a293d9b34a1af4d0d08c787b"
-  integrity sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==
-  dependencies:
-    agent-base "^4.3.0"
-    debug "^3.1.0"
-
-https-proxy-agent@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz#e2a90542abb68a762e0a0850f6c9edadfd8506b2"
-  integrity sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==
-  dependencies:
-    agent-base "6"
-    debug "4"
-
-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.npmjs.org/human-signals/-/human-signals-2.1.0.tgz"
+  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.24:
   version "0.4.24"
-  resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-ieee754@^1.1.13:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
-  integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
-
-ignore@^3.3.5:
-  version "3.3.10"
-  resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
-  integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
-
-ignore@^4.0.3, ignore@^4.0.6:
+ignore@^4.0.6:
   version "4.0.6"
-  resolved "https://registry.npmjs.org/ignore/-/ignore-4.0.6.tgz"
+  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.4:
   version "5.1.8"
-  resolved "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.8.tgz#f150a8b50a34289b33e22f5889abd4d8016f0e57"
   integrity sha512-BMpfD7PpiETpBl/A6S498BaIJ6Y/ABT93ETbby2fP00v4EbvPBXWEoaR1UBPKs3iR53pJY7EtZk5KACI57i1Uw==
 
 import-fresh@^3.0.0, import-fresh@^3.2.1:
   version "3.3.0"
-  resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz"
+  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"
@@ -5530,87 +1797,45 @@
 
 import-lazy@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43"
   integrity sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=
 
 imurmurhash@^0.1.4:
   version "0.1.4"
-  resolved "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz"
+  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
   integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
 
-indent-string@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80"
-  integrity sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=
-  dependencies:
-    repeating "^2.0.0"
-
 indent-string@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
   integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
 
-indent@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/indent/-/indent-0.0.2.tgz#8c79f080190559b687034b84c7aefa97d5a911d9"
-  integrity sha1-jHnwgBkFWbaHA0uEx676l9WpEdk=
-
-indexof@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
-  integrity sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=
-
 inflight@^1.0.4:
   version "1.0.6"
-  resolved "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
   integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
   dependencies:
     once "^1.3.0"
     wrappy "1"
 
-inherits@2, inherits@2.0.4, inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.1, inherits@~2.0.3:
+inherits@2, inherits@^2.0.1, inherits@^2.0.3:
   version "2.0.4"
-  resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
   integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
 
-inherits@2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
-  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
-
 ini@2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5"
   integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==
 
-ini@^1.3.4, ini@~1.3.0:
+ini@~1.3.0:
   version "1.3.8"
-  resolved "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz"
+  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
   integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
 
-inquirer@^1.0.2:
-  version "1.2.3"
-  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-1.2.3.tgz#4dec6f32f37ef7bb0b2ed3f1d1a5c3f545074918"
-  integrity sha1-TexvMvN+97sLLtPx0aXD9UUHSRg=
-  dependencies:
-    ansi-escapes "^1.1.0"
-    chalk "^1.0.0"
-    cli-cursor "^1.0.1"
-    cli-width "^2.0.0"
-    external-editor "^1.1.0"
-    figures "^1.3.5"
-    lodash "^4.3.0"
-    mute-stream "0.0.6"
-    pinkie-promise "^2.0.0"
-    run-async "^2.2.0"
-    rx "^4.1.0"
-    string-width "^1.0.1"
-    strip-ansi "^3.0.0"
-    through "^2.3.6"
-
-inquirer@^7.1.0, inquirer@^7.3.3:
+inquirer@^7.3.3:
   version "7.3.3"
-  resolved "https://registry.npmjs.org/inquirer/-/inquirer-7.3.3.tgz"
+  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.3.3.tgz#04d176b2af04afc157a83fd7c100e98ee0aad003"
   integrity sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==
   dependencies:
     ansi-escapes "^4.2.1"
@@ -5627,27 +1852,14 @@
     strip-ansi "^6.0.0"
     through "^2.3.6"
 
-interpret@^1.0.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
-  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
-
-intersect@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/intersect/-/intersect-1.0.1.tgz#332650e10854d8c0ac58c192bdc27a8bf7e7a30c"
-  integrity sha1-MyZQ4QhU2MCsWMGSvcJ6i/fnoww=
-
-invariant@^2.2.2:
-  version "2.2.4"
-  resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
-  integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==
+internal-slot@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
+  integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==
   dependencies:
-    loose-envify "^1.0.0"
-
-ipaddr.js@1.9.1:
-  version "1.9.1"
-  resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
-  integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
+    get-intrinsic "^1.1.0"
+    has "^1.0.3"
+    side-channel "^1.0.4"
 
 is-accessor-descriptor@^0.1.6:
   version "0.1.6"
@@ -5665,61 +1877,45 @@
 
 is-arrayish@^0.2.1:
   version "0.2.1"
-  resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"
+  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
   integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
 
-is-arrayish@^0.3.1:
-  version "0.3.2"
-  resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
-  integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
-
 is-bigint@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.1.tgz"
-  integrity sha512-J0ELF4yHFxHy0cmSxZuheDOz2luOdVvqjwmEcj8H/L1JHeuEDSDbeRP+Dk9kFVk5RTFzbucJ2Kb9F7ixY2QaCg==
-
-is-binary-path@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
-  integrity sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3"
+  integrity sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==
   dependencies:
-    binary-extensions "^1.0.0"
+    has-bigints "^1.0.1"
 
 is-boolean-object@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.0.tgz"
-  integrity sha512-a7Uprx8UtD+HWdyYwnD1+ExtTgqQtD2k/1yJgtXP6wnMm8byhkoTZRl+95LLThpzNZJ5aEvi46cdH+ayMFRwmA==
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.1.2.tgz#5c6dc200246dd9321ae4b885a114bb1f75f63719"
+  integrity sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==
   dependencies:
-    call-bind "^1.0.0"
+    call-bind "^1.0.2"
+    has-tostringtag "^1.0.0"
 
-is-buffer@^1.1.5, is-buffer@~1.1.6:
+is-buffer@^1.1.5:
   version "1.1.6"
   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.2.3:
-  version "1.2.3"
-  resolved "https://registry.npmjs.org/is-callable/-/is-callable-1.2.3.tgz"
-  integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==
-
-is-ci@^1.0.10:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
-  integrity sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==
-  dependencies:
-    ci-info "^1.5.0"
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
+  integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
 
 is-ci@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
   integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==
   dependencies:
     ci-info "^2.0.0"
 
-is-core-module@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz"
-  integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==
+is-core-module@^2.2.0, is-core-module@^2.5.0, is-core-module@^2.6.0:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.6.0.tgz#d7553b2526fe59b92ba3e40c8df757ec8a709e19"
+  integrity sha512-wShG8vs60jKfPWpF2KZRaAtvt3a20OAn7+IJ6hLPECpSABLcKtFKTTI4ZtH5QcBruBHlq+WsdHWyz0BCZW7svQ==
   dependencies:
     has "^1.0.3"
 
@@ -5738,14 +1934,11 @@
     kind-of "^6.0.0"
 
 is-date-object@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz"
-  integrity sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==
-
-is-deflate@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-deflate/-/is-deflate-1.0.0.tgz#c862901c3c161fb09dac7cdc7e784f80e98f2f14"
-  integrity sha1-yGKQHDwWH7CdrHzcfnhPgOmPLxQ=
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f"
+  integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==
+  dependencies:
+    has-tostringtag "^1.0.0"
 
 is-descriptor@^0.1.0:
   version "0.1.6"
@@ -5765,18 +1958,6 @@
     is-data-descriptor "^1.0.0"
     kind-of "^6.0.2"
 
-is-dotfile@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/is-dotfile/-/is-dotfile-1.0.3.tgz#a6a2f32ffd2dfb04f5ca25ecd0f6b83cf798a1e1"
-  integrity sha1-pqLzL/0t+wT1yiXs0Pa4PPeYoeE=
-
-is-equal-shallow@^0.1.3:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/is-equal-shallow/-/is-equal-shallow-0.1.3.tgz#2238098fc221de0bcfa5d9eac4c45d638aa1c534"
-  integrity sha1-IjgJj8Ih3gvPpdnqxMRdY4qhxTQ=
-  dependencies:
-    is-primitive "^2.0.0"
-
 is-extendable@^0.1.0, is-extendable@^0.1.1:
   version "0.1.1"
   resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89"
@@ -5789,45 +1970,21 @@
   dependencies:
     is-plain-object "^2.0.4"
 
-is-extglob@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-1.0.0.tgz#ac468177c4943405a092fc8f29760c6ffc6206c0"
-  integrity sha1-rEaBd8SUNAWgkvyPKXYMb/xiBsA=
-
 is-extglob@^2.1.0, is-extglob@^2.1.1:
   version "2.1.1"
-  resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-finite@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-finite/-/is-finite-1.1.0.tgz#904135c77fb42c0641d6aa1bcdbc4daa8da082f3"
-  integrity sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==
-
-is-fullwidth-code-point@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz#ef9e31386f031a7f0d643af82fde50c457ef00cb"
-  integrity sha1-754xOG8DGn8NZDr4L95QxFfvAMs=
-  dependencies:
-    number-is-nan "^1.0.0"
-
 is-fullwidth-code-point@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f"
   integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=
 
 is-fullwidth-code-point@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
   integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
 
-is-glob@^2.0.0, is-glob@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-2.0.1.tgz#d096f926a3ded5600f3fdfd91198cb0888c2d863"
-  integrity sha1-0Jb5JqPe1WAPP9/ZEZjLCIjC2GM=
-  dependencies:
-    is-extglob "^1.0.0"
-
 is-glob@^3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-3.1.0.tgz#7ba5ae24217804ac70707b96922567486cc3e84a"
@@ -5837,27 +1994,14 @@
 
 is-glob@^4.0.0, is-glob@^4.0.1:
   version "4.0.1"
-  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc"
   integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
   dependencies:
     is-extglob "^2.1.1"
 
-is-gzip@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-gzip/-/is-gzip-1.0.0.tgz#6ca8b07b99c77998025900e555ced8ed80879a83"
-  integrity sha1-bKiwe5nHeZgCWQDlVc7Y7YCHmoM=
-
-is-installed-globally@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.1.0.tgz#0dfd98f5a9111716dd535dda6492f67bf3d25a80"
-  integrity sha1-Df2Y9akRFxbdU13aZJL2e/PSWoA=
-  dependencies:
-    global-dirs "^0.1.0"
-    is-path-inside "^1.0.0"
-
 is-installed-globally@^0.4.0:
   version "0.4.0"
-  resolved "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520"
   integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==
   dependencies:
     global-dirs "^3.0.0"
@@ -5865,30 +2009,20 @@
 
 is-negative-zero@^2.0.1:
   version "2.0.1"
-  resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.1.tgz"
+  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@^5.0.0:
   version "5.0.0"
-  resolved "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz"
+  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-object@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.4.tgz"
-  integrity sha512-zohwelOAur+5uXtk8O3GPQ1eAcu4ZX3UwxQhUlfFFMNpUd83gXgjbhJh6HmB6LUNV/ieOLQuDwJO3dWJosUeMw==
-
-is-number@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/is-number/-/is-number-2.1.0.tgz#01fcbbb393463a548f2f466cce16dece49db908f"
-  integrity sha1-Afy7s5NGOlSPL0ZszhbezknbkI8=
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.0.6.tgz#6a7aaf838c7f0686a50b4553f7e54a96494e89f0"
+  integrity sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==
   dependencies:
-    kind-of "^3.0.2"
+    has-tostringtag "^1.0.0"
 
 is-number@^3.0.0:
   version "3.0.0"
@@ -5897,58 +2031,24 @@
   dependencies:
     kind-of "^3.0.2"
 
-is-number@^4.0.0:
-  version "4.0.0"
-  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.npmjs.org/is-number/-/is-number-7.0.0.tgz"
+  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"
-  integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8=
-
 is-obj@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982"
   integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==
 
-is-object@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.2.tgz#a56552e1c665c9e950b4a025461da87e72f86fcf"
-  integrity sha512-2rRIahhZr2UWb45fIOuvZGpFtz0TyOZLf32KxBbSoUCeZR495zCKlWUKKUByk3geS2eAs7ZAABt0Y/Rx0GiQGA==
-
-is-path-cwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-1.0.0.tgz#d225ec23132e89edd38fda767472e62e65f1106d"
-  integrity sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=
-
-is-path-in-cwd@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz#5ac48b345ef675339bd6c7a48a912110b241cf52"
-  integrity sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==
-  dependencies:
-    is-path-inside "^1.0.0"
-
-is-path-inside@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-1.0.1.tgz#8ef5b7de50437a3fdca6b4e865ef7aa55cb48036"
-  integrity sha1-jvW33lBDej/cprToZe96pVy0gDY=
-  dependencies:
-    path-is-inside "^1.0.1"
-
 is-path-inside@^3.0.2:
   version "3.0.3"
-  resolved "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283"
   integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==
 
-is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
+is-plain-obj@^1.1.0:
   version "1.1.0"
-  resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
   integrity sha1-caUMhCnfync8kqOQpKA7OfzVHT4=
 
 is-plain-object@^2.0.3, is-plain-object@^2.0.4:
@@ -5958,133 +2058,56 @@
   dependencies:
     isobject "^3.0.1"
 
-is-plain-object@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344"
-  integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==
-
-is-posix-bracket@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/is-posix-bracket/-/is-posix-bracket-0.1.1.tgz#3334dc79774368e92f016e6fbc0a88f5cd6e6bc4"
-  integrity sha1-MzTceXdDaOkvAW5vvAqI9c1ua8Q=
-
-is-potential-custom-element-name@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5"
-  integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==
-
-is-primitive@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575"
-  integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU=
-
-is-redirect@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-redirect/-/is-redirect-1.0.0.tgz#1d03dded53bd8db0f30c26e4f95d36fc7c87dc24"
-  integrity sha1-HQPd7VO9jbDzDCbk+V02/HyH3CQ=
-
-is-regex@^1.1.2:
-  version "1.1.2"
-  resolved "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz"
-  integrity sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==
+is-regex@^1.1.3:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
+  integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
   dependencies:
     call-bind "^1.0.2"
-    has-symbols "^1.0.1"
-
-is-retry-allowed@^1.0.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
-  integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
-
-is-scoped@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/is-scoped/-/is-scoped-1.0.0.tgz#449ca98299e713038256289ecb2b540dc437cb30"
-  integrity sha1-RJypgpnnEwOCViieyytUDcQ3yzA=
-  dependencies:
-    scoped-regex "^1.0.0"
-
-is-stream@^1.0.0, is-stream@^1.0.1, is-stream@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44"
-  integrity sha1-EtSj3U5o4Lec6428hBc66A2RykQ=
+    has-tostringtag "^1.0.0"
 
 is-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz"
-  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077"
+  integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==
 
-is-string@^1.0.5:
-  version "1.0.5"
-  resolved "https://registry.npmjs.org/is-string/-/is-string-1.0.5.tgz"
-  integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
+is-string@^1.0.5, is-string@^1.0.6:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
+  integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
+  dependencies:
+    has-tostringtag "^1.0.0"
 
 is-symbol@^1.0.2, is-symbol@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz"
-  integrity sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c"
+  integrity sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==
   dependencies:
-    has-symbols "^1.0.1"
+    has-symbols "^1.0.2"
 
-is-typedarray@^1.0.0, is-typedarray@~1.0.0:
+is-typedarray@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
   integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
 
-is-utf8@^0.2.0, is-utf8@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/is-utf8/-/is-utf8-0.2.1.tgz#4b0da1442104d1b336340e80797e865cf39f7d72"
-  integrity sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=
-
-is-valid-glob@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/is-valid-glob/-/is-valid-glob-0.3.0.tgz#d4b55c69f51886f9b65c70d6c2622d37e29f48fe"
-  integrity sha1-1LVcafUYhvm2XHDWwmItN+KfSP4=
-
-is-windows@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-0.2.0.tgz#de1aa6d63ea29dd248737b69f1ff8b8002d2108c"
-  integrity sha1-3hqm1j6indJIc3tp8f+LgALSEIw=
-
-is-windows@^1.0.1, is-windows@^1.0.2:
+is-windows@^1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
   integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 
 is-yarn-global@^0.3.0:
   version "0.3.0"
-  resolved "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232"
   integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==
 
-isarray@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf"
-  integrity sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=
-
-isarray@1.0.0, isarray@^1.0.0, isarray@~1.0.0:
+isarray@1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
   integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
 
-isarray@2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.1.tgz#a37d94ed9cda2d59865c9f76fe596ee1f338741e"
-  integrity sha1-o32U7ZzaLVmGXJ92/llu4fM4dB4=
-
-isbinaryfile@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-3.0.3.tgz#5d6def3edebf6e8ca8cae9c30183a804b5f8be80"
-  integrity sha512-8cJBL5tTd2OS0dM4jz07wQd5g0dCCqIhUxPIGtZfa5L6hWlvV5MHTITy/DBAsF+Oe2LS1X3krBUhNwaGUWpWxw==
-  dependencies:
-    buffer-alloc "^1.2.0"
-
-isbinaryfile@^4.0.0:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.6.tgz#edcb62b224e2b4710830b67498c8e4e5a4d2610b"
-  integrity sha512-ORrEy+SNVqUhrCaal4hA4fBzhggQQ+BaLntyPOdoEiwlKZW9BZiJXjg3RMiruE4tPEI3pyVPpySHQF/dKWperg==
-
 isexe@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
 
 isobject@^2.0.0:
@@ -6099,91 +2122,29 @@
   resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
   integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
 
-isstream@~0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
-  integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=
-
-istextorbinary@^2.2.1, istextorbinary@^2.5.1:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-2.6.0.tgz#60776315fb0fa3999add276c02c69557b9ca28ab"
-  integrity sha512-+XRlFseT8B3L9KyjxxLjfXSLMuErKDsd8DBNrsaxoViABMEZlOSCstwmw0qpoFX3+U6yWU1yhLudAe6/lETGGA==
-  dependencies:
-    binaryextensions "^2.1.2"
-    editions "^2.2.0"
-    textextensions "^2.5.0"
-
-isurl@^1.0.0-alpha5:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/isurl/-/isurl-1.0.0.tgz#b27f4f49f3cdaa3ea44a0a5b7f3462e6edc39d67"
-  integrity sha512-1P/yWsxPlDtn7QeRD+ULKQPaIaN6yF368GZ2vDfv0AL0NwpStafjWCDDdn0k8wgFMWpVAqG7oJhxHnlud42i9w==
-  dependencies:
-    has-to-string-tag-x "^1.2.0"
-    is-object "^1.0.1"
-
-jake@^10.6.1:
-  version "10.8.2"
-  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
-  integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==
-  dependencies:
-    async "0.9.x"
-    chalk "^2.4.2"
-    filelist "^1.0.1"
-    minimatch "^3.0.4"
-
-"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
+js-tokens@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
   integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
 
-js-tokens@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b"
-  integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls=
-
 js-yaml@^3.13.1:
   version "3.14.1"
-  resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537"
   integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==
   dependencies:
     argparse "^1.0.7"
     esprima "^4.0.0"
 
-jsbn@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
-  integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
-
 jsdoctypeparser@^9.0.0:
   version "9.0.0"
-  resolved "https://registry.npmjs.org/jsdoctypeparser/-/jsdoctypeparser-9.0.0.tgz"
+  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"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-1.3.0.tgz#46c3fec8c1892b12b0833db9bc7622176dbab34b"
-  integrity sha1-RsP+yMGJKxKwgz25vHYiF226s0s=
-
-jsesc@^2.5.1:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4"
-  integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==
-
-jsesc@~0.5.0:
-  version "0.5.0"
-  resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
-  integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
-
 json-buffer@3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.0.tgz#5b1f397afc75d677bde8bcfc0e47e1f9a3d9a898"
   integrity sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=
 
-json-buffer@3.0.1:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13"
-  integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==
-
 json-parse-better-errors@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@@ -6191,82 +2152,45 @@
 
 json-parse-even-better-errors@^2.3.0:
   version "2.3.1"
-  resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"
+  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.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
   integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
 
 json-schema-traverse@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2"
   integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==
 
-json-schema@0.2.3:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13"
-  integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=
-
 json-stable-stringify-without-jsonify@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
   integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
 
-json-stringify-safe@~5.0.1:
-  version "5.0.1"
-  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.npmjs.org/json5/-/json5-1.0.1.tgz"
+  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.2, json5@^2.1.3:
+json5@^2.1.3:
   version "2.2.0"
-  resolved "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
   integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
   dependencies:
     minimist "^1.2.5"
 
-jsonparse@^1.2.0:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280"
-  integrity sha1-P02uSpH6wxX3EGL4UhzCOfE2YoA=
-
-jsonschema@^1.1.0, jsonschema@^1.1.1:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/jsonschema/-/jsonschema-1.4.0.tgz#1afa34c4bc22190d8e42271ec17ac8b3404f87b2"
-  integrity sha512-/YgW6pRMr6M7C+4o8kS+B/2myEpHCrxO4PEWnqJNBFMjn7EWXqlQ4tGwL6xTHeRplwuZmcAncdvfOad1nT2yMw==
-
-jsprim@^1.2.2:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2"
-  integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=
-  dependencies:
-    assert-plus "1.0.0"
-    extsprintf "1.3.0"
-    json-schema "0.2.3"
-    verror "1.10.0"
-
 keyv@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
   integrity sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==
   dependencies:
     json-buffer "3.0.0"
 
-keyv@^4.0.0:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.0.3.tgz#4f3aa98de254803cafcd2896734108daa35e4254"
-  integrity sha512-zdGa2TOpSZPq5mU6iowDARnMBZgtCqJ11dJROFi6tg6kTn4nuUdU09lFyLFSaHrWqpIJ+EBq4E8/Dc0Vx5vLdA==
-  dependencies:
-    json-buffer "3.0.1"
-
 kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
   version "3.2.2"
   resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
@@ -6288,71 +2212,24 @@
 
 kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3:
   version "6.0.3"
-  resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
   integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
 
-kuler@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/kuler/-/kuler-2.0.0.tgz#e2c570a3800388fb44407e851531c1d670b061b3"
-  integrity sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==
-
-latest-version@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-2.0.0.tgz#56f8d6139620847b8017f8f1f4d78e211324168b"
-  integrity sha1-VvjWE5YghHuAF/jx9NeOIRMkFos=
-  dependencies:
-    package-json "^2.0.0"
-
-latest-version@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-3.1.0.tgz#a205383fea322b33b5ae3b18abee0dc2f356ee15"
-  integrity sha1-ogU4P+oyKzO1rjsYq+4NwvNW7hU=
-  dependencies:
-    package-json "^4.0.0"
-
 latest-version@^5.1.0:
   version "5.1.0"
-  resolved "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face"
   integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==
   dependencies:
     package-json "^6.3.0"
 
-launchpad@^0.7.0:
-  version "0.7.5"
-  resolved "https://registry.yarnpkg.com/launchpad/-/launchpad-0.7.5.tgz#a16950c937572f10ef01c9be945a96f7aef8e427"
-  integrity sha512-gsYFgT8XKL3X2XZHPPPrgwM0JqeQwGpSWnzg7EYadBY3MirbQrTVq6L4fm6l7UE2T+7gnfuhiGkKr/xxuU/fdw==
-  dependencies:
-    async "^2.0.1"
-    browserstack "^1.2.0"
-    debug "^2.2.0"
-    mkdirp "^0.5.1"
-    plist "^2.0.1"
-    q "^1.4.1"
-    rimraf "^3.0.0"
-    underscore "^1.8.3"
-
-lazy-cache@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-2.0.2.tgz#b9190a4f913354694840859f8a8f7084d8822264"
-  integrity sha1-uRkKT5EzVGlIQIWfio9whNiCImQ=
-  dependencies:
-    set-getter "^0.1.0"
-
-lazy-req@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/lazy-req/-/lazy-req-1.1.0.tgz#bdaebead30f8d824039ce0ce149d4daa07ba1fac"
-  integrity sha1-va6+rTD42CQDnODOFJ1Nqge6H6w=
-
-lazystream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
-  integrity sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=
-  dependencies:
-    readable-stream "^2.0.5"
+leven@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2"
+  integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==
 
 levn@^0.4.1:
   version "0.4.1"
-  resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
   integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
   dependencies:
     prelude-ls "^1.2.1"
@@ -6360,29 +2237,22 @@
 
 lines-and-columns@^1.1.6:
   version "1.1.6"
-  resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz"
+  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
   integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=
 
-load-json-file@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
-  integrity sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=
+lit-analyzer@1.2.1, lit-analyzer@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/lit-analyzer/-/lit-analyzer-1.2.1.tgz#725331a4019ae870dd631d4dd709d39a237161ea"
+  integrity sha512-OEARBhDidyaQENavLbzpTKbEmu5rnAI+SdYsH4ia1BlGlLiqQXoym7uH1MaRPtwtUPbkhUfT4OBDZ+74VHc3Cg==
   dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^2.2.0"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-    strip-bom "^2.0.0"
-
-load-json-file@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz"
-  integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=
-  dependencies:
-    graceful-fs "^4.1.2"
-    parse-json "^2.2.0"
-    pify "^2.0.0"
-    strip-bom "^3.0.0"
+    chalk "^2.4.2"
+    didyoumean2 "4.1.0"
+    fast-glob "^2.2.6"
+    parse5 "5.1.0"
+    ts-simple-type "~1.0.5"
+    vscode-css-languageservice "4.3.0"
+    vscode-html-languageservice "3.1.0"
+    web-component-analyzer "~1.1.1"
 
 load-json-file@^4.0.0:
   version "4.0.0"
@@ -6396,250 +2266,69 @@
 
 locate-path@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
   integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=
   dependencies:
     p-locate "^2.0.0"
     path-exists "^3.0.0"
 
-locate-path@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e"
-  integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==
-  dependencies:
-    p-locate "^3.0.0"
-    path-exists "^3.0.0"
-
 locate-path@^5.0.0:
   version "5.0.0"
-  resolved "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
   integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
   dependencies:
     p-locate "^4.1.0"
 
-lodash._reinterpolate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d"
-  integrity sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=
-
-lodash.camelcase@^4.3.0:
-  version "4.3.0"
-  resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
-  integrity sha1-soqmKIorn8ZRA1x3EfZathkDMaY=
-
 lodash.clonedeep@^4.5.0:
   version "4.5.0"
-  resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz"
+  resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
   integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=
 
-lodash.defaults@^4.2.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
-  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
-
-lodash.difference@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.difference/-/lodash.difference-4.5.0.tgz#9ccb4e505d486b91651345772885a2df27fd017c"
-  integrity sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw=
-
-lodash.flatten@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz"
-  integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8=
-
-lodash.get@^4.4.2:
-  version "4.4.2"
-  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
-  integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
-
-lodash.isequal@^4.0.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0"
-  integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA=
-
-lodash.isplainobject@^4.0.6:
-  version "4.0.6"
-  resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
-  integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
-
-lodash.mapvalues@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
-  integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=
+lodash.deburr@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/lodash.deburr/-/lodash.deburr-4.1.0.tgz#ddb1bbb3ef07458c0177ba07de14422cb033ff9b"
+  integrity sha1-3bG7s+8HRYwBd7oH3hRCLLAz/5s=
 
 lodash.merge@^4.6.2:
   version "4.6.2"
   resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
   integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
 
-lodash.padend@^4.6.1:
-  version "4.6.1"
-  resolved "https://registry.yarnpkg.com/lodash.padend/-/lodash.padend-4.6.1.tgz#53ccba047d06e158d311f45da625f4e49e6f166e"
-  integrity sha1-U8y6BH0G4VjTEfRdpiX05J5vFm4=
-
-lodash.set@^4.3.2:
-  version "4.3.2"
-  resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
-  integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM=
-
-lodash.sortby@^4.7.0:
-  version "4.7.0"
-  resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
-  integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
-
-lodash.template@^4.4.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.template/-/lodash.template-4.5.0.tgz#f976195cf3f347d0d5f52483569fe8031ccce8ab"
-  integrity sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-    lodash.templatesettings "^4.0.0"
-
-lodash.templatesettings@^4.0.0:
-  version "4.2.0"
-  resolved "https://registry.yarnpkg.com/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz#e481310f049d3cf6d47e912ad09313b154f0fb33"
-  integrity sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==
-  dependencies:
-    lodash._reinterpolate "^3.0.0"
-
 lodash.truncate@^4.4.2:
   version "4.4.2"
-  resolved "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz"
+  resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
   integrity sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=
 
-lodash.union@^4.6.0:
-  version "4.6.0"
-  resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
-  integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
-
-lodash.uniq@^4.5.0:
-  version "4.5.0"
-  resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773"
-  integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=
-
-lodash@^3.0.0, lodash@^3.10.1:
-  version "3.10.1"
-  resolved "https://registry.yarnpkg.com/lodash/-/lodash-3.10.1.tgz#5bf45e8e49ba4189e17d482789dfd15bd140b7b6"
-  integrity sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=
-
-lodash@^4.0.0, lodash@^4.11.1, lodash@^4.16.6, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.2, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.3.0:
+lodash@4.17.21, lodash@^4.15.0, lodash@^4.17.19, lodash@^4.17.21:
   version "4.17.21"
-  resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
   integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
 
-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"
-  integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=
-  dependencies:
-    chalk "^1.0.0"
-
-log-symbols@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-2.2.0.tgz#5740e1c5d6f0dfda4ad9323b5332107ef6b4c40a"
-  integrity sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==
-  dependencies:
-    chalk "^2.0.1"
-
-logform@^1.9.1:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-1.10.0.tgz#c9d5598714c92b546e23f4e78147c40f1e02012e"
-  integrity sha512-em5ojIhU18fIMOw/333mD+ZLE2fis0EzXl1ZwHx4iQzmpQi6odNiY/t+ITNr33JZhT9/KEaH+UPIipr6a9EjWg==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^2.3.3"
-    ms "^2.1.1"
-    triple-beam "^1.2.0"
-
-logform@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/logform/-/logform-2.2.0.tgz#40f036d19161fc76b68ab50fdc7fe495544492f2"
-  integrity sha512-N0qPlqfypFx7UHNn4B3lzS/b0uLqt2hmuoa+PpuXNYgozdJYAyauF5Ky0BWVjrxDlMWiT3qN4zPq3vVAfZy7Yg==
-  dependencies:
-    colors "^1.2.1"
-    fast-safe-stringify "^2.0.4"
-    fecha "^4.2.0"
-    ms "^2.1.1"
-    triple-beam "^1.3.0"
-
-lolex@^1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/lolex/-/lolex-1.6.0.tgz#3a9a0283452a47d7439e72731b9e07d7386e49f6"
-  integrity sha1-OpoCg0UqR9dDnnJzG54H1zhuSfY=
-
 long@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/long/-/long-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/long/-/long-4.0.0.tgz#9a7b71cfb7d361a194ea555241c92f7468d5bf28"
   integrity sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==
 
-loose-envify@^1.0.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf"
-  integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==
-  dependencies:
-    js-tokens "^3.0.0 || ^4.0.0"
-
-loud-rejection@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/loud-rejection/-/loud-rejection-1.6.0.tgz#5b46f80147edee578870f086d04821cf998e551f"
-  integrity sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=
-  dependencies:
-    currently-unhandled "^0.4.1"
-    signal-exit "^3.0.0"
-
-lower-case@^1.1.1:
-  version "1.1.4"
-  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-1.1.4.tgz#9a2cabd1b9e8e0ae993a4bf7d5875c39c42e8eac"
-  integrity sha1-miyr0bno4K6ZOkv31YdcOcQujqw=
-
 lowercase-keys@^1.0.0, lowercase-keys@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-1.0.1.tgz#6f9e30b47084d971a7c820ff15a6c5167b74c26f"
   integrity sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==
 
 lowercase-keys@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
   integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
 
-lru-cache@^4.0.1, lru-cache@^4.0.2:
-  version "4.1.5"
-  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd"
-  integrity sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==
-  dependencies:
-    pseudomap "^1.0.2"
-    yallist "^2.1.2"
-
 lru-cache@^6.0.0:
   version "6.0.0"
-  resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz"
+  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.4.1"
-  resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.4.1.tgz#64033d0ec6a5e6375155a74b1a1eba8e509820ac"
-  integrity sha512-H/QHeBIN1fIGJX517pvK8IEK53yQOW7YcEI55oYtgjDdoCQQz7eJS94qt5kNrscReEyuD/JcdFCm2XBEcGOITg==
-
-magic-string@^0.22.4:
-  version "0.22.5"
-  resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.22.5.tgz#8e9cf5afddf44385c1da5bc2a6a0dbd10b03657e"
-  integrity sha512-oreip9rJZkzvA8Qzk9HFs8fZGF/u7H/gtrE8EN6RjKJ9kh2HlC+yQ2QezifqTZfGyiuAV0dRv5a+y/8gBb1m9w==
-  dependencies:
-    vlq "^0.2.2"
-
-make-dir@^1.0.0, make-dir@^1.1.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c"
-  integrity sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==
-  dependencies:
-    pify "^3.0.0"
-
 make-dir@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f"
   integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==
   dependencies:
     semver "^6.0.0"
@@ -6649,14 +2338,14 @@
   resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf"
   integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=
 
-map-obj@^1.0.0, map-obj@^1.0.1:
+map-obj@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-1.0.1.tgz#d933ceb9205d82bdcf4886f6742bdc2b4dea146d"
   integrity sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=
 
 map-obj@^4.0.0:
   version "4.2.1"
-  resolved "https://registry.npmjs.org/map-obj/-/map-obj-4.2.1.tgz"
+  resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.2.1.tgz#e4ea399dbc979ae735c83c863dd31bdf364277b7"
   integrity sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ==
 
 map-visit@^1.0.0:
@@ -6666,111 +2355,9 @@
   dependencies:
     object-visit "^1.0.0"
 
-matcher@^1.1.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/matcher/-/matcher-1.1.1.tgz#51d8301e138f840982b338b116bb0c09af62c1c2"
-  integrity sha512-+BmqxWIubKTRKNWx/ahnCkk3mG8m7OturVlqq6HiojGJTd5hVYbgZm6WzcYPCoB+KBT4Vd6R7WSRG2OADNaCjg==
-  dependencies:
-    escape-string-regexp "^1.0.4"
-
-math-random@^1.0.1:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/math-random/-/math-random-1.0.4.tgz#5dd6943c938548267016d4e34f057583080c514c"
-  integrity sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==
-
-md5@^2.2.1:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/md5/-/md5-2.3.0.tgz#c3da9a6aae3a30b46b7b0c349b87b110dc3bda4f"
-  integrity sha512-T1GITYmFaKuO91vxyoQMFETst+O71VUPEU3ze5GNzDm0OWdP8v1ziTaAEPUr/3kLsY3Sftgz242A1SetQiDL7g==
-  dependencies:
-    charenc "0.0.2"
-    crypt "0.0.2"
-    is-buffer "~1.1.6"
-
-media-typer@0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
-  integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=
-
-mem-fs-editor@^5.0.0:
-  version "5.1.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-5.1.0.tgz#51972241640be8567680a04f7adaffe5fc603667"
-  integrity sha512-2Yt2GCYEbcotYbIJagmow4gEtHDqzpq5XN94+yAx/NT5+bGqIjkXnm3KCUQfE6kRfScGp9IZknScoGRKu8L78w==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^2.5.9"
-    glob "^7.0.3"
-    globby "^8.0.1"
-    isbinaryfile "^3.0.2"
-    mkdirp "^0.5.0"
-    multimatch "^2.0.0"
-    rimraf "^2.2.8"
-    through2 "^2.0.0"
-    vinyl "^2.0.1"
-
-mem-fs-editor@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-6.0.0.tgz#d63607cf0a52fe6963fc376c6a7aa52db3edabab"
-  integrity sha512-e0WfJAMm8Gv1mP5fEq/Blzy6Lt1VbLg7gNnZmZak7nhrBTibs+c6nQ4SKs/ZyJYHS1mFgDJeopsLAv7Ow0FMFg==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^2.6.1"
-    glob "^7.1.4"
-    globby "^9.2.0"
-    isbinaryfile "^4.0.0"
-    mkdirp "^0.5.0"
-    multimatch "^4.0.0"
-    rimraf "^2.6.3"
-    through2 "^3.0.1"
-    vinyl "^2.2.0"
-
-mem-fs-editor@^7.0.1:
-  version "7.1.0"
-  resolved "https://registry.yarnpkg.com/mem-fs-editor/-/mem-fs-editor-7.1.0.tgz#2a16f143228df87bf918874556723a7ee73bfe88"
-  integrity sha512-BH6QEqCXSqGeX48V7zu+e3cMwHU7x640NB8Zk8VNvVZniz+p4FK60pMx/3yfkzo6miI6G3a8pH6z7FeuIzqrzA==
-  dependencies:
-    commondir "^1.0.1"
-    deep-extend "^0.6.0"
-    ejs "^3.1.5"
-    glob "^7.1.4"
-    globby "^9.2.0"
-    isbinaryfile "^4.0.0"
-    mkdirp "^1.0.0"
-    multimatch "^4.0.0"
-    rimraf "^3.0.0"
-    through2 "^3.0.2"
-    vinyl "^2.2.1"
-
-mem-fs@^1.1.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/mem-fs/-/mem-fs-1.2.0.tgz#5f29b2d02a5875cd14cd836c388385892d556cde"
-  integrity sha512-b8g0jWKdl8pM0LqAPdK9i8ERL7nYrzmJfRhxMiWH2uYdfYnb7uXnmwVb0ZGe7xyEl4lj+nLIU3yf4zPUT+XsVQ==
-  dependencies:
-    through2 "^3.0.0"
-    vinyl "^2.0.1"
-    vinyl-file "^3.0.0"
-
-meow@^3.7.0:
-  version "3.7.0"
-  resolved "https://registry.yarnpkg.com/meow/-/meow-3.7.0.tgz#72cb668b425228290abbfa856892587308a801fb"
-  integrity sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=
-  dependencies:
-    camelcase-keys "^2.0.0"
-    decamelize "^1.1.2"
-    loud-rejection "^1.0.0"
-    map-obj "^1.0.1"
-    minimist "^1.1.3"
-    normalize-package-data "^2.3.4"
-    object-assign "^4.0.1"
-    read-pkg-up "^1.0.1"
-    redent "^1.0.0"
-    trim-newlines "^1.0.0"
-
 meow@^9.0.0:
   version "9.0.0"
-  resolved "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364"
   integrity sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==
   dependencies:
     "@types/minimist" "^1.2.0"
@@ -6786,53 +2373,17 @@
     type-fest "^0.18.0"
     yargs-parser "^20.2.3"
 
-merge-descriptors@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
-  integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=
-
-merge-stream@^1.0.0, merge-stream@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-1.0.1.tgz#4041202d508a342ba00174008df0c251b8c135e1"
-  integrity sha1-QEEgLVCKNCugAXQAjfDCUbjBNeE=
-  dependencies:
-    readable-stream "^2.0.1"
-
 merge-stream@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
   integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
 
 merge2@^1.2.3, merge2@^1.3.0:
   version "1.4.1"
-  resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz"
+  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"
-  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
-
-micromatch@^2.1.5, micromatch@^2.3.11, micromatch@^2.3.7:
-  version "2.3.11"
-  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-2.3.11.tgz#86677c97d1720b363431d04d0d15293bd38c1565"
-  integrity sha1-hmd8l9FyCzY0MdBNDRUpO9OMFWU=
-  dependencies:
-    arr-diff "^2.0.0"
-    array-unique "^0.2.1"
-    braces "^1.8.2"
-    expand-brackets "^0.1.4"
-    extglob "^0.3.1"
-    filename-regex "^2.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.1"
-    kind-of "^3.0.2"
-    normalize-path "^2.0.1"
-    object.omit "^2.0.0"
-    parse-glob "^3.0.4"
-    regex-cache "^0.4.2"
-
-micromatch@^3.0.4, micromatch@^3.1.10:
+micromatch@^3.1.10:
   version "3.1.10"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
   integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
@@ -6851,97 +2402,55 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.2"
 
-micromatch@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.npmjs.org/micromatch/-/micromatch-4.0.2.tgz"
-  integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==
+micromatch@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9"
+  integrity sha512-pRmzw/XUcwXGpD9aI9q/0XOwLNygjETJ8y0ao0wdqprrzDa4YnxLcz7fQRZr8voh8V10kGhABbNcHVk5wHgWwg==
   dependencies:
     braces "^3.0.1"
-    picomatch "^2.0.5"
-
-mime-db@1.47.0, "mime-db@>= 1.43.0 < 2", mime-db@^1.28.0:
-  version "1.47.0"
-  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.47.0.tgz#8cb313e59965d3c05cfbf898915a267af46a335c"
-  integrity sha512-QBmA/G2y+IfeS4oktet3qRZ+P5kPhCKRXxXnQEudYqUaEioAU1/Lq2us3D/t1Jfo4hE9REQPrbB7K5sOczJVIw==
-
-mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24:
-  version "2.1.30"
-  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.30.tgz#6e7be8b4c479825f85ed6326695db73f9305d62d"
-  integrity sha512-crmjA4bLtR8m9qLpHvgxSChT+XoSlZi8J4n/aIdn3z92e/U47Z0V/yl+Wh9W046GgFVAmoNR/fmdbZYcSSIUeg==
-  dependencies:
-    mime-db "1.47.0"
-
-mime@1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6"
-  integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ==
-
-mime@1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
-  integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
-
-mime@^2.3.1:
-  version "2.5.2"
-  resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
-  integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
+    picomatch "^2.2.3"
 
 mimic-fn@^2.1.0:
   version "2.1.0"
-  resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
   integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
 
 mimic-response@^1.0.0, mimic-response@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b"
   integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==
 
-mimic-response@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9"
-  integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==
-
 min-indent@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
   integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 
-minimalistic-assert@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"
-  integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==
-
-minimatch-all@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/minimatch-all/-/minimatch-all-1.1.0.tgz#40c496a27a2e128d19bf758e76bb01a0c7145787"
-  integrity sha1-QMSWonouEo0Zv3WOdrsBoMcUV4c=
+minimatch@3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.3.tgz#2a4e4090b96b2db06a9d7df01055a62a77c9b774"
+  integrity sha1-Kk5AkLlrLbBqnX3wEFWmKnfJt3Q=
   dependencies:
-    minimatch "^3.0.2"
+    brace-expansion "^1.0.0"
 
-"minimatch@2 || 3", minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4:
+minimatch@^3.0.4:
   version "3.0.4"
-  resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
   integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
   dependencies:
     brace-expansion "^1.1.7"
 
 minimist-options@4.1.0:
   version "4.1.0"
-  resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619"
   integrity sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==
   dependencies:
     arrify "^1.0.1"
     is-plain-obj "^1.1.0"
     kind-of "^6.0.3"
 
-minimist@^0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.2.1.tgz#827ba4e7593464e7c221e8c5bed930904ee2c455"
-  integrity sha512-GY8fANSrTMfBVfInqJAY41QkOM+upUTytK1jZ0c8+3HdHrJxBJ3rF5i9moClXTE8uUSnUo8cAsCoxDXvSY4DHg==
-
-minimist@^1.1.3, minimist@^1.2.0, minimist@^1.2.5:
+minimist@^1.2.0, minimist@^1.2.5:
   version "1.2.5"
-  resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
   integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
 
 mixin-deep@^1.2.0:
@@ -6952,41 +2461,14 @@
     for-in "^1.0.2"
     is-extendable "^1.0.1"
 
-mkdirp@^0.5.0, mkdirp@^0.5.1:
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
-  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
-  dependencies:
-    minimist "^1.2.5"
-
-mkdirp@^1.0.0, mkdirp@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
-  integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
-
-moment@^2.15.1, moment@^2.24.0:
-  version "2.29.1"
-  resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
-  integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
-
-mout@^1.0.0:
-  version "1.2.2"
-  resolved "https://registry.yarnpkg.com/mout/-/mout-1.2.2.tgz#c9b718a499806a0632cede178e80f436259e777d"
-  integrity sha512-w0OUxFEla6z3d7sVpMZGBCpQvYh8PHS1wZ6Wu9GNKHMpAHWJ0if0LsQZh3DlOqw55HlhJEOMLpFnwtxp99Y5GA==
-
 ms@2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
   integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=
 
-ms@2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a"
-  integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==
-
 ms@2.1.2:
   version "2.1.2"
-  resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
 ms@^2.1.1:
@@ -6994,73 +2476,11 @@
   resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
   integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
 
-multer@^1.3.0:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.2.tgz#2f1f4d12dbaeeba74cb37e623f234bf4d3d2057a"
-  integrity sha512-xY8pX7V+ybyUpbYMxtjM9KAiD9ixtg5/JkeKUTD6xilfDv0vzzOFcCp4Ljb1UU3tSOM3VTZtKo63OmzOrGi3Cg==
-  dependencies:
-    append-field "^1.0.0"
-    busboy "^0.2.11"
-    concat-stream "^1.5.2"
-    mkdirp "^0.5.1"
-    object-assign "^4.1.1"
-    on-finished "^2.3.0"
-    type-is "^1.6.4"
-    xtend "^4.0.0"
-
-multimatch@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-2.1.0.tgz#9c7906a22fb4c02919e2f5f75161b4cdbd4b2a2b"
-  integrity sha1-nHkGoi+0wCkZ4vX3UWG0zb1LKis=
-  dependencies:
-    array-differ "^1.0.0"
-    array-union "^1.0.1"
-    arrify "^1.0.0"
-    minimatch "^3.0.0"
-
-multimatch@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/multimatch/-/multimatch-4.0.0.tgz#8c3c0f6e3e8449ada0af3dd29efb491a375191b3"
-  integrity sha512-lDmx79y1z6i7RNx0ZGCPq1bzJ6ZoDDKbvh7jxr9SJcWLkShMzXrHbYVpTdnhNM5MXpDUxCQ4DgqVttVXlBgiBQ==
-  dependencies:
-    "@types/minimatch" "^3.0.3"
-    array-differ "^3.0.0"
-    array-union "^2.1.0"
-    arrify "^2.0.1"
-    minimatch "^3.0.4"
-
-multipipe@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/multipipe/-/multipipe-1.0.2.tgz#cc13efd833c9cda99f224f868461b8e1a3fd939d"
-  integrity sha1-zBPv2DPJzamfIk+GhGG44aP9k50=
-  dependencies:
-    duplexer2 "^0.1.2"
-    object-assign "^4.1.0"
-
-mute-stream@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.6.tgz#48962b19e169fd1dfc240b3f1e7317627bbc47db"
-  integrity sha1-SJYrGeFp/R38JAs/HnMXYnu8R9s=
-
 mute-stream@0.0.8:
   version "0.0.8"
-  resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz"
+  resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
   integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==
 
-mz@^2.4.0, mz@^2.6.0:
-  version "2.7.0"
-  resolved "https://registry.yarnpkg.com/mz/-/mz-2.7.0.tgz#95008057a56cafadc2bc63dde7f9ff6955948e32"
-  integrity sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==
-  dependencies:
-    any-promise "^1.0.0"
-    object-assign "^4.0.1"
-    thenify-all "^1.0.0"
-
-nan@^2.12.1:
-  version "2.14.2"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.2.tgz#f5376400695168f4cc694ac9393d0c9585eeea19"
-  integrity sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
-
 nanomatch@^1.2.9:
   version "1.2.13"
   resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"
@@ -7078,64 +2498,19 @@
     snapdragon "^0.8.1"
     to-regex "^3.0.1"
 
-native-promise-only@^0.8.1:
-  version "0.8.1"
-  resolved "https://registry.yarnpkg.com/native-promise-only/-/native-promise-only-0.8.1.tgz#20a318c30cb45f71fe7adfbf7b21c99c1472ef11"
-  integrity sha1-IKMYwwy0X3H+et+/eyHJnBRy7xE=
-
 natural-compare@^1.4.0:
   version "1.4.0"
-  resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
+  resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
 ncp@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
   integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=
 
-negotiator@0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
-  integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw==
-
-nice-try@^1.0.4:
-  version "1.0.5"
-  resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
-  integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
-
-no-case@^2.2.0:
-  version "2.3.2"
-  resolved "https://registry.yarnpkg.com/no-case/-/no-case-2.3.2.tgz#60b813396be39b3f1288a4c1ed5d1e7d28b464ac"
-  integrity sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==
-  dependencies:
-    lower-case "^1.1.1"
-
-node-fetch@^2.6.0, node-fetch@^2.6.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"
-  integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==
-
-node-releases@^1.1.70:
-  version "1.1.71"
-  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
-  integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==
-
-node-status-codes@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/node-status-codes/-/node-status-codes-1.0.0.tgz#5ae5541d024645d32a58fcddc9ceecea7ae3ac2f"
-  integrity sha1-WuVUHQJGRdMqWPzdyc7s6nrjrC8=
-
-nomnom@^1.8.1:
-  version "1.8.1"
-  resolved "https://registry.yarnpkg.com/nomnom/-/nomnom-1.8.1.tgz#2151f722472ba79e50a76fc125bb8c8f2e4dc2a7"
-  integrity sha1-IVH3Ikcrp55Qp2/BJbuMjy5Nwqc=
-  dependencies:
-    chalk "~0.4.0"
-    underscore "~1.6.0"
-
-normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
+normalize-package-data@^2.3.2, normalize-package-data@^2.5.0:
   version "2.5.0"
-  resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
   integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==
   dependencies:
     hosted-git-info "^2.1.4"
@@ -7144,72 +2519,33 @@
     validate-npm-package-license "^3.0.1"
 
 normalize-package-data@^3.0.0:
-  version "3.0.2"
-  resolved "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.2.tgz"
-  integrity sha512-6CdZocmfGaKnIHPVFhJJZ3GuR8SsLKvDANFp47Jmy51aKIr8akjAWTSxtpI+MBgBFdSMRyo4hMpDlT6dTffgZg==
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-3.0.3.tgz#dbcc3e2da59509a0983422884cd172eefdfa525e"
+  integrity sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==
   dependencies:
     hosted-git-info "^4.0.1"
-    resolve "^1.20.0"
+    is-core-module "^2.5.0"
     semver "^7.3.4"
     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"
-  integrity sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=
-  dependencies:
-    remove-trailing-separator "^1.0.1"
-
-normalize-path@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
-  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
-
 normalize-url@^4.1.0:
-  version "4.5.0"
-  resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.0.tgz"
-  integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ==
+  version "4.5.1"
+  resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a"
+  integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==
 
-npm-api@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/npm-api/-/npm-api-1.0.1.tgz#3def9b51afedca57db14ca0c970d92442d21c9c5"
-  integrity sha512-4sITrrzEbPcr0aNV28QyOmgn6C9yKiF8k92jn4buYAK8wmA5xo1qL3II5/gT1r7wxbXBflSduZ2K3FbtOrtGkA==
-  dependencies:
-    JSONStream "^1.3.5"
-    clone-deep "^4.0.1"
-    download-stats "^0.3.4"
-    moment "^2.24.0"
-    node-fetch "^2.6.0"
-    paged-request "^2.0.1"
-
-npm-run-path@^2.0.0:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"
-  integrity sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=
-  dependencies:
-    path-key "^2.0.0"
-
-npm-run-path@^4.0.0, npm-run-path@^4.0.1:
+npm-run-path@^4.0.1:
   version "4.0.1"
-  resolved "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
   integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
   dependencies:
     path-key "^3.0.0"
 
-number-is-nan@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
-  integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
-
-oauth-sign@~0.9.0:
-  version "0.9.0"
-  resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
-  integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
-
-object-assign@^4, object-assign@^4.0.0, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
-  version "4.1.1"
-  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
-  integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
+nth-check@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
+  integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==
+  dependencies:
+    boolbase "~1.0.0"
 
 object-copy@^0.1.0:
   version "0.1.0"
@@ -7220,14 +2556,14 @@
     define-property "^0.2.5"
     kind-of "^3.0.3"
 
-object-inspect@^1.9.0:
-  version "1.9.0"
-  resolved "https://registry.npmjs.org/object-inspect/-/object-inspect-1.9.0.tgz"
-  integrity sha512-i3Bp9iTqwhaLZBxGkRfo5ZbE07BQRT7MGu8+nNgwW9ItGp1TzCTw2DLEoWwjClxBjOFI/hWljTAmYGCEwmtnOw==
+object-inspect@^1.11.0, object-inspect@^1.9.0:
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
+  integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
 
 object-keys@^1.0.12, object-keys@^1.1.1:
   version "1.1.1"
-  resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e"
   integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==
 
 object-visit@^1.0.0:
@@ -7237,9 +2573,9 @@
   dependencies:
     isobject "^3.0.0"
 
-object.assign@^4.1.0, object.assign@^4.1.2:
+object.assign@^4.1.2:
   version "4.1.2"
-  resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz"
+  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"
@@ -7247,14 +2583,6 @@
     has-symbols "^1.0.1"
     object-keys "^1.1.1"
 
-object.omit@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
-  integrity sha1-Gpx0SCnznbuFjHbKNXmuKlTr0fo=
-  dependencies:
-    for-own "^0.1.4"
-    is-extendable "^0.1.1"
-
 object.pick@^1.3.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/object.pick/-/object.pick-1.3.0.tgz#87a10ac4c1694bd2e1cbf53591a66141fb5dd747"
@@ -7262,74 +2590,32 @@
   dependencies:
     isobject "^3.0.1"
 
-object.values@^1.1.1:
-  version "1.1.3"
-  resolved "https://registry.npmjs.org/object.values/-/object.values-1.1.3.tgz"
-  integrity sha512-nkF6PfDB9alkOUxpf1HNm/QlkeW3SReqL5WXeBLpEJJnlPSvRaDQpW3gQTksTN3fgJX4hL42RzKyOin6ff3tyw==
+object.values@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30"
+  integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==
   dependencies:
     call-bind "^1.0.2"
     define-properties "^1.1.3"
-    es-abstract "^1.18.0-next.2"
-    has "^1.0.3"
-
-obuf@^1.0.0, obuf@^1.1.1:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
-  integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==
-
-octokit-pagination-methods@^1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
-  integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
-
-on-finished@^2.3.0, on-finished@~2.3.0:
-  version "2.3.0"
-  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
-  integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc=
-  dependencies:
-    ee-first "1.1.1"
-
-on-headers@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
-  integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
+    es-abstract "^1.18.2"
 
 once@^1.3.0, once@^1.3.1, once@^1.4.0:
   version "1.4.0"
-  resolved "https://registry.npmjs.org/once/-/once-1.4.0.tgz"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
   integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
   dependencies:
     wrappy "1"
 
-one-time@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/one-time/-/one-time-1.0.0.tgz#e06bc174aed214ed58edede573b433bbf827cb45"
-  integrity sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==
-  dependencies:
-    fn.name "1.x.x"
-
-onetime@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
-  integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
-
 onetime@^5.1.0, onetime@^5.1.2:
   version "5.1.2"
-  resolved "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
   integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
   dependencies:
     mimic-fn "^2.1.0"
 
-opn@^3.0.2:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/opn/-/opn-3.0.3.tgz#b6d99e7399f78d65c3baaffef1fb288e9b85243a"
-  integrity sha1-ttmec5n3jWXDuq/+8fsojpuFJDo=
-  dependencies:
-    object-assign "^4.0.1"
-
 optionator@^0.9.1:
   version "0.9.1"
-  resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
   integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
   dependencies:
     deep-is "^0.1.3"
@@ -7339,145 +2625,57 @@
     type-check "^0.4.0"
     word-wrap "^1.2.3"
 
-ordered-read-streams@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/ordered-read-streams/-/ordered-read-streams-0.3.0.tgz#7137e69b3298bb342247a1bbee3881c80e2fd78b"
-  integrity sha1-cTfmmzKYuzQiR6G77jiByA4v14s=
-  dependencies:
-    is-stream "^1.0.1"
-    readable-stream "^2.0.1"
-
-os-homedir@^1.0.0, os-homedir@^1.0.1:
+os-tmpdir@~1.0.2:
   version "1.0.2"
-  resolved "https://registry.yarnpkg.com/os-homedir/-/os-homedir-1.0.2.tgz#ffbc4988336e0e833de0c168c7ef152121aa7fb3"
-  integrity sha1-/7xJiDNuDoM94MFox+8VISGqf7M=
-
-os-name@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/os-name/-/os-name-3.1.0.tgz#dec19d966296e1cd62d701a5a66ee1ddeae70801"
-  integrity sha512-h8L+8aNjNcMpo/mAIBPn5PXCM16iyPGjHNWo6U1YO8sJTMHtEtyczI6QJnLoplswm6goopQkqc7OAnjhWcugVg==
-  dependencies:
-    macos-release "^2.2.0"
-    windows-release "^3.1.0"
-
-os-shim@^0.1.2:
-  version "0.1.3"
-  resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917"
-  integrity sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc=
-
-os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2:
-  version "1.0.2"
-  resolved "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
   integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
 
-osenv@^0.1.0, osenv@^0.1.3:
-  version "0.1.5"
-  resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
-  integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
-  dependencies:
-    os-homedir "^1.0.0"
-    os-tmpdir "^1.0.0"
-
-p-cancelable@^0.3.0:
-  version "0.3.0"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
-  integrity sha512-RVbZPLso8+jFeq1MfNvgXtCRED2raz/dKpacfTNxsx6pLEpEomM7gah6VeHSYV3+vo0OAi4MkArtQcWWXuQoyw==
-
 p-cancelable@^1.0.0:
   version "1.1.0"
-  resolved "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-1.1.0.tgz#d078d15a3af409220c886f1d9a0ca2e441ab26cc"
   integrity sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==
 
-p-cancelable@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.0.tgz#4d51c3b91f483d02a0d300765321fca393d758dd"
-  integrity sha512-HAZyB3ZodPo+BDpb4/Iu7Jv4P6cSazBz9ZM0ChhEXp70scx834aWCEjQRwgt41UzzejUAPdbqqONfRWTPYrPAQ==
-
-p-finally@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae"
-  integrity sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=
-
 p-limit@^1.1.0:
   version "1.3.0"
-  resolved "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
   integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==
   dependencies:
     p-try "^1.0.0"
 
-p-limit@^2.0.0, p-limit@^2.2.0:
+p-limit@^2.2.0:
   version "2.3.0"
-  resolved "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
   integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
   dependencies:
     p-try "^2.0.0"
 
 p-locate@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43"
   integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=
   dependencies:
     p-limit "^1.1.0"
 
-p-locate@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4"
-  integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==
-  dependencies:
-    p-limit "^2.0.0"
-
 p-locate@^4.1.0:
   version "4.1.0"
-  resolved "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
   integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
   dependencies:
     p-limit "^2.2.0"
 
-p-map@^1.1.1:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/p-map/-/p-map-1.2.0.tgz#e4e94f311eabbc8633a1e79908165fca26241b6b"
-  integrity sha512-r6zKACMNhjPJMTl8KcFH4li//gkrXWfbD6feV8l6doRHlzljFWGJ2AP6iKaCJXyZmAUMOPtvbW7EXkbWO/pLEA==
-
-p-timeout@^1.1.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/p-timeout/-/p-timeout-1.2.1.tgz#5eb3b353b7fce99f101a1038880bb054ebbea386"
-  integrity sha1-XrOzU7f86Z8QGhA4iAuwVOu+o4Y=
-  dependencies:
-    p-finally "^1.0.0"
-
 p-try@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
   integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=
 
-p-try@^2.0.0, p-try@^2.1.0:
+p-try@^2.0.0:
   version "2.2.0"
-  resolved "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
   integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
 
-package-json@^2.0.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-2.4.0.tgz#0d15bd67d1cbbddbb2ca222ff2edb86bcb31a8bb"
-  integrity sha1-DRW9Z9HLvduyyiIv8u24a8sxqLs=
-  dependencies:
-    got "^5.0.0"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
-package-json@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/package-json/-/package-json-4.0.1.tgz#8869a0401253661c4c4ca3da6c2121ed555f5eed"
-  integrity sha1-iGmgQBJTZhxMTKPabCEh7VVfXu0=
-  dependencies:
-    got "^6.7.1"
-    registry-auth-token "^3.0.1"
-    registry-url "^3.0.3"
-    semver "^5.1.0"
-
 package-json@^6.3.0:
   version "6.5.0"
-  resolved "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz"
+  resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0"
   integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==
   dependencies:
     got "^9.6.0"
@@ -7485,49 +2683,13 @@
     registry-url "^5.0.0"
     semver "^6.2.0"
 
-paged-request@^2.0.1:
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/paged-request/-/paged-request-2.0.2.tgz#4d621a08b8d6bee4440a0a92112354eeece5b5b0"
-  integrity sha512-NWrGqneZImDdcMU/7vMcAOo1bIi5h/pmpJqe7/jdsy85BA/s5MSaU/KlpxwW/IVPmIwBcq2uKPrBWWhEWhtxag==
-  dependencies:
-    axios "^0.21.1"
-
-pako@~0.2.0:
-  version "0.2.9"
-  resolved "https://registry.yarnpkg.com/pako/-/pako-0.2.9.tgz#f3f7522f4ef782348da8161bad9ecfd51bf83a75"
-  integrity sha1-8/dSL073gjSNqBYbrZ7P1Rv4OnU=
-
-param-case@2.1.x:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/param-case/-/param-case-2.1.1.tgz#df94fd8cf6531ecf75e6bef9a0858fbc72be2247"
-  integrity sha1-35T9jPZTHs915r75oIWPvHK+Ikc=
-  dependencies:
-    no-case "^2.2.0"
-
 parent-module@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
   integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
   dependencies:
     callsites "^3.0.0"
 
-parse-glob@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c"
-  integrity sha1-ssN2z7EfNVE7rdFz7wu246OIORw=
-  dependencies:
-    glob-base "^0.3.0"
-    is-dotfile "^1.0.0"
-    is-extglob "^1.0.0"
-    is-glob "^2.0.0"
-
-parse-json@^2.1.0, parse-json@^2.2.0:
-  version "2.2.0"
-  resolved "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz"
-  integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=
-  dependencies:
-    error-ex "^1.2.0"
-
 parse-json@^4.0.0:
   version "4.0.0"
   resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-4.0.0.tgz#be35f5425be1f7f6c747184f98a788cb99477ee0"
@@ -7538,7 +2700,7 @@
 
 parse-json@^5.0.0:
   version "5.2.0"
-  resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd"
   integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==
   dependencies:
     "@babel/code-frame" "^7.0.0"
@@ -7546,30 +2708,29 @@
     json-parse-even-better-errors "^2.3.0"
     lines-and-columns "^1.1.6"
 
-parse-passwd@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
-  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
+parse5-htmlparser2-tree-adapter@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6"
+  integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==
+  dependencies:
+    parse5 "^6.0.1"
 
-parse5@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/parse5/-/parse5-4.0.0.tgz#6d78656e3da8d78b4ec0b906f7c08ef1dfe3f608"
-  integrity sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==
+parse5@5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.0.tgz#c59341c9723f414c452975564c7c00a68d58acd2"
+  integrity sha512-fxNG2sQjHvlVAYmzBZS9YlDp6PTSSDwa98vkD4QgVDDCAo84z5X1t5XyJQ62ImdLXx5NdIIfihey6xpum9/gRQ==
 
-parseqs@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/parseqs/-/parseqs-0.0.6.tgz#8e4bb5a19d1cdc844a08ac974d34e273afa670d5"
-  integrity sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w==
+parse5@^3.0.1:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-3.0.3.tgz#042f792ffdd36851551cf4e9e066b3874ab45b5c"
+  integrity sha512-rgO9Zg5LLLkfJF9E6CCmXlSE4UVceloys8JrFqCcHloC3usd/kJCyPDwH2SOlzix2j3xaP9sUX3e8+kvkuleAA==
+  dependencies:
+    "@types/node" "*"
 
-parseuri@0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/parseuri/-/parseuri-0.0.6.tgz#e1496e829e3ac2ff47f39a4dd044b32823c4a25a"
-  integrity sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow==
-
-parseurl@~1.3.3:
-  version "1.3.3"
-  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
-  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+parse5@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
+  integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
 
 pascalcase@^0.1.1:
   version "0.1.1"
@@ -7581,75 +2742,30 @@
   resolved "https://registry.yarnpkg.com/path-dirname/-/path-dirname-1.0.2.tgz#cc33d24d525e099a5388c0336c6e32b9160609e0"
   integrity sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=
 
-path-exists@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
-  integrity sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=
-  dependencies:
-    pinkie-promise "^2.0.0"
-
 path-exists@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515"
   integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=
 
 path-exists@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
   integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
 
 path-is-absolute@^1.0.0:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
   integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
 
-path-is-inside@^1.0.1, path-is-inside@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53"
-  integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=
-
-path-key@^2.0.0, path-key@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40"
-  integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=
-
 path-key@^3.0.0, path-key@^3.1.0:
   version "3.1.1"
-  resolved "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
   integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
 
 path-parse@^1.0.6:
-  version "1.0.6"
-  resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz"
-  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
-
-path-to-regexp@0.1.7:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c"
-  integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=
-
-path-to-regexp@^1.0.1, path-to-regexp@^1.7.0:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a"
-  integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==
-  dependencies:
-    isarray "0.0.1"
-
-path-type@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/path-type/-/path-type-1.1.0.tgz#59c44f7ee491da704da415da5a4070ba4f8fe441"
-  integrity sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=
-  dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.0.0"
-    pinkie-promise "^2.0.0"
-
-path-type@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz"
-  integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=
-  dependencies:
-    pify "^2.0.0"
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
 path-type@^3.0.0:
   version "3.0.0"
@@ -7660,360 +2776,32 @@
 
 path-type@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"
+  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"
-  integrity sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==
-  dependencies:
-    buffer-from "^1.0.0"
-    duplexify "^3.5.0"
-    through2 "^2.0.3"
-
-pem@^1.8.3:
-  version "1.14.4"
-  resolved "https://registry.yarnpkg.com/pem/-/pem-1.14.4.tgz#a68c70c6e751ccc5b3b5bcd7af78b0aec1177ff9"
-  integrity sha512-v8lH3NpirgiEmbOqhx0vwQTxwi0ExsiWBGYh0jYNq7K6mQuO4gI6UEFlr6fLAdv9TPXRt6GqiwE37puQdIDS8g==
-  dependencies:
-    es6-promisify "^6.0.0"
-    md5 "^2.2.1"
-    os-tmpdir "^1.0.1"
-    which "^2.0.2"
-
-pend@~1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
-  integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
-
-performance-now@^2.1.0:
-  version "2.1.0"
-  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.npmjs.org/picomatch/-/picomatch-2.2.2.tgz"
-  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
-
-pify@^2.0.0, pify@^2.3.0:
+picomatch@^2.2.3:
   version "2.3.0"
-  resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz"
-  integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972"
+  integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw==
 
 pify@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/pify/-/pify-3.0.0.tgz#e5a4acd2c101fdf3d9a4d07f0dbc4db49dd28176"
   integrity sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=
 
-pify@^4.0.1:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
-  integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
-
-pinkie-promise@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"
-  integrity sha1-ITXW36ejWMBprJsXh3YogihFD/o=
-  dependencies:
-    pinkie "^2.0.0"
-
-pinkie@^2.0.0:
-  version "2.0.4"
-  resolved "https://registry.yarnpkg.com/pinkie/-/pinkie-2.0.4.tgz#72556b80cfa0d48a974e80e77248e80ed4f7f870"
-  integrity sha1-clVrgM+g1IqXToDnckjoDtT3+HA=
-
 pkg-dir@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b"
   integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=
   dependencies:
     find-up "^2.1.0"
 
-plist@^2.0.1:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/plist/-/plist-2.1.0.tgz#57ccdb7a0821df21831217a3cad54e3e146a1025"
-  integrity sha1-V8zbeggh3yGDEhejytVOPhRqECU=
+pkg-up@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-2.0.0.tgz#c819ac728059a461cab1c3889a2be3c49a004d7f"
+  integrity sha1-yBmscoBZpGHKscOImivjxJoATX8=
   dependencies:
-    base64-js "1.2.0"
-    xmlbuilder "8.2.2"
-    xmldom "0.1.x"
-
-plylog@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/plylog/-/plylog-1.1.0.tgz#f6f354e2ae0b01f6db4ed111f4b3855da9c37417"
-  integrity sha512-/QnY5aSVaP54va6hruzNtAj02HpsLlAt7V5EndMrtq6ZUTZJKUja43rgiUtGXqm95yrSJjbZoPW0yQQQwLpoJA==
-  dependencies:
-    logform "^1.9.1"
-    winston "^3.0.0"
-    winston-transport "^4.2.0"
-
-polymer-analyzer@^3.0.0, polymer-analyzer@^3.1.3, polymer-analyzer@^3.2.2:
-  version "3.2.4"
-  resolved "https://registry.yarnpkg.com/polymer-analyzer/-/polymer-analyzer-3.2.4.tgz#7d76356620a2328e8bc9e30e47069f9729260ca1"
-  integrity sha512-JmxUhMajTuC18tLXbTtu2+aN2x9bTX+4MvCD4IZKJ0rtAL8jWi1iRLfogpHJB4Ig9Dc8EEEuEYipLuzPFl3vqA==
-  dependencies:
-    "@babel/generator" "^7.0.0-beta.42"
-    "@babel/traverse" "^7.0.0-beta.42"
-    "@babel/types" "^7.0.0-beta.42"
-    "@types/babel-generator" "^6.25.1"
-    "@types/babel-traverse" "^6.25.2"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/chai-subset" "^1.3.0"
-    "@types/chalk" "^0.4.30"
-    "@types/clone" "^0.1.30"
-    "@types/cssbeautify" "^0.3.1"
-    "@types/doctrine" "^0.0.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/minimatch" "^3.0.1"
-    "@types/parse5" "^2.2.34"
-    "@types/path-is-inside" "^1.0.0"
-    "@types/resolve" "0.0.6"
-    "@types/whatwg-url" "^6.4.0"
-    babylon "^7.0.0-beta.42"
-    cancel-token "^0.1.1"
-    chalk "^1.1.3"
-    clone "^2.0.0"
-    cssbeautify "^0.3.1"
-    doctrine "^2.0.2"
-    dom5 "^3.0.0"
-    indent "0.0.2"
-    is-windows "^1.0.2"
-    jsonschema "^1.1.0"
-    minimatch "^3.0.4"
-    parse5 "^4.0.0"
-    path-is-inside "^1.0.2"
-    resolve "^1.5.0"
-    shady-css-parser "^0.1.0"
-    stable "^0.1.6"
-    strip-indent "^2.0.0"
-    vscode-uri "=1.0.6"
-    whatwg-url "^6.4.0"
-
-polymer-build@^3.1.0, polymer-build@^3.1.4:
-  version "3.1.4"
-  resolved "https://registry.yarnpkg.com/polymer-build/-/polymer-build-3.1.4.tgz#ab539f1a13d803518b13b73ffd09198431d98142"
-  integrity sha512-OhTOPG5Y/tK2HqGZ5XA/CVDh+TuOaDv7wTZWXDCg6hxeMgNKuljDMn2coyGU5NLM0pLbS+gwFAc2ZJ5cWHCHNg==
-  dependencies:
-    "@babel/core" "^7.0.0"
-    "@babel/plugin-external-helpers" "^7.0.0"
-    "@babel/plugin-proposal-async-generator-functions" "^7.0.0"
-    "@babel/plugin-proposal-object-rest-spread" "^7.0.0"
-    "@babel/plugin-syntax-async-generators" "^7.0.0"
-    "@babel/plugin-syntax-dynamic-import" "^7.0.0"
-    "@babel/plugin-syntax-import-meta" "^7.0.0"
-    "@babel/plugin-syntax-object-rest-spread" "^7.0.0"
-    "@babel/plugin-transform-arrow-functions" "^7.0.0"
-    "@babel/plugin-transform-async-to-generator" "^7.0.0"
-    "@babel/plugin-transform-block-scoped-functions" "^7.0.0"
-    "@babel/plugin-transform-block-scoping" "^7.0.0"
-    "@babel/plugin-transform-classes" "^7.0.0"
-    "@babel/plugin-transform-computed-properties" "^7.0.0"
-    "@babel/plugin-transform-destructuring" "^7.0.0"
-    "@babel/plugin-transform-duplicate-keys" "^7.0.0"
-    "@babel/plugin-transform-exponentiation-operator" "^7.0.0"
-    "@babel/plugin-transform-for-of" "^7.0.0"
-    "@babel/plugin-transform-function-name" "^7.0.0"
-    "@babel/plugin-transform-instanceof" "^7.0.0"
-    "@babel/plugin-transform-literals" "^7.0.0"
-    "@babel/plugin-transform-modules-amd" "^7.0.0"
-    "@babel/plugin-transform-object-super" "^7.0.0"
-    "@babel/plugin-transform-parameters" "^7.0.0"
-    "@babel/plugin-transform-regenerator" "^7.0.0"
-    "@babel/plugin-transform-shorthand-properties" "^7.0.0"
-    "@babel/plugin-transform-spread" "^7.0.0"
-    "@babel/plugin-transform-sticky-regex" "^7.0.0"
-    "@babel/plugin-transform-template-literals" "^7.0.0"
-    "@babel/plugin-transform-typeof-symbol" "^7.0.0"
-    "@babel/plugin-transform-unicode-regex" "^7.0.0"
-    "@babel/traverse" "^7.0.0"
-    "@polymer/esm-amd-loader" "^1.0.0"
-    "@types/babel-types" "^6.25.1"
-    "@types/babylon" "^6.16.2"
-    "@types/gulp-if" "0.0.33"
-    "@types/html-minifier" "^3.5.1"
-    "@types/is-windows" "^0.2.0"
-    "@types/mz" "0.0.31"
-    "@types/parse5" "^2.2.34"
-    "@types/resolve" "0.0.7"
-    "@types/uuid" "^3.4.3"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "^2.4.8"
-    babel-plugin-minify-guarded-expressions "^0.4.3"
-    babel-preset-minify "^0.5.0"
-    babylon "^7.0.0-beta.42"
-    css-slam "^2.1.2"
-    dom5 "^3.0.0"
-    gulp-if "^2.0.2"
-    html-minifier "^3.5.10"
-    matcher "^1.1.0"
-    multipipe "^1.0.2"
-    mz "^2.6.0"
-    parse5 "^4.0.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.1.3"
-    polymer-bundler "^4.0.9"
-    polymer-project-config "^4.0.3"
-    regenerator-runtime "^0.11.1"
-    stream "0.0.2"
-    sw-precache "^5.1.1"
-    uuid "^3.2.1"
-    vinyl "^1.2.0"
-    vinyl-fs "^2.4.4"
-
-polymer-bundler@^4.0.9:
-  version "4.0.10"
-  resolved "https://registry.yarnpkg.com/polymer-bundler/-/polymer-bundler-4.0.10.tgz#abc8d33977652f031068d034c8104841e80d4cbb"
-  integrity sha512-nwlN3LQlQDqbZ2sUH3394C/dHZUDHq8tpdS5HARvPDb0Q9IXWD+znOR1cr7wSjF0EZN4LiUH5hWyUoV4QSjhpQ==
-  dependencies:
-    "@types/babel-generator" "^6.25.1"
-    "@types/babel-traverse" "^6.25.3"
-    babel-generator "^6.26.1"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    clone "^2.1.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    dom5 "^3.0.0"
-    espree "^3.5.2"
-    magic-string "^0.22.4"
-    mkdirp "^0.5.1"
-    parse5 "^4.0.0"
-    polymer-analyzer "^3.2.2"
-    rollup "^1.3.0"
-    source-map "^0.5.6"
-    vscode-uri "=1.0.6"
-
-polymer-cli@^1.9.11:
-  version "1.9.11"
-  resolved "https://registry.npmjs.org/polymer-cli/-/polymer-cli-1.9.11.tgz"
-  integrity sha512-tiURjHDCOUUtDVPuVYvrfFI9PXe4OOUmBbn6Sg5GJNQ2POtP7r7hv+I5yI8P9qsxmalHTa19chVtf5/t9IBXDg==
-  dependencies:
-    "@octokit/rest" "^16.2.0"
-    "@types/chalk" "^2.2.0"
-    "@types/del" "^3.0.0"
-    "@types/findup-sync" "^0.3.29"
-    "@types/globby" "^6.1.0"
-    "@types/inquirer" "0.0.32"
-    "@types/merge-stream" "^1.0.28"
-    "@types/mz" "^0.0.31"
-    "@types/request" "2.0.3"
-    "@types/resolve" "0.0.4"
-    "@types/rimraf" "^0.0.28"
-    "@types/semver" "^5.3.30"
-    "@types/temp" "^0.8.28"
-    "@types/update-notifier" "^1.0.0"
-    "@types/vinyl" "^2.0.0"
-    "@types/vinyl-fs" "0.0.28"
-    "@types/yeoman-generator" "^2.0.3"
-    bower "^1.8.8"
-    bower-json "^0.8.1"
-    bower-logger "^0.2.2"
-    chalk "^2.4.2"
-    chokidar "^1.7.0"
-    command-line-args "^5.0.2"
-    command-line-commands "^2.0.1"
-    command-line-usage "^5.0.5"
-    del "^3.0.0"
-    findup-sync "^0.4.2"
-    globby "^8.0.1"
-    gunzip-maybe "^1.3.1"
-    inquirer "^1.0.2"
-    merge-stream "^1.0.1"
-    mz "^2.6.0"
-    plylog "^1.0.0"
-    polymer-analyzer "^3.2.2"
-    polymer-build "^3.1.4"
-    polymer-bundler "^4.0.9"
-    polymer-linter "^3.0.0"
-    polymer-project-config "^4.0.3"
-    polyserve "^0.27.15"
-    request "^2.72.0"
-    rimraf "^2.6.1"
-    semver "^5.3.0"
-    tar-fs "^1.12.0"
-    temp "^0.8.3"
-    update-notifier "^1.0.0"
-    validate-element-name "^2.1.1"
-    vinyl "^1.1.1"
-    vinyl-fs "^2.4.3"
-    web-component-tester "^6.9.0"
-    yeoman-environment "^1.5.2"
-    yeoman-generator "^3.1.1"
-
-polymer-linter@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/polymer-linter/-/polymer-linter-3.0.1.tgz#8804e1705fa2a7c263467b8a22da11bb764ee26b"
-  integrity sha512-eDh2CeswZz4Rwf8gfYXpMN66pieq4qJvP9bH3m39LLGm81hRePo4N5OHoQzR5unen1PUdmtjDv0Iicz3dTYEZQ==
-  dependencies:
-    "@types/fast-levenshtein" "0.0.1"
-    "@types/parse5" "^2.2.34"
-    babel-traverse "^6.26.0"
-    babel-types "^6.26.0"
-    cancel-token "^0.1.1"
-    css-what "^2.1.0"
-    dom5 "^3.0.0"
-    fast-levenshtein "^2.0.6"
-    parse5 "^4.0.0"
-    polymer-analyzer "^3.0.0"
-    shady-css-parser "^0.1.0"
-    stable "^0.1.6"
-    strip-indent "^2.0.0"
-    validate-element-name "^2.1.1"
-
-polymer-project-config@^4.0.0, polymer-project-config@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/polymer-project-config/-/polymer-project-config-4.0.3.tgz#ef0c1a676ce4809907986c8e910745660de8024f"
-  integrity sha512-Drr+Imq+znhBC8XSt9pMlmPixoGnIOmleV5SD6mto1zOGC5oCDbSNsQL2v89DWOk+9aSUO79vnWwOmEPDSvYfw==
-  dependencies:
-    "@types/parse5" "^2.2.34"
-    browser-capabilities "^1.0.0"
-    jsonschema "^1.1.1"
-    minimatch-all "^1.1.0"
-    plylog "^1.0.0"
-    winston "^3.0.0"
-
-polyserve@^0.27.13, polyserve@^0.27.15:
-  version "0.27.15"
-  resolved "https://registry.yarnpkg.com/polyserve/-/polyserve-0.27.15.tgz#261fa5a0873c8d95fd7068598f44c9dac20cf9c4"
-  integrity sha512-AaFgANt+tUUVgHLw+BnaVYcn649JiwL1ru0TOZUKj1gGGn/Bq2S16gxql+1muGpRaAsgFu13Zu7k5XkwatwwSg==
-  dependencies:
-    "@types/compression" "^0.0.33"
-    "@types/content-type" "^1.1.0"
-    "@types/escape-html" "0.0.20"
-    "@types/express" "^4.0.36"
-    "@types/mime" "^2.0.0"
-    "@types/mz" "0.0.29"
-    "@types/opn" "^3.0.28"
-    "@types/parse5" "^2.2.34"
-    "@types/pem" "^1.8.1"
-    "@types/resolve" "0.0.6"
-    "@types/serve-static" "^1.7.31"
-    "@types/spdy" "^3.4.1"
-    bower-config "^1.4.1"
-    browser-capabilities "^1.0.0"
-    command-line-args "^5.0.2"
-    command-line-usage "^5.0.5"
-    compression "^1.6.2"
-    content-type "^1.0.2"
-    cors "^2.8.4"
-    escape-html "^1.0.3"
-    express "^4.8.5"
-    find-port "^1.0.1"
-    http-proxy-middleware "^0.17.2"
-    lru-cache "^4.0.2"
-    mime "^2.3.1"
-    mz "^2.4.0"
-    opn "^3.0.2"
-    pem "^1.8.3"
-    polymer-build "^3.1.0"
-    polymer-project-config "^4.0.0"
-    requirejs "^2.3.4"
-    resolve "^1.5.0"
-    send "^0.16.2"
-    spdy "^3.3.3"
+    find-up "^2.1.0"
 
 posix-character-classes@^0.1.0:
   version "0.1.1"
@@ -8022,59 +2810,39 @@
 
 prelude-ls@^1.2.1:
   version "1.2.1"
-  resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"
+  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"
-  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-1.0.4.tgz#d4f4562b0ce3696e41ac52d0e002e57a635dc6dc"
-  integrity sha1-1PRWKwzjaW5BrFLQ4ALlemNdxtw=
-
 prepend-http@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/prepend-http/-/prepend-http-2.0.0.tgz#e92434bfa5ea8c19f41cdfd401d741a3c819d897"
   integrity sha1-6SQ0v6XqjBn0HN/UAddBo8gZ2Jc=
 
-preserve@^0.2.0:
-  version "0.2.0"
-  resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
-  integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
-
 prettier-linter-helpers@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b"
   integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@2.2.1, prettier@^2.1.2:
-  version "2.2.1"
-  resolved "https://registry.npmjs.org/prettier/-/prettier-2.2.1.tgz"
-  integrity sha512-PqyhM2yCjg/oKkFPtTGUojv7gnZAoG80ttl45O6x2Ug/rMJw4wcc9k6aaf2hibP7BGVCCM33gZoGjyvt9mm16Q==
+prettier@2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6"
+  integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA==
 
-pretty-bytes@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-4.0.2.tgz#b2bf82e7350d65c6c33aa95aaa5a4f6327f61cd9"
-  integrity sha1-sr+C5zUNZcbDOqlaqlpPYyf2HNk=
+prettier@^2.1.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.2.tgz#ef280a05ec253712e486233db5c6f23441e7342d"
+  integrity sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==
 
-pretty-bytes@^5.1.0, pretty-bytes@^5.2.0:
-  version "5.6.0"
-  resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb"
-  integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==
-
-process-nextick-args@^2.0.0, process-nextick-args@~2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
-  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
-
-progress@2.0.3, progress@^2.0.0:
+progress@^2.0.0:
   version "2.0.3"
-  resolved "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8"
   integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==
 
 protobufjs@6.8.8:
   version "6.8.8"
-  resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz"
+  resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-6.8.8.tgz#c8b4f1282fd7a90e6f5b109ed11c84af82908e7c"
   integrity sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==
   dependencies:
     "@protobufjs/aspromise" "^1.1.2"
@@ -8091,131 +2859,39 @@
     "@types/node" "^10.1.0"
     long "^4.0.0"
 
-proxy-addr@~2.0.5:
-  version "2.0.6"
-  resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf"
-  integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw==
-  dependencies:
-    forwarded "~0.1.2"
-    ipaddr.js "1.9.1"
-
-pseudomap@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
-  integrity sha1-8FKijacOYYkX7wqKw0wa5aaChrM=
-
-psl@^1.1.24, psl@^1.1.28:
-  version "1.8.0"
-  resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24"
-  integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==
-
-pump@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-1.0.3.tgz#5dfe8311c33bbf6fc18261f9f34702c47c08a954"
-  integrity sha512-8k0JupWme55+9tCVE+FS5ULT3K6AbgqrGa58lTT49RpyfwwcGedHqaC5LlQNdEAumn/wFsu6aPwkuPMioy8kqw==
-  dependencies:
-    end-of-stream "^1.1.0"
-    once "^1.3.1"
-
-pump@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/pump/-/pump-2.0.1.tgz#12399add6e4cf7526d973cbc8b5ce2e2908b3909"
-  integrity sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==
-  dependencies:
-    end-of-stream "^1.1.0"
-    once "^1.3.1"
-
 pump@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
   integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
   dependencies:
     end-of-stream "^1.1.0"
     once "^1.3.1"
 
-pumpify@^1.3.3:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/pumpify/-/pumpify-1.5.1.tgz#36513be246ab27570b1a374a5ce278bfd74370ce"
-  integrity sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==
-  dependencies:
-    duplexify "^3.6.0"
-    inherits "^2.0.3"
-    pump "^2.0.0"
-
-punycode@^1.4.1:
-  version "1.4.1"
-  resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e"
-  integrity sha1-wNWmOycYgArY4esPpSachN1BhF4=
-
-punycode@^2.1.0, punycode@^2.1.1:
+punycode@^2.1.0:
   version "2.1.1"
-  resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
   integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
 
 pupa@^2.1.1:
   version "2.1.1"
-  resolved "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz"
+  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"
 
-q@^1.4.1, q@^1.5.1:
-  version "1.5.1"
-  resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7"
-  integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=
-
-qs@6.7.0:
-  version "6.7.0"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"
-  integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==
-
-qs@~6.5.2:
-  version "6.5.2"
-  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
-  integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==
-
 queue-microtask@^1.2.2:
   version "1.2.3"
-  resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
+  resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
 quick-lru@^4.0.1:
   version "4.0.1"
-  resolved "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f"
   integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==
 
-quick-lru@^5.1.1:
-  version "5.1.1"
-  resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932"
-  integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==
-
-randomatic@^3.0.0:
-  version "3.1.1"
-  resolved "https://registry.yarnpkg.com/randomatic/-/randomatic-3.1.1.tgz#b776efc59375984e36c537b2f51a1f0aff0da1ed"
-  integrity sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==
-  dependencies:
-    is-number "^4.0.0"
-    kind-of "^6.0.0"
-    math-random "^1.0.1"
-
-range-parser@~1.2.0, range-parser@~1.2.1:
-  version "1.2.1"
-  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
-  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
-
-raw-body@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332"
-  integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q==
-  dependencies:
-    bytes "3.1.0"
-    http-errors "1.7.2"
-    iconv-lite "0.4.24"
-    unpipe "1.0.0"
-
-rc@^1.0.1, rc@^1.1.6, rc@^1.2.8:
+rc@^1.2.8:
   version "1.2.8"
-  resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz"
+  resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
   dependencies:
     deep-extend "^0.6.0"
@@ -8223,81 +2899,23 @@
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
-read-all-stream@^3.0.0:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/read-all-stream/-/read-all-stream-3.1.0.tgz#35c3e177f2078ef789ee4bfafa4373074eaef4fa"
-  integrity sha1-NcPhd/IHjveJ7kv6+kNzB06u9Po=
-  dependencies:
-    pinkie-promise "^2.0.0"
-    readable-stream "^2.0.0"
-
-read-chunk@^3.0.0, read-chunk@^3.2.0:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/read-chunk/-/read-chunk-3.2.0.tgz#2984afe78ca9bfbbdb74b19387bf9e86289c16ca"
-  integrity sha512-CEjy9LCzhmD7nUpJ1oVOE6s/hBkejlcJEgLQHVnQznOSilOPb+kpKktlLfFDK3/WP43+F80xkUTM2VOkYoSYvQ==
-  dependencies:
-    pify "^4.0.1"
-    with-open-file "^0.1.6"
-
-read-pkg-up@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02"
-  integrity sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=
-  dependencies:
-    find-up "^1.0.0"
-    read-pkg "^1.0.0"
-
-read-pkg-up@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz"
-  integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=
+read-pkg-up@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-3.0.0.tgz#3ed496685dba0f8fe118d0691dc51f4a1ff96f07"
+  integrity sha1-PtSWaF26D4/hGNBpHcUfSh/5bwc=
   dependencies:
     find-up "^2.0.0"
-    read-pkg "^2.0.0"
-
-read-pkg-up@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-4.0.0.tgz#1b221c6088ba7799601c808f91161c66e58f8978"
-  integrity sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==
-  dependencies:
-    find-up "^3.0.0"
     read-pkg "^3.0.0"
 
-read-pkg-up@^5.0.0:
-  version "5.0.0"
-  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-5.0.0.tgz#b6a6741cb144ed3610554f40162aa07a6db621b8"
-  integrity sha512-XBQjqOBtTzyol2CpsQOw8LHV0XbDZVG7xMMjmXAJomlVY03WOBRmYgDJETlvcg0H63AJvPRwT7GFi5rvOzUOKg==
-  dependencies:
-    find-up "^3.0.0"
-    read-pkg "^5.0.0"
-
 read-pkg-up@^7.0.1:
   version "7.0.1"
-  resolved "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"
   integrity sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==
   dependencies:
     find-up "^4.1.0"
     read-pkg "^5.2.0"
     type-fest "^0.8.1"
 
-read-pkg@^1.0.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-1.1.0.tgz#f5ffaa5ecd29cb31c0474bca7d756b6bb29e3f28"
-  integrity sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=
-  dependencies:
-    load-json-file "^1.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^1.0.0"
-
-read-pkg@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz"
-  integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=
-  dependencies:
-    load-json-file "^2.0.0"
-    normalize-package-data "^2.3.2"
-    path-type "^2.0.0"
-
 read-pkg@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-3.0.0.tgz#9cbc686978fee65d16c00e2b19c237fcf6e38389"
@@ -8307,9 +2925,9 @@
     normalize-package-data "^2.3.2"
     path-type "^3.0.0"
 
-read-pkg@^5.0.0, read-pkg@^5.2.0:
+read-pkg@^5.2.0:
   version "5.2.0"
-  resolved "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-5.2.0.tgz#7bf295438ca5a33e56cd30e053b34ee7250c93cc"
   integrity sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==
   dependencies:
     "@types/normalize-package-data" "^2.4.0"
@@ -8317,17 +2935,7 @@
     parse-json "^5.0.0"
     type-fest "^0.6.0"
 
-readable-stream@1.1.x:
-  version "1.1.14"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.1.14.tgz#7cf4c54ef648e3813084c636dd2079e166c081d9"
-  integrity sha1-fPTFTvZI44EwhMY23SB54WbAgdk=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-"readable-stream@2 || 3", readable-stream@^3.1.1, readable-stream@^3.4.0:
+readable-stream@^3.1.1:
   version "3.6.0"
   resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198"
   integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
@@ -8336,101 +2944,18 @@
     string_decoder "^1.1.1"
     util-deprecate "^1.0.1"
 
-"readable-stream@>=1.0.33-1 <1.1.0-0":
-  version "1.0.34"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-1.0.34.tgz#125820e34bc842d2f2aaafafe4c2916ee32c157c"
-  integrity sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.1"
-    isarray "0.0.1"
-    string_decoder "~0.10.x"
-
-readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@^2.3.7, readable-stream@~2.3.6:
-  version "2.3.7"
-  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
-  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
-  dependencies:
-    core-util-is "~1.0.0"
-    inherits "~2.0.3"
-    isarray "~1.0.0"
-    process-nextick-args "~2.0.0"
-    safe-buffer "~5.1.1"
-    string_decoder "~1.1.1"
-    util-deprecate "~1.0.1"
-
-readdirp@^2.0.0:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525"
-  integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    micromatch "^3.1.10"
-    readable-stream "^2.0.2"
-
-rechoir@^0.6.2:
-  version "0.6.2"
-  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384"
-  integrity sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=
-  dependencies:
-    resolve "^1.1.6"
-
-redent@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/redent/-/redent-1.0.0.tgz#cf916ab1fd5f1f16dfb20822dd6ec7f730c2afde"
-  integrity sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=
-  dependencies:
-    indent-string "^2.1.0"
-    strip-indent "^1.0.1"
-
 redent@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
   integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==
   dependencies:
     indent-string "^4.0.0"
     strip-indent "^3.0.0"
 
-reduce-flatten@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-1.0.1.tgz#258c78efd153ddf93cb561237f61184f3696e327"
-  integrity sha1-JYx479FT3fk8tWEjf2EYTzaW4yc=
-
-regenerate-unicode-properties@^8.2.0:
-  version "8.2.0"
-  resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-8.2.0.tgz#e5de7111d655e7ba60c057dbe9ff37c87e65cdec"
-  integrity sha512-F9DjY1vKLo/tPePDycuH3dn9H1OTPIkVD9Kz4LODu+F2C75mgjAJ7x/gwy6ZcSNRAAkhNlJSOHRe8k3p+K9WhA==
-  dependencies:
-    regenerate "^1.4.0"
-
-regenerate@^1.4.0:
-  version "1.4.2"
-  resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a"
-  integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==
-
-regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
-  version "0.11.1"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
-  integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
-
 regenerator-runtime@^0.13.4:
-  version "0.13.7"
-  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz#cac2dacc8a1ea675feaabaeb8ae833898ae46f55"
-  integrity sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew==
-
-regenerator-transform@^0.14.2:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.14.5.tgz#c98da154683671c9c4dcb16ece736517e1b7feb4"
-  integrity sha512-eOf6vka5IO151Jfsw2NO9WpGX58W6wWmefK3I1zEGr0lOD0u8rwPaNqQL1aRxUaxLeKO3ArNh3VYg1KbaD+FFw==
-  dependencies:
-    "@babel/runtime" "^7.8.4"
-
-regex-cache@^0.4.2:
-  version "0.4.4"
-  resolved "https://registry.yarnpkg.com/regex-cache/-/regex-cache-0.4.4.tgz#75bdc58a2a1496cec48a12835bc54c8d562336dd"
-  integrity sha512-nVIZwtCjkC9YgvWkpM55B5rBhBYRZhAaJbgcFYXXsHnbZ9UZI9nnVWYZpBlCqv9ho2eZryPnWrZGsOdPwVWXWQ==
-  dependencies:
-    is-equal-shallow "^0.1.3"
+  version "0.13.9"
+  resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52"
+  integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA==
 
 regex-not@^1.0.0, regex-not@^1.0.2:
   version "1.0.2"
@@ -8441,196 +2966,62 @@
     safe-regex "^1.1.0"
 
 regexpp@^3.0.0, regexpp@^3.1.0:
-  version "3.1.0"
-  resolved "https://registry.npmjs.org/regexpp/-/regexpp-3.1.0.tgz"
-  integrity sha512-ZOIzd8yVsQQA7j8GCSlPGXwg5PfmA1mrq0JP4nGhh54LaKN3xdai/vHUDu74pKwV8OxseMS65u2NImosQcSD0Q==
-
-regexpu-core@^4.7.1:
-  version "4.7.1"
-  resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-4.7.1.tgz#2dea5a9a07233298fbf0db91fa9abc4c6e0f8ad6"
-  integrity sha512-ywH2VUraA44DZQuRKzARmw6S66mr48pQVva4LBeRhcOltJ6hExvWly5ZjFLYo67xbIxb6W1q4bAGtgfEl20zfQ==
-  dependencies:
-    regenerate "^1.4.0"
-    regenerate-unicode-properties "^8.2.0"
-    regjsgen "^0.5.1"
-    regjsparser "^0.6.4"
-    unicode-match-property-ecmascript "^1.0.4"
-    unicode-match-property-value-ecmascript "^1.2.0"
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
+  integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
 
 regextras@^0.7.1:
   version "0.7.1"
-  resolved "https://registry.npmjs.org/regextras/-/regextras-0.7.1.tgz"
+  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"
-  resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-3.4.0.tgz#d7446815433f5d5ed6431cd5dca21048f66b397e"
-  integrity sha512-4LM6Fw8eBQdwMYcES4yTnn2TqIasbXuwDx3um+QRs7S55aMKCBKBxvPXl2RiUjHwuJLTyYfxSpmfSAjQpcuP+A==
-  dependencies:
-    rc "^1.1.6"
-    safe-buffer "^5.0.1"
-
 registry-auth-token@^4.0.0:
   version "4.2.1"
-  resolved "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.1.tgz"
+  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"
 
-registry-url@^3.0.3:
-  version "3.1.0"
-  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-3.1.0.tgz#3d4ef870f73dde1d77f0cf9a381432444e174942"
-  integrity sha1-PU74cPc93h138M+aOBQyRE4XSUI=
-  dependencies:
-    rc "^1.0.1"
-
 registry-url@^5.0.0:
   version "5.1.0"
-  resolved "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009"
   integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==
   dependencies:
     rc "^1.2.8"
 
-regjsgen@^0.5.1:
-  version "0.5.2"
-  resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.5.2.tgz#92ff295fb1deecbf6ecdab2543d207e91aa33733"
-  integrity sha512-OFFT3MfrH90xIW8OOSyUrk6QHD5E9JOTeGodiJeBS3J6IwlgzJMNE/1bZklWz5oTg+9dCMyEetclvCVXOPoN3A==
-
-regjsparser@^0.6.4:
-  version "0.6.9"
-  resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.6.9.tgz#b489eef7c9a2ce43727627011429cf833a7183e6"
-  integrity sha512-ZqbNRz1SNjLAiYuwY0zoXW8Ne675IX5q+YHioAGbCw4X96Mjl2+dcX9B2ciaeyYjViDAfvIjFpQjJgLttTEERQ==
-  dependencies:
-    jsesc "~0.5.0"
-
-relateurl@0.2.x:
-  version "0.2.7"
-  resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9"
-  integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk=
-
-remove-trailing-separator@^1.0.1:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef"
-  integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8=
-
 repeat-element@^1.1.2:
   version "1.1.4"
   resolved "https://registry.yarnpkg.com/repeat-element/-/repeat-element-1.1.4.tgz#be681520847ab58c7568ac75fbfad28ed42d39e9"
   integrity sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==
 
-repeat-string@^1.5.2, repeat-string@^1.6.1:
+repeat-string@^1.6.1:
   version "1.6.1"
   resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637"
   integrity sha1-jcrkcOHIirwtYA//Sndihtp15jc=
 
-repeating@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/repeating/-/repeating-2.0.1.tgz#5214c53a926d3552707527fbab415dbc08d06dda"
-  integrity sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=
-  dependencies:
-    is-finite "^1.0.0"
-
-replace-ext@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924"
-  integrity sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=
-
-replace-ext@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.1.tgz#2d6d996d04a15855d967443631dd5f77825b016a"
-  integrity sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==
-
-request@2.88.0:
-  version "2.88.0"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef"
-  integrity sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.0"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.4.3"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
-
-request@^2.72.0, request@^2.85.0:
-  version "2.88.2"
-  resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
-  integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
-  dependencies:
-    aws-sign2 "~0.7.0"
-    aws4 "^1.8.0"
-    caseless "~0.12.0"
-    combined-stream "~1.0.6"
-    extend "~3.0.2"
-    forever-agent "~0.6.1"
-    form-data "~2.3.2"
-    har-validator "~5.1.3"
-    http-signature "~1.2.0"
-    is-typedarray "~1.0.0"
-    isstream "~0.1.2"
-    json-stringify-safe "~5.0.1"
-    mime-types "~2.1.19"
-    oauth-sign "~0.9.0"
-    performance-now "^2.1.0"
-    qs "~6.5.2"
-    safe-buffer "^5.1.2"
-    tough-cookie "~2.5.0"
-    tunnel-agent "^0.6.0"
-    uuid "^3.3.2"
+require-directory@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+  integrity sha1-jGStX9MNqxyXbiNE/+f3kqam30I=
 
 require-from-string@^2.0.2:
   version "2.0.2"
-  resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909"
   integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==
 
-requirejs@^2.3.4:
-  version "2.3.6"
-  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
-  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
+require-main-filename@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b"
+  integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==
 
-requires-port@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
-  integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=
-
-resolve-alpn@^1.0.0:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.1.1.tgz#4a006a7d533c81a5dd04681612090fde227cd6e1"
-  integrity sha512-0KbFjFPR2bnJhNx1t8Ad6RqVc8+QPJC4y561FYyC/Q/6OzB3fhUzB5PEgitYhPK6aifwR5gXBSnDMllaDWixGQ==
-
-resolve-dir@^0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-0.1.1.tgz#b219259a5602fac5c5c496ad894a6e8cc430261e"
-  integrity sha1-shklmlYC+sXFxJatiUpujMQwJh4=
-  dependencies:
-    expand-tilde "^1.2.2"
-    global-modules "^0.2.3"
-
-resolve-dir@^1.0.0, resolve-dir@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
-  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
-  dependencies:
-    expand-tilde "^2.0.0"
-    global-modules "^1.0.0"
+requireindex@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.2.0.tgz#3463cdb22ee151902635aa6c9535d4de9c2ef1ef"
+  integrity sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==
 
 resolve-from@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
   integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
 
 resolve-url@^0.2.1:
@@ -8638,9 +3029,9 @@
   resolved "https://registry.yarnpkg.com/resolve-url/-/resolve-url-0.2.1.tgz#2c637fe77c893afd2a663fe21aa9080068e2052a"
   integrity sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=
 
-resolve@^1.1.6, resolve@^1.10.0, resolve@^1.10.1, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.20.0, resolve@^1.5.0:
+resolve@^1.10.0, resolve@^1.10.1, resolve@^1.20.0:
   version "1.20.0"
-  resolved "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
   integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
   dependencies:
     is-core-module "^2.2.0"
@@ -8648,29 +3039,14 @@
 
 responselike@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/responselike/-/responselike-1.0.2.tgz#918720ef3b631c5642be068f15ade5a46f4ba1e7"
   integrity sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=
   dependencies:
     lowercase-keys "^1.0.0"
 
-responselike@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.0.tgz#26391bcc3174f750f9a79eacc40a12a5c42d7723"
-  integrity sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==
-  dependencies:
-    lowercase-keys "^2.0.0"
-
-restore-cursor@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
-  integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
-  dependencies:
-    exit-hook "^1.0.0"
-    onetime "^1.0.0"
-
 restore-cursor@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e"
   integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==
   dependencies:
     onetime "^5.1.0"
@@ -8683,76 +3059,43 @@
 
 reusify@^1.0.4:
   version "1.0.4"
-  resolved "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz"
+  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.3:
-  version "2.7.1"
-  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
-  integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
-  dependencies:
-    glob "^7.1.3"
-
-rimraf@^3.0.0, rimraf@^3.0.2:
+rimraf@^3.0.2:
   version "3.0.2"
-  resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
   integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
   dependencies:
     glob "^7.1.3"
 
-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==
-  dependencies:
-    glob "^7.1.3"
-
-rollup@^1.3.0:
-  version "1.32.1"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-1.32.1.tgz#4480e52d9d9e2ae4b46ba0d9ddeaf3163940f9c4"
-  integrity sha512-/2HA0Ec70TvQnXdzynFffkjA6XN+1e2pEv/uKS5Ulca40g2L7KuOE3riasHoNVHOsFD5KKZgDsMk1CP3Tw9s+A==
-  dependencies:
-    "@types/estree" "*"
-    "@types/node" "*"
-    acorn "^7.1.0"
-
 rollup@^2.45.2:
-  version "2.45.2"
-  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.45.2.tgz#8fb85917c9f35605720e92328f3ccbfba6f78b48"
-  integrity sha512-kRRU7wXzFHUzBIv0GfoFFIN3m9oteY4uAsKllIpQDId5cfnkWF2J130l+27dzDju0E6MScKiV0ZM5Bw8m4blYQ==
+  version "2.56.3"
+  resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.56.3.tgz#b63edadd9851b0d618a6d0e6af8201955a77aeff"
+  integrity sha512-Au92NuznFklgQCUcV96iXlxUbHuB1vQMaH76DHl5M11TotjOHwqk9CwcrT78+Tnv4FN9uTBxq6p4EJoYkpyekg==
   optionalDependencies:
-    fsevents "~2.3.1"
+    fsevents "~2.3.2"
 
-run-async@^2.0.0, run-async@^2.2.0, run-async@^2.4.0:
+run-async@^2.4.0:
   version "2.4.1"
-  resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz"
+  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.2.0"
-  resolved "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"
   integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==
   dependencies:
     queue-microtask "^1.2.2"
 
-rx@^4.1.0:
-  version "4.1.0"
-  resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782"
-  integrity sha1-pfE/957zt0D+MKqAP7CfmIBdR4I=
-
-rxjs@^6.4.0, rxjs@^6.6.0:
+rxjs@^6.6.0:
   version "6.6.7"
-  resolved "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz"
+  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.7.tgz#90ac018acabf491bf65044235d5863c4dab804c9"
   integrity sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==
   dependencies:
     tslib "^1.9.0"
 
-safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1:
-  version "5.1.2"
-  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
-  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
-
-safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0:
+safe-buffer@~5.2.0:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
   integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
@@ -8764,155 +3107,44 @@
   dependencies:
     ret "~0.1.10"
 
-"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0:
+"safer-buffer@>= 2.1.2 < 3":
   version "2.1.2"
-  resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
   integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
 
-samsam@1.x, samsam@^1.1.3:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/samsam/-/samsam-1.3.0.tgz#8d1d9350e25622da30de3e44ba692b5221ab7c50"
-  integrity sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==
-
-sauce-connect-launcher@^1.0.0:
-  version "1.3.2"
-  resolved "https://registry.yarnpkg.com/sauce-connect-launcher/-/sauce-connect-launcher-1.3.2.tgz#dfc675a258550809a8eaf457eb9162b943ddbaf0"
-  integrity sha512-wf0coUlidJ7rmeClgVVBh6Kw55/yalZCY/Un5RgjSnTXRAeGqagnTsTYpZaqC4dCtrY4myuYpOAZXCdbO7lHfQ==
-  dependencies:
-    adm-zip "~0.4.3"
-    async "^2.1.2"
-    https-proxy-agent "^5.0.0"
-    lodash "^4.16.6"
-    rimraf "^2.5.4"
-
-scoped-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/scoped-regex/-/scoped-regex-1.0.0.tgz#a346bb1acd4207ae70bd7c0c7ca9e566b6baddb8"
-  integrity sha1-o0a7Gs1CB65wvXwMfKnlZra63bg=
-
-select-hose@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca"
-  integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo=
-
-selenium-standalone@^6.7.0:
-  version "6.23.0"
-  resolved "https://registry.yarnpkg.com/selenium-standalone/-/selenium-standalone-6.23.0.tgz#91a7d12b1c8ba077a82b44323445c5882eb20ff1"
-  integrity sha512-6dVLSEvbixd/MRSEmrcRQD8dmABrzNsxRqroKFQY+RVzm1JVPgGHIlo6qJzG6akfjc2V8SadHslE6lN4BFVM3w==
-  dependencies:
-    commander "^2.20.3"
-    cross-spawn "^7.0.3"
-    debug "^4.3.1"
-    got "^11.8.0"
-    lodash.mapvalues "^4.6.0"
-    lodash.merge "^4.6.2"
-    minimist "^1.2.5"
-    mkdirp "^1.0.4"
-    progress "2.0.3"
-    tar-stream "2.1.4"
-    which "^2.0.2"
-    yauzl "^2.10.0"
-
-semver-diff@^2.0.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36"
-  integrity sha1-S7uEN8jTfksM8aaP1ybsbWRdbTY=
-  dependencies:
-    semver "^5.0.3"
-
 semver-diff@^3.1.1:
   version "3.1.1"
-  resolved "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b"
   integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==
   dependencies:
     semver "^6.3.0"
 
-"semver@2 || 3 || 4 || 5", semver@5.6.0, semver@^5.3.0:
-  version "5.6.0"
-  resolved "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz"
-  integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==
-
-semver@^5.0.3, semver@^5.1.0, semver@^5.5.0:
+"semver@2 || 3 || 4 || 5":
   version "5.7.1"
   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==
 
+semver@5.6.0:
+  version "5.6.0"
+  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.2.0, semver@^6.3.0:
   version "6.3.0"
-  resolved "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
   integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 
-semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4:
+semver@^7.2.1, semver@^7.3.4, semver@^7.3.5:
   version "7.3.5"
-  resolved "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
   integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
   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"
-  integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.7.2"
-    mime "1.6.0"
-    ms "2.1.1"
-    on-finished "~2.3.0"
-    range-parser "~1.2.1"
-    statuses "~1.5.0"
-
-send@^0.16.1, send@^0.16.2:
-  version "0.16.2"
-  resolved "https://registry.yarnpkg.com/send/-/send-0.16.2.tgz#6ecca1e0f8c156d141597559848df64730a6bbc1"
-  integrity sha512-E64YFPUssFHEFBvpbbjr44NCLtI1AohxQ8ZSiJjQLskAdKuriYEP6VyGEsRDH8ScozGpkaX1BGvhanqCwkcEZw==
-  dependencies:
-    debug "2.6.9"
-    depd "~1.1.2"
-    destroy "~1.0.4"
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    etag "~1.8.1"
-    fresh "0.5.2"
-    http-errors "~1.6.2"
-    mime "1.4.1"
-    ms "2.0.0"
-    on-finished "~2.3.0"
-    range-parser "~1.2.0"
-    statuses "~1.4.0"
-
-serve-static@1.14.1:
-  version "1.14.1"
-  resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9"
-  integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg==
-  dependencies:
-    encodeurl "~1.0.2"
-    escape-html "~1.0.3"
-    parseurl "~1.3.3"
-    send "0.17.1"
-
-server-destroy@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/server-destroy/-/server-destroy-1.0.1.tgz#f13bf928e42b9c3e79383e61cc3998b5d14e6cdd"
-  integrity sha1-8Tv5KOQrnD55OD5hzDmYtdFObN0=
-
-serviceworker-cache-polyfill@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/serviceworker-cache-polyfill/-/serviceworker-cache-polyfill-4.0.0.tgz#de19ee73bef21ab3c0740a37b33db62464babdeb"
-  integrity sha1-3hnuc77yGrPAdAo3sz22JGS6ves=
-
-set-getter@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376"
-  integrity sha1-12nBgsnVpR9AkUXy+6guXoboA3Y=
-  dependencies:
-    to-object-path "^0.3.0"
+set-blocking@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
+  integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
 
 set-value@^2.0.0, set-value@^2.0.1:
   version "2.0.1"
@@ -8924,121 +3156,46 @@
     is-plain-object "^2.0.3"
     split-string "^3.0.1"
 
-setprototypeof@1.1.0:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656"
-  integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==
-
-setprototypeof@1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683"
-  integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==
-
-shady-css-parser@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/shady-css-parser/-/shady-css-parser-0.1.0.tgz#534dc79c8ca5884c5ed92a4e5a13d6d863bca428"
-  integrity sha512-irfJUUkEuDlNHKZNAp2r7zOyMlmbfVJ+kWSfjlCYYUx/7dJnANLCyTzQZsuxy5NJkvtNwSxY5Gj8MOlqXUQPyA==
-
-shallow-clone@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
-  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
-  dependencies:
-    kind-of "^6.0.2"
-
-shebang-command@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
-  integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=
-  dependencies:
-    shebang-regex "^1.0.0"
-
 shebang-command@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
   integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
   dependencies:
     shebang-regex "^3.0.0"
 
-shebang-regex@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3"
-  integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=
-
 shebang-regex@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
   integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
 
-shelljs@^0.8.0, shelljs@^0.8.4:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.4.tgz#de7684feeb767f8716b326078a8a00875890e3c2"
-  integrity sha512-7gk3UZ9kOfPLIAbslLzyWeGiEqx9e3rxwZM0KE6EL8GlGwjym9Mrlx5/p33bWTu9YG6vcS4MBxYZDHYr5lr8BQ==
+side-channel@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
+  integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==
   dependencies:
-    glob "^7.0.0"
-    interpret "^1.0.0"
-    rechoir "^0.6.2"
+    call-bind "^1.0.0"
+    get-intrinsic "^1.0.2"
+    object-inspect "^1.9.0"
 
-signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
+signal-exit@^3.0.2, signal-exit@^3.0.3:
   version "3.0.3"
-  resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.3.tgz"
+  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"
-  integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
-  dependencies:
-    is-arrayish "^0.3.1"
-
-sinon-chai@^2.10.0:
-  version "2.14.0"
-  resolved "https://registry.yarnpkg.com/sinon-chai/-/sinon-chai-2.14.0.tgz#da7dd4cc83cd6a260b67cca0f7a9fdae26a1205d"
-  integrity sha512-9stIF1utB0ywNHNT7RgiXbdmen8QDCRsrTjw+G9TgKt1Yexjiv8TOWZ6WHsTPz57Yky3DIswZvEqX8fpuHNDtQ==
-
-sinon@^2.3.5:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/sinon/-/sinon-2.4.1.tgz#021fd64b54cb77d9d2fb0d43cdedfae7629c3a36"
-  integrity sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==
-  dependencies:
-    diff "^3.1.0"
-    formatio "1.2.0"
-    lolex "^1.6.0"
-    native-promise-only "^0.8.1"
-    path-to-regexp "^1.7.0"
-    samsam "^1.1.3"
-    text-encoding "0.6.4"
-    type-detect "^4.0.0"
-
-slash@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"
-  integrity sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=
-
-slash@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44"
-  integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==
-
 slash@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz"
+  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.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b"
   integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==
   dependencies:
     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"
-  resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707"
-  integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc=
-
 snapdragon-node@^2.0.1:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b"
@@ -9069,72 +3226,6 @@
     source-map-resolve "^0.5.0"
     use "^3.1.0"
 
-socket.io-adapter@~1.1.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-1.1.2.tgz#ab3f0d6f66b8fc7fca3959ab5991f82221789be9"
-  integrity sha512-WzZRUj1kUjrTIrUKpZLEzFZ1OLj5FwLlAFQs9kuZJzJi5DKdU7FsWc36SNmA8iDOtwBQyT8FkrriRM8vXLYz8g==
-
-socket.io-client@2.4.0:
-  version "2.4.0"
-  resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-2.4.0.tgz#aafb5d594a3c55a34355562fc8aea22ed9119a35"
-  integrity sha512-M6xhnKQHuuZd4Ba9vltCLT9oa+YvTsP8j9NcEiLElfIg8KeYPyhWOes6x4t+LTAC8enQbE/995AdTem2uNyKKQ==
-  dependencies:
-    backo2 "1.0.2"
-    component-bind "1.0.0"
-    component-emitter "~1.3.0"
-    debug "~3.1.0"
-    engine.io-client "~3.5.0"
-    has-binary2 "~1.0.2"
-    indexof "0.0.1"
-    parseqs "0.0.6"
-    parseuri "0.0.6"
-    socket.io-parser "~3.3.0"
-    to-array "0.1.4"
-
-socket.io-parser@~3.3.0:
-  version "3.3.2"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.3.2.tgz#ef872009d0adcf704f2fbe830191a14752ad50b6"
-  integrity sha512-FJvDBuOALxdCI9qwRrO/Rfp9yfndRtc1jSgVgV8FDraihmSP/MLGD5PEuJrNfjALvcQ+vMDM/33AWOYP/JSjDg==
-  dependencies:
-    component-emitter "~1.3.0"
-    debug "~3.1.0"
-    isarray "2.0.1"
-
-socket.io-parser@~3.4.0:
-  version "3.4.1"
-  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-3.4.1.tgz#b06af838302975837eab2dc980037da24054d64a"
-  integrity sha512-11hMgzL+WCLWf1uFtHSNvliI++tcRUWdoeYuwIl+Axvwy9z2gQM+7nJyN3STj1tLj5JyIUH8/gpDGxzAlDdi0A==
-  dependencies:
-    component-emitter "1.2.1"
-    debug "~4.1.0"
-    isarray "2.0.1"
-
-socket.io@^2.0.3:
-  version "2.4.1"
-  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-2.4.1.tgz#95ad861c9a52369d7f1a68acf0d4a1b16da451d2"
-  integrity sha512-Si18v0mMXGAqLqCVpTxBa8MGqriHGQh8ccEOhmsmNS3thNCGBwO8WGrwMibANsWtQQ5NStdZwHqZR3naJVFc3w==
-  dependencies:
-    debug "~4.1.0"
-    engine.io "~3.5.0"
-    has-binary2 "~1.0.2"
-    socket.io-adapter "~1.1.0"
-    socket.io-client "2.4.0"
-    socket.io-parser "~3.4.0"
-
-sort-keys-length@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/sort-keys-length/-/sort-keys-length-1.0.1.tgz#9cb6f4f4e9e48155a6aa0671edd336ff1479a188"
-  integrity sha1-nLb09OnkgVWmqgZx7dM2/xR5oYg=
-  dependencies:
-    sort-keys "^1.0.0"
-
-sort-keys@^1.0.0:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-1.1.2.tgz#441b6d4d346798f1b4e49e8920adfba0e543f9ad"
-  integrity sha1-RBttTTRnmPG05J6JIK37oOVD+a0=
-  dependencies:
-    is-plain-obj "^1.0.0"
-
 source-map-resolve@^0.5.0:
   version "0.5.3"
   resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.5.3.tgz#190866bece7553e1f8f267a2ee82c606b5509a1a"
@@ -9148,7 +3239,7 @@
 
 source-map-support@0.5.9:
   version "0.5.9"
-  resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.9.tgz"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.9.tgz#41bc953b2534267ea2d605bccfa7bfa3111ced5f"
   integrity sha512-gR6Rw4MvUlYy83vP0vxoVNzM6t8MUXqNuRsuBmBHQDu1Fh6X015FrLdgoDKcNdkwGubozq0P4N0Q37UyFVr1EA==
   dependencies:
     buffer-from "^1.0.0"
@@ -9167,14 +3258,14 @@
   resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56"
   integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw==
 
-source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7:
+source-map@^0.5.6:
   version "0.5.7"
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc"
   integrity sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=
 
-source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, source-map@~0.6.1:
+source-map@^0.6.0:
   version "0.6.1"
-  resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
   integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
 
 source-map@~0.7.2:
@@ -9182,17 +3273,9 @@
   resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
   integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
 
-spawn-sync@^1.0.15:
-  version "1.0.15"
-  resolved "https://registry.yarnpkg.com/spawn-sync/-/spawn-sync-1.0.15.tgz#b00799557eb7fb0c8376c29d44e8a1ea67e57476"
-  integrity sha1-sAeZVX63+wyDdsKdROih6mfldHY=
-  dependencies:
-    concat-stream "^1.4.7"
-    os-shim "^0.1.2"
-
 spdx-correct@^3.0.0:
   version "3.1.1"
-  resolved "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz"
+  resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.1.tgz#dece81ac9c1e6713e5f7d1b6f17d468fa53d89a9"
   integrity sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==
   dependencies:
     spdx-expression-parse "^3.0.0"
@@ -9200,46 +3283,21 @@
 
 spdx-exceptions@^2.1.0:
   version "2.3.0"
-  resolved "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz#3f28ce1a77a00372683eade4a433183527a2163d"
   integrity sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==
 
 spdx-expression-parse@^3.0.0, spdx-expression-parse@^3.0.1:
   version "3.0.1"
-  resolved "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz"
+  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.7"
-  resolved "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz"
-  integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ==
-
-spdy-transport@^2.0.18:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-2.1.1.tgz#c54815d73858aadd06ce63001e7d25fa6441623b"
-  integrity sha512-q7D8c148escoB3Z7ySCASadkegMmUZW8Wb/Q1u0/XBgDKMO880rLQDj8Twiew/tYi7ghemKUi/whSYOwE17f5Q==
-  dependencies:
-    debug "^2.6.8"
-    detect-node "^2.0.3"
-    hpack.js "^2.1.6"
-    obuf "^1.1.1"
-    readable-stream "^2.2.9"
-    safe-buffer "^5.0.1"
-    wbuf "^1.7.2"
-
-spdy@^3.3.3:
-  version "3.4.7"
-  resolved "https://registry.yarnpkg.com/spdy/-/spdy-3.4.7.tgz#42ff41ece5cc0f99a3a6c28aabb73f5c3b03acbc"
-  integrity sha1-Qv9B7OXMD5mjpsKKq7c/XDsDrLw=
-  dependencies:
-    debug "^2.6.8"
-    handle-thing "^1.2.5"
-    http-deceiver "^1.2.7"
-    safe-buffer "^5.0.1"
-    select-hose "^2.0.0"
-    spdy-transport "^2.0.18"
+  version "3.0.10"
+  resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.10.tgz#0d9becccde7003d6c658d487dd48a32f0bf3014b"
+  integrity sha512-oie3/+gKf7QtpitB0LYLETe+k8SifzsX4KixvpOsbI6S0kRiRQ5MKOio8eMSAKQ17N06+wdEOXRiId+zOxo0hA==
 
 split-string@^3.0.1, split-string@^3.0.2:
   version "3.1.0"
@@ -9250,42 +3308,9 @@
 
 sprintf-js@~1.0.2:
   version "1.0.3"
-  resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
   integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
 
-sshpk@^1.7.0:
-  version "1.16.1"
-  resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
-  integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==
-  dependencies:
-    asn1 "~0.2.3"
-    assert-plus "^1.0.0"
-    bcrypt-pbkdf "^1.0.0"
-    dashdash "^1.12.0"
-    ecc-jsbn "~0.1.1"
-    getpass "^0.1.1"
-    jsbn "~0.1.0"
-    safer-buffer "^2.0.2"
-    tweetnacl "~0.14.0"
-
-stable@^0.1.6:
-  version "0.1.8"
-  resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf"
-  integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==
-
-stack-trace@0.0.x:
-  version "0.0.10"
-  resolved "https://registry.yarnpkg.com/stack-trace/-/stack-trace-0.0.10.tgz#547c70b347e8d32b4e108ea1a2a159e5fdde19c0"
-  integrity sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA=
-
-stacky@^1.3.1:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/stacky/-/stacky-1.3.1.tgz#3f117e5187b9a73d23f876d69f05c85b11804a12"
-  integrity sha1-PxF+UYe5pz0j+HbWnwXIWxGAShI=
-  dependencies:
-    chalk "^1.1.1"
-    lodash "^3.0.0"
-
 static-extend@^0.1.1:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6"
@@ -9294,58 +3319,9 @@
     define-property "^0.2.5"
     object-copy "^0.1.0"
 
-"statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0:
-  version "1.5.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
-  integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=
-
-statuses@~1.4.0:
-  version "1.4.0"
-  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087"
-  integrity sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew==
-
-stream-shift@^1.0.0:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d"
-  integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==
-
-stream@0.0.2:
-  version "0.0.2"
-  resolved "https://registry.yarnpkg.com/stream/-/stream-0.0.2.tgz#7f5363f057f6592c5595f00bc80a27f5cec1f0ef"
-  integrity sha1-f1Nj8Ff2WSxVlfALyAon9c7B8O8=
-  dependencies:
-    emitter-component "^1.1.1"
-
-streamsearch@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a"
-  integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
-
-string-template@~0.2.1:
-  version "0.2.1"
-  resolved "https://registry.yarnpkg.com/string-template/-/string-template-0.2.1.tgz#42932e598a352d01fc22ec3367d9d84eec6c9add"
-  integrity sha1-QpMuWYo1LQH8IuwzZ9nYTuxsmt0=
-
-string-width@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"
-  integrity sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=
-  dependencies:
-    code-point-at "^1.0.0"
-    is-fullwidth-code-point "^1.0.0"
-    strip-ansi "^3.0.0"
-
-string-width@^2.0.0, string-width@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
-  integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
-  dependencies:
-    is-fullwidth-code-point "^2.0.0"
-    strip-ansi "^4.0.0"
-
 string-width@^3.0.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961"
   integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==
   dependencies:
     emoji-regex "^7.0.1"
@@ -9354,7 +3330,7 @@
 
 string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0:
   version "4.2.2"
-  resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.2.tgz"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.2.tgz#dafd4f9559a7585cfba529c6a0a4f73488ebd4c5"
   integrity sha512-XBJbT3N4JhVumXE0eoLU9DCjcaF92KLNqTmFCnG1pf8duUxFGwtP6AD6nkjw9a3IdiRtL3E2w3JDiE/xi3vOeA==
   dependencies:
     emoji-regex "^8.0.0"
@@ -9363,7 +3339,7 @@
 
 string.prototype.trimend@^1.0.4:
   version "1.0.4"
-  resolved "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz#e75ae90c2942c63504686c18b287b4a0b1a45f80"
   integrity sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==
   dependencies:
     call-bind "^1.0.2"
@@ -9371,7 +3347,7 @@
 
 string.prototype.trimstart@^1.0.4:
   version "1.0.4"
-  resolved "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz#b36399af4ab2999b4c9c648bd7a3fb2bb26feeed"
   integrity sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==
   dependencies:
     call-bind "^1.0.2"
@@ -9384,402 +3360,99 @@
   dependencies:
     safe-buffer "~5.2.0"
 
-string_decoder@~0.10.x:
-  version "0.10.31"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94"
-  integrity sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=
-
-string_decoder@~1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
-  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
-  dependencies:
-    safe-buffer "~5.1.0"
-
-strip-ansi@^3.0.0:
-  version "3.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-3.0.1.tgz#6a385fb8853d952d5ff05d0e8aaf94278dc63dcf"
-  integrity sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=
-  dependencies:
-    ansi-regex "^2.0.0"
-
-strip-ansi@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f"
-  integrity sha1-qEeQIusaw2iocTibY1JixQXuNo8=
-  dependencies:
-    ansi-regex "^3.0.0"
-
 strip-ansi@^5.1.0:
   version "5.2.0"
-  resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"
   integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==
   dependencies:
     ansi-regex "^4.1.0"
 
 strip-ansi@^6.0.0:
   version "6.0.0"
-  resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532"
   integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==
   dependencies:
     ansi-regex "^5.0.0"
 
-strip-ansi@~0.1.0:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-0.1.1.tgz#39e8a98d044d150660abe4a6808acf70bb7bc991"
-  integrity sha1-OeipjQRNFQZgq+SmgIrPcLt7yZE=
-
-strip-bom-buf@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-buf/-/strip-bom-buf-1.0.0.tgz#1cb45aaf57530f4caf86c7f75179d2c9a51dd572"
-  integrity sha1-HLRar1dTD0yvhsf3UXnSyaUd1XI=
-  dependencies:
-    is-utf8 "^0.2.1"
-
-strip-bom-stream@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee"
-  integrity sha1-5xRDmFd9Uaa+0PoZlPoF9D/ZiO4=
-  dependencies:
-    first-chunk-stream "^1.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom-stream@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-2.0.0.tgz#f87db5ef2613f6968aa545abfe1ec728b6a829ca"
-  integrity sha1-+H217yYT9paKpUWr/h7HKLaoKco=
-  dependencies:
-    first-chunk-stream "^2.0.0"
-    strip-bom "^2.0.0"
-
-strip-bom@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-2.0.0.tgz#6219a85616520491f35788bdbf1447a99c7e6b0e"
-  integrity sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=
-  dependencies:
-    is-utf8 "^0.2.0"
-
 strip-bom@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3"
   integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=
 
-strip-eof@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf"
-  integrity sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=
-
 strip-final-newline@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
   integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
 
-strip-indent@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-1.0.1.tgz#0c7962a6adefa7bbd4ac366460a638552ae1a0a2"
-  integrity sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=
-  dependencies:
-    get-stdin "^4.0.1"
-
-strip-indent@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-2.0.0.tgz#5ef8db295d01e6ed6cbf7aab96998d7822527b68"
-  integrity sha1-XvjbKV0B5u1sv3qrlpmNeCJSe2g=
-
 strip-indent@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001"
   integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==
   dependencies:
     min-indent "^1.0.0"
 
 strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
   version "3.1.1"
-  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"
+  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"
-  resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
   integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
 
-supports-color@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-2.0.0.tgz#535d045ce6b6363fa40117084629995e9df324c7"
-  integrity sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=
-
 supports-color@^5.3.0:
   version "5.5.0"
-  resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f"
   integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==
   dependencies:
     has-flag "^3.0.0"
 
 supports-color@^7.1.0:
   version "7.2.0"
-  resolved "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
   integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
   dependencies:
     has-flag "^4.0.0"
 
-sw-precache@^5.1.1:
-  version "5.2.1"
-  resolved "https://registry.yarnpkg.com/sw-precache/-/sw-precache-5.2.1.tgz#06134f319eec68f3b9583ce9a7036b1c119f7179"
-  integrity sha512-8FAy+BP/FXE+ILfiVTt+GQJ6UEf4CVHD9OfhzH0JX+3zoy2uFk7Vn9EfXASOtVmmIVbL3jE/W8Z66VgPSZcMhw==
-  dependencies:
-    dom-urls "^1.1.0"
-    es6-promise "^4.0.5"
-    glob "^7.1.1"
-    lodash.defaults "^4.2.0"
-    lodash.template "^4.4.0"
-    meow "^3.7.0"
-    mkdirp "^0.5.1"
-    pretty-bytes "^4.0.2"
-    sw-toolbox "^3.4.0"
-    update-notifier "^2.3.0"
-
-sw-toolbox@^3.4.0:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/sw-toolbox/-/sw-toolbox-3.6.0.tgz#26df1d1c70348658e4dea2884319149b7b3183b5"
-  integrity sha1-Jt8dHHA0hljk3qKIQxkUm3sxg7U=
-  dependencies:
-    path-to-regexp "^1.0.1"
-    serviceworker-cache-polyfill "^4.0.0"
-
-table-layout@^0.4.3:
-  version "0.4.5"
-  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-0.4.5.tgz#d906de6a25fa09c0c90d1d08ecd833ecedcb7378"
-  integrity sha512-zTvf0mcggrGeTe/2jJ6ECkJHAQPIYEwDoqsiqBjI24mvRmQbInK5jq33fyypaCBxX08hMkfmdOqj6haT33EqWw==
-  dependencies:
-    array-back "^2.0.0"
-    deep-extend "~0.6.0"
-    lodash.padend "^4.6.1"
-    typical "^2.6.1"
-    wordwrapjs "^3.0.0"
-
-table@^6.0.4:
-  version "6.0.9"
-  resolved "https://registry.npmjs.org/table/-/table-6.0.9.tgz"
-  integrity sha512-F3cLs9a3hL1Z7N4+EkSscsel3z55XT950AvB05bwayrNg5T1/gykXtigioTAjbltvbMSJvvhFCbnf6mX+ntnJQ==
+table@^6.0.9:
+  version "6.7.1"
+  resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"
+  integrity sha512-ZGum47Yi6KOOFDE8m223td53ath2enHcYLgOCjGr5ngu8bdIARQk6mN/wRMv4yMRcHnCSnHbCEha4sobQx5yWg==
   dependencies:
     ajv "^8.0.1"
-    is-boolean-object "^1.1.0"
-    is-number-object "^1.0.4"
-    is-string "^1.0.5"
     lodash.clonedeep "^4.5.0"
-    lodash.flatten "^4.4.0"
     lodash.truncate "^4.4.2"
     slice-ansi "^4.0.0"
     string-width "^4.2.0"
-
-tar-fs@^1.12.0:
-  version "1.16.3"
-  resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-1.16.3.tgz#966a628841da2c4010406a82167cbd5e0c72d509"
-  integrity sha512-NvCeXpYx7OsmOh8zIOP/ebG55zZmxLE0etfWRbWok+q2Qo8x/vOR/IJT1taADXPe+jsiu9axDb3X4B+iIgNlKw==
-  dependencies:
-    chownr "^1.0.1"
-    mkdirp "^0.5.1"
-    pump "^1.0.0"
-    tar-stream "^1.1.2"
-
-tar-stream@2.1.4:
-  version "2.1.4"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.1.4.tgz#c4fb1a11eb0da29b893a5b25476397ba2d053bfa"
-  integrity sha512-o3pS2zlG4gxr67GmFYBLlq+dM8gyRGUOvsrHclSkvtVtQbjV0s/+ZE8OpICbaj8clrX3tjeHngYGP7rweaBnuw==
-  dependencies:
-    bl "^4.0.3"
-    end-of-stream "^1.4.1"
-    fs-constants "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^3.1.1"
-
-tar-stream@^1.1.2:
-  version "1.6.2"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555"
-  integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==
-  dependencies:
-    bl "^1.0.0"
-    buffer-alloc "^1.2.0"
-    end-of-stream "^1.0.0"
-    fs-constants "^1.0.0"
-    readable-stream "^2.3.0"
-    to-buffer "^1.1.1"
-    xtend "^4.0.0"
-
-tar-stream@^2.1.0:
-  version "2.2.0"
-  resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-2.2.0.tgz#acad84c284136b060dc3faa64474aa9aebd77287"
-  integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==
-  dependencies:
-    bl "^4.0.3"
-    end-of-stream "^1.4.1"
-    fs-constants "^1.0.0"
-    inherits "^2.0.3"
-    readable-stream "^3.1.1"
-
-temp@^0.8.1, temp@^0.8.3:
-  version "0.8.4"
-  resolved "https://registry.yarnpkg.com/temp/-/temp-0.8.4.tgz#8c97a33a4770072e0a05f919396c7665a7dd59f2"
-  integrity sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==
-  dependencies:
-    rimraf "~2.6.2"
-
-term-size@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/term-size/-/term-size-1.2.0.tgz#458b83887f288fc56d6fffbfad262e26638efa69"
-  integrity sha1-RYuDiH8oj8Vtb/+/rSYuJmOO+mk=
-  dependencies:
-    execa "^0.7.0"
-
-ternary-stream@^2.0.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/ternary-stream/-/ternary-stream-2.1.1.tgz#4ad64b98668d796a085af2c493885a435a8a8bfc"
-  integrity sha512-j6ei9hxSoyGlqTmoMjOm+QNvUKDOIY6bNl4Uh1lhBvl6yjPW2iLqxDUYyfDPZknQ4KdRziFl+ec99iT4l7g0cw==
-  dependencies:
-    duplexify "^3.5.0"
-    fork-stream "^0.0.4"
-    merge-stream "^1.0.0"
-    through2 "^2.0.1"
+    strip-ansi "^6.0.0"
 
 terser@^5.6.1:
-  version "5.6.1"
-  resolved "https://registry.yarnpkg.com/terser/-/terser-5.6.1.tgz#a48eeac5300c0a09b36854bf90d9c26fb201973c"
-  integrity sha512-yv9YLFQQ+3ZqgWCUk+pvNJwgUTdlIxUk1WTN+RnaFJe2L7ipG2csPT0ra2XRm7Cs8cxN7QXmK1rFzEwYEQkzXw==
+  version "5.7.2"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.7.2.tgz#d4d95ed4f8bf735cb933e802f2a1829abf545e3f"
+  integrity sha512-0Omye+RD4X7X69O0eql3lC4Heh/5iLj3ggxR/B5ketZLOtLiOqukUgjw3q4PDnNQbsrkKr3UMypqStQG3XKRvw==
   dependencies:
     commander "^2.20.0"
     source-map "~0.7.2"
     source-map-support "~0.5.19"
 
-text-encoding@0.6.4:
-  version "0.6.4"
-  resolved "https://registry.yarnpkg.com/text-encoding/-/text-encoding-0.6.4.tgz#e399a982257a276dae428bb92845cb71bdc26d19"
-  integrity sha1-45mpgiV6J22uQou5KEXLcb3CbRk=
-
-text-hex@1.0.x:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/text-hex/-/text-hex-1.0.0.tgz#69dc9c1b17446ee79a92bf5b884bb4b9127506f5"
-  integrity sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==
-
 text-table@^0.2.0:
   version "0.2.0"
-  resolved "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz"
+  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
   integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
 
-textextensions@^2.5.0:
-  version "2.6.0"
-  resolved "https://registry.yarnpkg.com/textextensions/-/textextensions-2.6.0.tgz#d7e4ab13fe54e32e08873be40d51b74229b00fc4"
-  integrity sha512-49WtAWS+tcsy93dRt6P0P3AMD2m5PvXRhuEA0kaXos5ZLlujtYmpmFsB+QvWUSxE1ZsstmYXfQ7L40+EcQgpAQ==
-
-thenify-all@^1.0.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/thenify-all/-/thenify-all-1.6.0.tgz#1a1918d402d8fc3f98fbf234db0bcc8cc10e9726"
-  integrity sha1-GhkY1ALY/D+Y+/I02wvMjMEOlyY=
-  dependencies:
-    thenify ">= 3.1.0 < 4"
-
-"thenify@>= 3.1.0 < 4":
-  version "3.3.1"
-  resolved "https://registry.yarnpkg.com/thenify/-/thenify-3.3.1.tgz#8932e686a4066038a016dd9e2ca46add9838a95f"
-  integrity sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==
-  dependencies:
-    any-promise "^1.0.0"
-
-through2-filter@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-2.0.0.tgz#60bc55a0dacb76085db1f9dae99ab43f83d622ec"
-  integrity sha1-YLxVoNrLdghdsfna6Zq0P4PWIuw=
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2-filter@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"
-  integrity sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==
-  dependencies:
-    through2 "~2.0.0"
-    xtend "~4.0.0"
-
-through2@^0.6.0:
-  version "0.6.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-0.6.5.tgz#41ab9c67b29d57209071410e1d7a7a968cd3ad48"
-  integrity sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=
-  dependencies:
-    readable-stream ">=1.0.33-1 <1.1.0-0"
-    xtend ">=4.0.0 <4.1.0-0"
-
-through2@^2.0.0, through2@^2.0.1, through2@^2.0.3, through2@~2.0.0:
-  version "2.0.5"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
-  integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==
-  dependencies:
-    readable-stream "~2.3.6"
-    xtend "~4.0.1"
-
-through2@^3.0.0, through2@^3.0.1, through2@^3.0.2:
-  version "3.0.2"
-  resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.2.tgz#99f88931cfc761ec7678b41d5d7336b5b6a07bf4"
-  integrity sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==
-  dependencies:
-    inherits "^2.0.4"
-    readable-stream "2 || 3"
-
-"through@>=2.2.7 <3", through@^2.3.6:
+through@^2.3.6:
   version "2.3.8"
-  resolved "https://registry.npmjs.org/through/-/through-2.3.8.tgz"
+  resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
   integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
 
-timed-out@^3.0.0:
-  version "3.1.3"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-3.1.3.tgz#95860bfcc5c76c277f8f8326fd0f5b2e20eba217"
-  integrity sha1-lYYL/MXHbCd/j4Mm/Q9bLiDrohc=
-
-timed-out@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/timed-out/-/timed-out-4.0.1.tgz#f32eacac5a175bea25d7fab565ab3ed8741ef56f"
-  integrity sha1-8y6srFoXW+ol1/q1Zas+2HQe9W8=
-
-tmp@^0.0.29:
-  version "0.0.29"
-  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.29.tgz#f25125ff0dd9da3ccb0c2dd371ee1288bb9128c0"
-  integrity sha1-8lEl/w3Z2jzLDC3Tce4SiLuRKMA=
-  dependencies:
-    os-tmpdir "~1.0.1"
-
 tmp@^0.0.33:
   version "0.0.33"
-  resolved "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
   integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==
   dependencies:
     os-tmpdir "~1.0.2"
 
-to-absolute-glob@^0.1.1:
-  version "0.1.1"
-  resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f"
-  integrity sha1-HN+kcqnvUMI57maZm2YsoOs5k38=
-  dependencies:
-    extend-shallow "^2.0.1"
-
-to-array@0.1.4:
-  version "0.1.4"
-  resolved "https://registry.yarnpkg.com/to-array/-/to-array-0.1.4.tgz#17e6c11f73dd4f3d74cda7a4ff3238e9ad9bf890"
-  integrity sha1-F+bBH3PdTz10zaek/zI46a2b+JA=
-
-to-buffer@^1.1.1:
-  version "1.1.1"
-  resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80"
-  integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg==
-
-to-fast-properties@^1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47"
-  integrity sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=
-
-to-fast-properties@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e"
-  integrity sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=
-
 to-object-path@^0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/to-object-path/-/to-object-path-0.3.0.tgz#297588b7b0e7e0ac08e04e672f85c1f4999e17af"
@@ -9789,7 +3462,7 @@
 
 to-readable-stream@^1.0.0:
   version "1.0.0"
-  resolved "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/to-readable-stream/-/to-readable-stream-1.0.0.tgz#ce0aa0c2f3df6adf852efb404a783e77c0475771"
   integrity sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==
 
 to-regex-range@^2.1.0:
@@ -9802,7 +3475,7 @@
 
 to-regex-range@^5.0.1:
   version "5.0.1"
-  resolved "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz"
+  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"
@@ -9817,58 +3490,27 @@
     regex-not "^1.0.2"
     safe-regex "^1.1.0"
 
-toidentifier@1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553"
-  integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw==
-
-tough-cookie@~2.4.3:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781"
-  integrity sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==
-  dependencies:
-    psl "^1.1.24"
-    punycode "^1.4.1"
-
-tough-cookie@~2.5.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2"
-  integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==
-  dependencies:
-    psl "^1.1.28"
-    punycode "^2.1.1"
-
-tr46@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09"
-  integrity sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=
-  dependencies:
-    punycode "^2.1.0"
-
-trim-newlines@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-1.0.0.tgz#5887966bb582a4503a41eb524f7d35011815a613"
-  integrity sha1-WIeWa7WCpFA6QetST301ARgVphM=
-
 trim-newlines@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.0.tgz"
-  integrity sha512-C4+gOpvmxaSMKuEf9Qc134F1ZuOHVXKRbtEflf4NTtuuJDEIJ9p5PXsalL8SkeRw+qit1Mo+yuvMPAKwWg/1hA==
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144"
+  integrity sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==
 
-trim-right@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003"
-  integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=
+ts-lit-plugin@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/ts-lit-plugin/-/ts-lit-plugin-1.2.1.tgz#7fca17a454645c14911917fa7f17ade582fa3056"
+  integrity sha512-k/Me+aT1N9ckC/KuJCAlAJgCHFezOxuOGOzBE0q42xnKbJnUMNl08WqWF6C7OKecCPHIMRk5Wj5o6MDsmt9+qA==
+  dependencies:
+    lit-analyzer "1.2.1"
 
-triple-beam@^1.2.0, triple-beam@^1.3.0:
-  version "1.3.0"
-  resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.3.0.tgz#a595214c7298db8339eeeee083e4d10bd8cb8dd9"
-  integrity sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==
+ts-simple-type@~1.0.5:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/ts-simple-type/-/ts-simple-type-1.0.7.tgz#03930af557528dd40eaa121913c7035a0baaacf8"
+  integrity sha512-zKmsCQs4dZaeSKjEA7pLFDv7FHHqAFLPd0Mr//OIJvu8M+4p4bgSFJwZSEBEg3ec9W7RzRz1vi8giiX0+mheBQ==
 
-tsconfig-paths@^3.9.0:
-  version "3.9.0"
-  resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz"
-  integrity sha512-dRcuzokWhajtZWkQsDVKbWyY+jgcLC5sqJhg2PSgf4ZkH2aHPvaOY8YWGhmjb68b5qqTfasSsDO9k7RUiEmZAw==
+tsconfig-paths@^3.11.0:
+  version "3.11.0"
+  resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.11.0.tgz#954c1fe973da6339c78e06b03ce2e48810b65f36"
+  integrity sha512-7ecdYDnIdmv639mmDwslG6KQg1Z9STTz1j7Gcz0xa+nshh/gKDAHcPxRbWOsA3SPp0tXP2leTcY9Kw+NAkfZzA==
   dependencies:
     "@types/json5" "^0.0.29"
     json5 "^1.0.1"
@@ -9877,116 +3519,78 @@
 
 tslib@^1.8.1, tslib@^1.9.0:
   version "1.14.1"
-  resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
   integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
 
-tsutils@3.21.0, tsutils@^3.17.1:
+tsutils@3.21.0, tsutils@^3.21.0:
   version "3.21.0"
   resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623"
   integrity sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==
   dependencies:
     tslib "^1.8.1"
 
-tunnel-agent@^0.6.0:
-  version "0.6.0"
-  resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd"
-  integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=
+twinkie@^1.1.3:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/twinkie/-/twinkie-1.1.3.tgz#1a6f0cd11c59e245bc2d16c7c9fc1ec13e477229"
+  integrity sha512-8Y1U/UCtf8JC4snuV4KAo4e9nwJcKZUoMVOApihJzua4JJWeGB/2RYqAusKk3cUczJeZRGzirHpP1hkArcbA8A==
   dependencies:
-    safe-buffer "^5.0.1"
-
-tweetnacl@^0.14.3, tweetnacl@~0.14.0:
-  version "0.14.5"
-  resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
-  integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=
+    "@types/minimatch" "3.0.3"
+    cheerio "1.0.0-rc.2"
+    minimatch "3.0.3"
+    typescript "4.0.5"
 
 type-check@^0.4.0, type-check@~0.4.0:
   version "0.4.0"
-  resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
   integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
   dependencies:
     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.18.0:
   version "0.18.1"
-  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz"
+  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.20.2:
   version "0.20.2"
-  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
   integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
 
 type-fest@^0.21.3:
   version "0.21.3"
-  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37"
   integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==
 
 type-fest@^0.6.0:
   version "0.6.0"
-  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b"
   integrity sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==
 
 type-fest@^0.8.1:
   version "0.8.1"
-  resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d"
   integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==
 
-type-is@^1.6.4, type-is@~1.6.17, type-is@~1.6.18:
-  version "1.6.18"
-  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
-  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
-  dependencies:
-    media-typer "0.3.0"
-    mime-types "~2.1.24"
-
 typedarray-to-buffer@^3.1.5:
   version "3.1.5"
-  resolved "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz"
+  resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080"
   integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==
   dependencies:
     is-typedarray "^1.0.0"
 
-typedarray@^0.0.6:
-  version "0.0.6"
-  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
-  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+typescript@4.0.5, typescript@4.3.2:
+  version "4.3.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.2.tgz#399ab18aac45802d6f2498de5054fcbbe716a805"
+  integrity sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==
 
-typescript@4.1.4:
-  version "4.1.4"
-  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.4.tgz#f058636e2f4f83f94ddaae07b20fd5e14598432f"
-  integrity sha512-+Uru0t8qIRgjuCpiSPpfGuhHecMllk5Zsazj5LZvVsEStEjmIRRBZe+jHjGQvsgS7M1wONy2PQXd67EMyV6acg==
+typescript@^3.8.3:
+  version "3.9.10"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
+  integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
 
-typical@^2.6.1:
-  version "2.6.1"
-  resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d"
-  integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0=
-
-typical@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/typical/-/typical-4.0.0.tgz#cbeaff3b9d7ae1e2bbfaf5a4e6f11eccfde94fc4"
-  integrity sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw==
-
-ua-parser-js@^0.7.15:
-  version "0.7.28"
-  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
-  integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
-
-uglify-js@3.4.x:
-  version "3.4.10"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.10.tgz#9ad9563d8eb3acdfb8d38597d2af1d815f6a755f"
-  integrity sha512-Y2VsbPVs0FIshJztycsO2SfPk7/KAF/T72qzv9u5EpQ4kB2hQoHlhNQTsNyy6ul7lQtqJN/AoWeS23OzEiEFxw==
-  dependencies:
-    commander "~2.19.0"
-    source-map "~0.6.1"
-
-unbox-primitive@^1.0.0:
+unbox-primitive@^1.0.1:
   version "1.0.1"
-  resolved "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz"
+  resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.1.tgz#085e215625ec3162574dc8859abee78a59b14471"
   integrity sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==
   dependencies:
     function-bind "^1.1.1"
@@ -9994,39 +3598,6 @@
     has-symbols "^1.0.2"
     which-boxed-primitive "^1.0.2"
 
-underscore@^1.8.3:
-  version "1.13.0"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.0.tgz#3ccdcbb824230fc6bf234ad0ddcd83dff4eafe5f"
-  integrity sha512-sCs4H3pCytsb5K7i072FAEC9YlSYFIbosvM0tAKAlpSSUgD7yC1iXSEGdl5XrDKQ1YUB+p/HDzYrSG2H2Vl36g==
-
-underscore@~1.6.0:
-  version "1.6.0"
-  resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.6.0.tgz#8b38b10cacdef63337b8b24e4ff86d45aea529a8"
-  integrity sha1-izixDKze9jM3uLJOT/htRa6lKag=
-
-unicode-canonical-property-names-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
-  integrity sha512-jDrNnXWHd4oHiTZnx/ZG7gtUTVp+gCcTTKr8L0HjlwphROEW3+Him+IpvC+xcJEFegapiMZyZe02CyuOnRmbnQ==
-
-unicode-match-property-ecmascript@^1.0.4:
-  version "1.0.4"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-1.0.4.tgz#8ed2a32569961bce9227d09cd3ffbb8fed5f020c"
-  integrity sha512-L4Qoh15vTfntsn4P1zqnHulG0LdXgjSO035fEpdtp6YxXhMT51Q6vgM5lYdG/5X3MjS+k/Y9Xw4SFCY9IkR0rg==
-  dependencies:
-    unicode-canonical-property-names-ecmascript "^1.0.4"
-    unicode-property-aliases-ecmascript "^1.0.4"
-
-unicode-match-property-value-ecmascript@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-1.2.0.tgz#0d91f600eeeb3096aa962b1d6fc88876e64ea531"
-  integrity sha512-wjuQHGQVofmSJv1uVISKLE5zO2rNGzM/KCYZch/QQvez7C1hUhBIuZ701fYXExuufJFMPhv2SyL8CyoIfMLbIQ==
-
-unicode-property-aliases-ecmascript@^1.0.4:
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-1.1.0.tgz#dd57a99f6207bedff4628abefb94c50db941c8f4"
-  integrity sha512-PqSoPh/pWetQ2phoj5RLiaqIk4kCNwoV3CI+LfGmWLKI3rE3kl1h59XpX2BjgDrmbxD9ARtQobPGU1SguCYuQg==
-
 union-value@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847"
@@ -10037,45 +3608,13 @@
     is-extendable "^0.1.1"
     set-value "^2.0.1"
 
-unique-stream@^2.0.2:
-  version "2.3.1"
-  resolved "https://registry.yarnpkg.com/unique-stream/-/unique-stream-2.3.1.tgz#c65d110e9a4adf9a6c5948b28053d9a8d04cbeac"
-  integrity sha512-2nY4TnBE70yoxHkDli7DMazpWiP7xMdCYqU2nBRO0UB+ZpEkGsSija7MvmvnZFUeC+mrgiUfcHSr3LmRFIg4+A==
-  dependencies:
-    json-stable-stringify-without-jsonify "^1.0.1"
-    through2-filter "^3.0.0"
-
-unique-string@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a"
-  integrity sha1-nhBXzKhRq7kzmPizOuGHuZyuwRo=
-  dependencies:
-    crypto-random-string "^1.0.0"
-
 unique-string@^2.0.0:
   version "2.0.0"
-  resolved "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d"
   integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==
   dependencies:
     crypto-random-string "^2.0.0"
 
-universal-user-agent@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-4.0.1.tgz#fd8d6cb773a679a709e967ef8288a31fcc03e557"
-  integrity sha512-LnST3ebHwVL2aNe4mejI9IQh2HfZ1RLo8Io2HugSif8ekzD1TlWpHpColOB/eh8JHMLkGH3Akqf040I+4ylNxg==
-  dependencies:
-    os-name "^3.1.0"
-
-universal-user-agent@^6.0.0:
-  version "6.0.0"
-  resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-6.0.0.tgz#3381f8503b251c0d9cd21bc1de939ec9df5480ee"
-  integrity sha512-isyNax3wXoKaulPDZWHQqbmIx1k2tb9fb3GGDBRxCscfYV2Ch7WxPArBsFEG8s/safwXTT7H4QGhaIkTp9447w==
-
-unpipe@1.0.0, unpipe@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
-  integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw=
-
 unset-value@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unset-value/-/unset-value-1.0.0.tgz#8376873f7d2335179ffb1e6fc3a8ed0dfc8ab559"
@@ -10084,61 +3623,9 @@
     has-value "^0.3.1"
     isobject "^3.0.0"
 
-untildify@^2.0.0, untildify@^2.1.0:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-2.1.0.tgz#17eb2807987f76952e9c0485fc311d06a826a2e0"
-  integrity sha1-F+soB5h/dpUunASF/DEdBqgmouA=
-  dependencies:
-    os-homedir "^1.0.0"
-
-untildify@^3.0.3:
-  version "3.0.3"
-  resolved "https://registry.yarnpkg.com/untildify/-/untildify-3.0.3.tgz#1e7b42b140bcfd922b22e70ca1265bfe3634c7c9"
-  integrity sha512-iSk/J8efr8uPT/Z4eSUywnqyrQU7DSdMfdqK4iWEaUVVmcP5JcnpRqmVMwcwcnmI1ATFNgC5V90u09tBynNFKA==
-
-unzip-response@^1.0.2:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-1.0.2.tgz#b984f0877fc0a89c2c773cc1ef7b5b232b5b06fe"
-  integrity sha1-uYTwh3/AqJwsdzzB73tbIytbBv4=
-
-unzip-response@^2.0.1:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97"
-  integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c=
-
-update-notifier@^1.0.0:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-1.0.3.tgz#8f92c515482bd6831b7c93013e70f87552c7cf5a"
-  integrity sha1-j5LFFUgr1oMbfJMBPnD4dVLHz1o=
-  dependencies:
-    boxen "^0.6.0"
-    chalk "^1.0.0"
-    configstore "^2.0.0"
-    is-npm "^1.0.0"
-    latest-version "^2.0.0"
-    lazy-req "^1.1.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^2.0.0"
-
-update-notifier@^2.2.0, update-notifier@^2.3.0:
-  version "2.5.0"
-  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6"
-  integrity sha512-gwMdhgJHGuj/+wHJJs9e6PcCszpxR1b236igrOkUofGhqJuG+amlIKwApH1IW1WWl7ovZxsX49lMBWLxSdm5Dw==
-  dependencies:
-    boxen "^1.2.1"
-    chalk "^2.0.1"
-    configstore "^3.0.0"
-    import-lazy "^2.1.0"
-    is-ci "^1.0.10"
-    is-installed-globally "^0.1.0"
-    is-npm "^1.0.0"
-    latest-version "^3.0.0"
-    semver-diff "^2.0.0"
-    xdg-basedir "^3.0.0"
-
 update-notifier@^5.0.0:
   version "5.1.0"
-  resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9"
   integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==
   dependencies:
     boxen "^5.0.0"
@@ -10156,284 +3643,101 @@
     semver-diff "^3.1.1"
     xdg-basedir "^4.0.0"
 
-upper-case@^1.1.1:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/upper-case/-/upper-case-1.1.3.tgz#f6b4501c2ec4cdd26ba78be7222961de77621598"
-  integrity sha1-9rRQHC7EzdJrp4vnIilh3ndiFZg=
-
 uri-js@^4.2.2:
   version "4.4.1"
-  resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
   integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
   dependencies:
     punycode "^2.1.0"
 
-urijs@^1.16.1:
-  version "1.19.6"
-  resolved "https://registry.yarnpkg.com/urijs/-/urijs-1.19.6.tgz#51f8cb17ca16faefb20b9a31ac60f84aa2b7c870"
-  integrity sha512-eSXsXZ2jLvGWeLYlQA3Gh36BcjF+0amo92+wHPyN1mdR8Nxf75fuEuYTd9c0a+m/vhCjRK0ESlE9YNLW+E1VEw==
-
 urix@^0.1.0:
   version "0.1.0"
   resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72"
   integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
 
-url-parse-lax@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-1.0.0.tgz#7af8f303645e9bd79a272e7a14ac68bc0609da73"
-  integrity sha1-evjzA2Rem9eaJy56FKxovAYJ2nM=
-  dependencies:
-    prepend-http "^1.0.1"
-
 url-parse-lax@^3.0.0:
   version "3.0.0"
-  resolved "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/url-parse-lax/-/url-parse-lax-3.0.0.tgz#16b5cafc07dbe3676c1b1999177823d6503acb0c"
   integrity sha1-FrXK/Afb42dsGxmZF3gj1lA6yww=
   dependencies:
     prepend-http "^2.0.0"
 
-url-to-options@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
-  integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
-
 use@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
   integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==
 
-util-deprecate@^1.0.1, util-deprecate@~1.0.1:
+util-deprecate@^1.0.1:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
   integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
 
-utils-merge@1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
-  integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
-
-uuid@^2.0.1:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a"
-  integrity sha1-Z+LoY3lyFVMN/zGOW/nc6/1Hsho=
-
-uuid@^3.2.1, uuid@^3.3.2:
-  version "3.4.0"
-  resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
-  integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
-
 v8-compile-cache@^2.0.3:
   version "2.3.0"
-  resolved "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz"
+  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
   integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
 
-vali-date@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/vali-date/-/vali-date-1.0.0.tgz#1b904a59609fb328ef078138420934f6b86709a6"
-  integrity sha1-G5BKWWCfsyjvB4E4Qgk09rhnCaY=
-
-validate-element-name@^2.1.1:
-  version "2.1.1"
-  resolved "https://registry.yarnpkg.com/validate-element-name/-/validate-element-name-2.1.1.tgz#8ff75f7da69f73e7c510588362130508b7ac644e"
-  integrity sha1-j/dffaafc+fFEFiDYhMFCLesZE4=
-  dependencies:
-    is-potential-custom-element-name "^1.0.0"
-    log-symbols "^1.0.0"
-    meow "^3.7.0"
-
 validate-npm-package-license@^3.0.1:
   version "3.0.4"
-  resolved "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz"
+  resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a"
   integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==
   dependencies:
     spdx-correct "^3.0.0"
     spdx-expression-parse "^3.0.0"
 
-vargs@^0.1.0:
-  version "0.1.0"
-  resolved "https://registry.yarnpkg.com/vargs/-/vargs-0.1.0.tgz#6b6184da6520cc3204ce1b407cac26d92609ebff"
-  integrity sha1-a2GE2mUgzDIEzhtAfKwm2SYJ6/8=
-
-vary@^1, vary@~1.1.2:
-  version "1.1.2"
-  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
-  integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=
-
-verror@1.10.0:
-  version "1.10.0"
-  resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400"
-  integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=
+vscode-css-languageservice@4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/vscode-css-languageservice/-/vscode-css-languageservice-4.3.0.tgz#40c797d664ab6188cace33cfbb19b037580a9318"
+  integrity sha512-BkQAMz4oVHjr0oOAz5PdeE72txlLQK7NIwzmclfr+b6fj6I8POwB+VoXvrZLTbWt9hWRgfvgiQRkh5JwrjPJ5A==
   dependencies:
-    assert-plus "^1.0.0"
-    core-util-is "1.0.2"
-    extsprintf "^1.2.0"
+    vscode-languageserver-textdocument "^1.0.1"
+    vscode-languageserver-types "3.16.0-next.2"
+    vscode-nls "^4.1.2"
+    vscode-uri "^2.1.2"
 
-vinyl-file@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/vinyl-file/-/vinyl-file-3.0.0.tgz#b104d9e4409ffa325faadd520642d0a3b488b365"
-  integrity sha1-sQTZ5ECf+jJfqt1SBkLQo7SIs2U=
+vscode-html-languageservice@3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/vscode-html-languageservice/-/vscode-html-languageservice-3.1.0.tgz#265b53bda595e6947b16b0fb8c604e1e58685393"
+  integrity sha512-QAyRHI98bbEIBCqTzZVA0VblGU40na0txggongw5ZgTj9UVsVk5XbLT16O9OTcbqBGSqn0oWmFDNjK/XGIDcqg==
   dependencies:
-    graceful-fs "^4.1.2"
-    pify "^2.3.0"
-    strip-bom-buf "^1.0.0"
-    strip-bom-stream "^2.0.0"
-    vinyl "^2.0.1"
+    vscode-languageserver-textdocument "^1.0.1"
+    vscode-languageserver-types "3.16.0-next.2"
+    vscode-nls "^4.1.2"
+    vscode-uri "^2.1.2"
 
-vinyl-fs@^2.4.3, vinyl-fs@^2.4.4:
-  version "2.4.4"
-  resolved "https://registry.yarnpkg.com/vinyl-fs/-/vinyl-fs-2.4.4.tgz#be6ff3270cb55dfd7d3063640de81f25d7532239"
-  integrity sha1-vm/zJwy1Xf19MGNkDegfJddTIjk=
+vscode-languageserver-textdocument@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.1.tgz#178168e87efad6171b372add1dea34f53e5d330f"
+  integrity sha512-UIcJDjX7IFkck7cSkNNyzIz5FyvpQfY7sdzVy+wkKN/BLaD4DQ0ppXQrKePomCxTS7RrolK1I0pey0bG9eh8dA==
+
+vscode-languageserver-types@3.16.0-next.2:
+  version "3.16.0-next.2"
+  resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.16.0-next.2.tgz#940bd15c992295a65eae8ab6b8568a1e8daa3083"
+  integrity sha512-QjXB7CKIfFzKbiCJC4OWC8xUncLsxo19FzGVp/ADFvvi87PlmBSCAtZI5xwGjF5qE0xkLf0jjKUn3DzmpDP52Q==
+
+vscode-nls@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-4.1.2.tgz#ca8bf8bb82a0987b32801f9fddfdd2fb9fd3c167"
+  integrity sha512-7bOHxPsfyuCqmP+hZXscLhiHwe7CSuFE4hyhbs22xPIhQ4jv99FcR4eBzfYYVLP356HNFpdvz63FFb/xw6T4Iw==
+
+vscode-uri@^2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-2.1.2.tgz#c8d40de93eb57af31f3c715dd650e2ca2c096f1c"
+  integrity sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==
+
+web-component-analyzer@~1.1.1:
+  version "1.1.6"
+  resolved "https://registry.yarnpkg.com/web-component-analyzer/-/web-component-analyzer-1.1.6.tgz#d9bd904d904a711c19ba6046a45b60a7ee3ed2e9"
+  integrity sha512-1PyBkb/jijDEVE+Pnk3DTmVHD8takipdvAwvZv1V8jIidsSIJ5nhN87Gs+4dpEb1vw48yp8dnbZKkvMYJ+C0VQ==
   dependencies:
-    duplexify "^3.2.0"
-    glob-stream "^5.3.2"
-    graceful-fs "^4.0.0"
-    gulp-sourcemaps "1.6.0"
-    is-valid-glob "^0.3.0"
-    lazystream "^1.0.0"
-    lodash.isequal "^4.0.0"
-    merge-stream "^1.0.0"
-    mkdirp "^0.5.0"
-    object-assign "^4.0.0"
-    readable-stream "^2.0.4"
-    strip-bom "^2.0.0"
-    strip-bom-stream "^1.0.0"
-    through2 "^2.0.0"
-    through2-filter "^2.0.0"
-    vali-date "^1.0.0"
-    vinyl "^1.0.0"
-
-vinyl@^1.0.0, vinyl@^1.1.1, vinyl@^1.2.0:
-  version "1.2.0"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-1.2.0.tgz#5c88036cf565e5df05558bfc911f8656df218884"
-  integrity sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=
-  dependencies:
-    clone "^1.0.0"
-    clone-stats "^0.0.1"
-    replace-ext "0.0.1"
-
-vinyl@^2.0.1, vinyl@^2.2.0, vinyl@^2.2.1:
-  version "2.2.1"
-  resolved "https://registry.yarnpkg.com/vinyl/-/vinyl-2.2.1.tgz#23cfb8bbab5ece3803aa2c0a1eb28af7cbba1974"
-  integrity sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==
-  dependencies:
-    clone "^2.1.1"
-    clone-buffer "^1.0.0"
-    clone-stats "^1.0.0"
-    cloneable-readable "^1.0.0"
-    remove-trailing-separator "^1.0.1"
-    replace-ext "^1.0.0"
-
-vlq@^0.2.2:
-  version "0.2.3"
-  resolved "https://registry.yarnpkg.com/vlq/-/vlq-0.2.3.tgz#8f3e4328cf63b1540c0d67e1b2778386f8975b26"
-  integrity sha512-DRibZL6DsNhIgYQ+wNdWDL2SL3bKPlVrRiBqV5yuMm++op8W4kGFtaQfCs4KEJn0wBZcHVHJ3eoywX8983k1ow==
-
-vscode-uri@=1.0.6:
-  version "1.0.6"
-  resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.6.tgz#6b8f141b0bbc44ad7b07e94f82f168ac7608ad4d"
-  integrity sha512-sLI2L0uGov3wKVb9EB+vIQBl9tVP90nqRvxSoJ35vI3NjxE8jfsE5DSOhWgSunHSZmKS4OCi2jrtfxK7uyp2ww==
-
-wbuf@^1.1.0, wbuf@^1.7.2:
-  version "1.7.3"
-  resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
-  integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==
-  dependencies:
-    minimalistic-assert "^1.0.0"
-
-wct-local@^2.1.1:
-  version "2.1.5"
-  resolved "https://registry.yarnpkg.com/wct-local/-/wct-local-2.1.5.tgz#f7986753e3ad9a35d39178a9989350523561fff1"
-  integrity sha512-eqoZhjGy4Xq2tY0uB46Grkw/ztq+/rC0ImbYKl62unFHXtOgal+kkvnxR3SLRFNM8ty9+ItgycPeH0IpTqVL+w==
-  dependencies:
-    "@types/express" "^4.0.30"
-    "@types/freeport" "^1.0.19"
-    "@types/launchpad" "^0.6.0"
-    "@types/which" "^1.3.1"
-    chalk "^2.3.0"
-    cleankill "^2.0.0"
-    freeport "^1.0.4"
-    launchpad "^0.7.0"
-    selenium-standalone "^6.7.0"
-    which "^1.0.8"
-
-wct-sauce@^2.0.2:
-  version "2.1.0"
-  resolved "https://registry.yarnpkg.com/wct-sauce/-/wct-sauce-2.1.0.tgz#67d0be346aabbbc28384e8d143b8d3ca7ba774c0"
-  integrity sha512-c3R4PJcbpS7Gxv2vZ4HDAqpXV6cT9peslAWMU7hHH9PMhKDPbn8RNa6E4DVL0tOmZznB+3cRmtZ6+vJ/aDwu1A==
-  dependencies:
-    chalk "^2.4.1"
-    cleankill "^2.0.0"
-    lodash "^4.17.10"
-    request "^2.85.0"
-    sauce-connect-launcher "^1.0.0"
-    temp "^0.8.1"
-    uuid "^3.2.1"
-
-wd@^1.2.0:
-  version "1.14.0"
-  resolved "https://registry.yarnpkg.com/wd/-/wd-1.14.0.tgz#1fe6450b5baef37caa135e7755292c6998ca8a90"
-  integrity sha512-X7ZfGHHYlQ5zYpRlgP16LUsvYti+Al/6fz3T/ClVyivVCpCZQpESTDdz6zbK910O5OIvujO23Ym2DBBo3XsQlA==
-  dependencies:
-    archiver "^3.0.0"
-    async "^2.0.0"
-    lodash "^4.0.0"
-    mkdirp "^0.5.1"
-    q "^1.5.1"
-    request "2.88.0"
-    vargs "^0.1.0"
-
-web-component-tester@^6.9.0:
-  version "6.9.2"
-  resolved "https://registry.yarnpkg.com/web-component-tester/-/web-component-tester-6.9.2.tgz#40a7b824f2cf3cbc4305552bdfc3357977ded48a"
-  integrity sha512-s2kB/+IE8XWcnxY1fqSpqTiiHEGHWgUWariAbiRlxmAvWSuvaCVNALHYebsZrLCNCLHKcJR8/sGv/bw0MWMvjw==
-  dependencies:
-    "@polymer/sinonjs" "^1.14.1"
-    "@polymer/test-fixture" "^0.0.3"
-    "@webcomponents/webcomponentsjs" "^1.0.7"
-    accessibility-developer-tools "^2.12.0"
-    async "^2.4.1"
-    body-parser "^1.17.2"
-    bower-config "^1.4.0"
-    chalk "^1.1.3"
-    cleankill "^2.0.0"
-    express "^4.15.3"
-    findup-sync "^2.0.0"
-    glob "^7.1.2"
-    lodash "^3.10.1"
-    multer "^1.3.0"
-    nomnom "^1.8.1"
-    polyserve "^0.27.13"
-    resolve "^1.5.0"
-    semver "^5.3.0"
-    send "^0.16.1"
-    server-destroy "^1.0.1"
-    sinon "^2.3.5"
-    sinon-chai "^2.10.0"
-    socket.io "^2.0.3"
-    stacky "^1.3.1"
-    wd "^1.2.0"
-  optionalDependencies:
-    update-notifier "^2.2.0"
-    wct-local "^2.1.1"
-    wct-sauce "^2.0.2"
-
-webidl-conversions@^4.0.2:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"
-  integrity sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==
-
-whatwg-url@^6.4.0:
-  version "6.5.0"
-  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-6.5.0.tgz#f2df02bff176fd65070df74ad5ccbb5a199965a8"
-  integrity sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==
-  dependencies:
-    lodash.sortby "^4.7.0"
-    tr46 "^1.0.1"
-    webidl-conversions "^4.0.2"
+    fast-glob "^3.2.2"
+    ts-simple-type "~1.0.5"
+    typescript "^3.8.3"
+    yargs "^15.3.1"
 
 which-boxed-primitive@^1.0.2:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz#13757bc89b209b049fe5d86430e21cf40a89a8e6"
   integrity sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==
   dependencies:
     is-bigint "^1.0.1"
@@ -10442,101 +3746,42 @@
     is-string "^1.0.5"
     is-symbol "^1.0.3"
 
-which@^1.0.8, which@^1.2.12, which@^1.2.14, which@^1.2.9:
-  version "1.3.1"
-  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
-  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
-  dependencies:
-    isexe "^2.0.0"
+which-module@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
+  integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 
-which@^2.0.1, which@^2.0.2:
+which@^2.0.1:
   version "2.0.2"
-  resolved "https://registry.npmjs.org/which/-/which-2.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
   integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
   dependencies:
     isexe "^2.0.0"
 
-widest-line@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-1.0.0.tgz#0c09c85c2a94683d0d7eaf8ee097d564bf0e105c"
-  integrity sha1-DAnIXCqUaD0Nfq+O4JfVZL8OEFw=
-  dependencies:
-    string-width "^1.0.1"
-
-widest-line@^2.0.0:
-  version "2.0.1"
-  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-2.0.1.tgz#7438764730ec7ef4381ce4df82fb98a53142a3fc"
-  integrity sha512-Ba5m9/Fa4Xt9eb2ELXt77JxVDV8w7qQrH0zS/TWSJdLyAwQjWoOzpzj5lwVftDz6n/EOu3tNACS84v509qwnJA==
-  dependencies:
-    string-width "^2.1.1"
-
 widest-line@^3.1.0:
   version "3.1.0"
-  resolved "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz"
+  resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca"
   integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==
   dependencies:
     string-width "^4.0.0"
 
-windows-release@^3.1.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.3.3.tgz#1c10027c7225743eec6b89df160d64c2e0293999"
-  integrity sha512-OSOGH1QYiW5yVor9TtmXKQvt2vjQqbYS+DqmsZw+r7xDwLXEeT3JGW0ZppFmHx4diyXmxt238KFR3N9jzevBRg==
-  dependencies:
-    execa "^1.0.0"
-
-winston-transport@^4.2.0, winston-transport@^4.4.0:
-  version "4.4.0"
-  resolved "https://registry.yarnpkg.com/winston-transport/-/winston-transport-4.4.0.tgz#17af518daa690d5b2ecccaa7acf7b20ca7925e59"
-  integrity sha512-Lc7/p3GtqtqPBYYtS6KCN3c77/2QCev51DvcJKbkFPQNoj1sinkGwLGFDxkXY9J6p9+EPnYs+D90uwbnaiURTw==
-  dependencies:
-    readable-stream "^2.3.7"
-    triple-beam "^1.2.0"
-
-winston@^3.0.0:
-  version "3.3.3"
-  resolved "https://registry.yarnpkg.com/winston/-/winston-3.3.3.tgz#ae6172042cafb29786afa3d09c8ff833ab7c9170"
-  integrity sha512-oEXTISQnC8VlSAKf1KYSSd7J6IWuRPQqDdo8eoRNaYKLvwSb5+79Z3Yi1lrl6KDpU6/VWaxpakDAtb1oQ4n9aw==
-  dependencies:
-    "@dabh/diagnostics" "^2.0.2"
-    async "^3.1.0"
-    is-stream "^2.0.0"
-    logform "^2.2.0"
-    one-time "^1.0.0"
-    readable-stream "^3.4.0"
-    stack-trace "0.0.x"
-    triple-beam "^1.3.0"
-    winston-transport "^4.4.0"
-
-with-open-file@^0.1.6:
-  version "0.1.7"
-  resolved "https://registry.yarnpkg.com/with-open-file/-/with-open-file-0.1.7.tgz#e2de8d974e8a8ae6e58886be4fe8e7465b58a729"
-  integrity sha512-ecJS2/oHtESJ1t3ZfMI3B7KIDKyfN0O16miWxdn30zdh66Yd3LsRFebXZXq6GU4xfxLf6nVxp9kIqElb5fqczA==
-  dependencies:
-    p-finally "^1.0.0"
-    p-try "^2.1.0"
-    pify "^4.0.1"
-
 word-wrap@^1.2.3:
   version "1.2.3"
-  resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
-wordwrap@^0.0.3:
-  version "0.0.3"
-  resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-0.0.3.tgz#a3d5da6cd5c0bc0008d37234bbaf1bed63059107"
-  integrity sha1-o9XabNXAvAAI03I0u68b7WMFkQc=
-
-wordwrapjs@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-3.0.0.tgz#c94c372894cadc6feb1a66bff64e1d9af92c5d1e"
-  integrity sha512-mO8XtqyPvykVCsrwj5MlOVWvSnCdT+C+QVbm6blradR7JExAhbkZ7hZ9A+9NUtwzSqrlUo9a67ws0EiILrvRpw==
+wrap-ansi@^6.2.0:
+  version "6.2.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
+  integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
   dependencies:
-    reduce-flatten "^1.0.1"
-    typical "^2.6.1"
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
 
 wrap-ansi@^7.0.0:
   version "7.0.0"
-  resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
   dependencies:
     ansi-styles "^4.0.0"
@@ -10545,30 +3790,12 @@
 
 wrappy@1:
   version "1.0.2"
-  resolved "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
   integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
 
-write-file-atomic@^1.1.2:
-  version "1.3.4"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-1.3.4.tgz#f807a4f0b1d9e913ae7a48112e6cc3af1991b45f"
-  integrity sha1-+Aek8LHZ6ROuekgRLmzDrxmRtF8=
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    slide "^1.1.5"
-
-write-file-atomic@^2.0.0:
-  version "2.4.3"
-  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481"
-  integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==
-  dependencies:
-    graceful-fs "^4.1.11"
-    imurmurhash "^0.1.4"
-    signal-exit "^3.0.2"
-
 write-file-atomic@^3.0.0, write-file-atomic@^3.0.3:
   version "3.0.3"
-  resolved "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz"
+  resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8"
   integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==
   dependencies:
     imurmurhash "^0.1.4"
@@ -10576,189 +3803,47 @@
     signal-exit "^3.0.2"
     typedarray-to-buffer "^3.1.5"
 
-ws@~7.4.2:
-  version "7.4.4"
-  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59"
-  integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==
-
-xdg-basedir@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-2.0.0.tgz#edbc903cc385fc04523d966a335504b5504d1bd2"
-  integrity sha1-7byQPMOF/ARSPZZqM1UEtVBNG9I=
-  dependencies:
-    os-homedir "^1.0.0"
-
-xdg-basedir@^3.0.0:
-  version "3.0.0"
-  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-3.0.0.tgz#496b2cc109eca8dbacfe2dc72b603c17c5870ad4"
-  integrity sha1-SWsswQnsqNus/i3HK2A8F8WHCtQ=
-
 xdg-basedir@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13"
   integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==
 
-xmlbuilder@8.2.2:
-  version "8.2.2"
-  resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-8.2.2.tgz#69248673410b4ba42e1a6136551d2922335aa773"
-  integrity sha1-aSSGc0ELS6QuGmE2VR0pIjNap3M=
-
-xmldom@0.1.x:
-  version "0.1.31"
-  resolved "https://registry.yarnpkg.com/xmldom/-/xmldom-0.1.31.tgz#b76c9a1bd9f0a9737e5a72dc37231cf38375e2ff"
-  integrity sha512-yS2uJflVQs6n+CyjHoaBmVSqIDevTAWrzMmjG1Gc7h1qQ7uVozNhEPJAwZXWyGQ/Gafo3fCwrcaokezLPupVyQ==
-
-xmlhttprequest-ssl@~1.5.4:
-  version "1.5.5"
-  resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.5.5.tgz#c2876b06168aadc40e57d97e81191ac8f4398b3e"
-  integrity sha1-wodrBhaKrcQOV9l+gRkayPQ5iz4=
-
-"xtend@>=4.0.0 <4.1.0-0", xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1:
-  version "4.0.2"
-  resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"
-  integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
-
-yallist@^2.1.2:
-  version "2.1.2"
-  resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
-  integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
+y18n@^4.0.0:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
+  integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==
 
 yallist@^4.0.0:
   version "4.0.0"
-  resolved "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
   integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
 
+yargs-parser@^18.1.2:
+  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"
+
 yargs-parser@^20.2.3:
-  version "20.2.7"
-  resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.7.tgz"
-  integrity sha512-FiNkvbeHzB/syOjIUxFDCnhSfzAL8R5vs40MgLFBorXACCOAEaWu0gRZl14vG8MR9AOJIZbmkjhusqBYZ3HTHw==
+  version "20.2.9"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
+  integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
 
-yauzl@^2.10.0:
-  version "2.10.0"
-  resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
-  integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
+yargs@^15.3.1:
+  version "15.4.1"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8"
+  integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==
   dependencies:
-    buffer-crc32 "~0.2.3"
-    fd-slicer "~1.1.0"
-
-yeast@0.1.2:
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
-  integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
-
-yeoman-environment@^1.5.2:
-  version "1.6.6"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-1.6.6.tgz#cd85fa67d156060e440d7807d7ef7cf0d2d1d671"
-  integrity sha1-zYX6Z9FWBg5EDXgH1+988NLR1nE=
-  dependencies:
-    chalk "^1.0.0"
-    debug "^2.0.0"
-    diff "^2.1.2"
-    escape-string-regexp "^1.0.2"
-    globby "^4.0.0"
-    grouped-queue "^0.3.0"
-    inquirer "^1.0.2"
-    lodash "^4.11.1"
-    log-symbols "^1.0.1"
-    mem-fs "^1.1.0"
-    text-table "^0.2.0"
-    untildify "^2.0.0"
-
-yeoman-environment@^2.0.5, yeoman-environment@^2.9.5:
-  version "2.10.3"
-  resolved "https://registry.yarnpkg.com/yeoman-environment/-/yeoman-environment-2.10.3.tgz#9d8f42b77317414434cc0e51fb006a4bdd54688e"
-  integrity sha512-pLIhhU9z/G+kjOXmJ2bPFm3nejfbH+f1fjYRSOteEXDBrv1EoJE/e+kuHixSXfCYfTkxjYsvRaDX+1QykLCnpQ==
-  dependencies:
-    chalk "^2.4.1"
-    debug "^3.1.0"
-    diff "^3.5.0"
-    escape-string-regexp "^1.0.2"
-    execa "^4.0.0"
-    globby "^8.0.1"
-    grouped-queue "^1.1.0"
-    inquirer "^7.1.0"
-    is-scoped "^1.0.0"
-    lodash "^4.17.10"
-    log-symbols "^2.2.0"
-    mem-fs "^1.1.0"
-    mem-fs-editor "^6.0.0"
-    npm-api "^1.0.0"
-    semver "^7.1.3"
-    strip-ansi "^4.0.0"
-    text-table "^0.2.0"
-    untildify "^3.0.3"
-    yeoman-generator "^4.8.2"
-
-yeoman-generator@^3.1.1:
-  version "3.2.0"
-  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-3.2.0.tgz#02077d2d7ff28fedc1ed7dad7f9967fd7c3604cc"
-  integrity sha512-iR/qb2je3GdXtSfxgvOXxUW0Cp8+C6LaZaNlK2BAICzFNzwHtM10t/QBwz5Ea9nk6xVDQNj4Q889TjCXGuIv8w==
-  dependencies:
-    async "^2.6.0"
-    chalk "^2.3.0"
-    cli-table "^0.3.1"
-    cross-spawn "^6.0.5"
-    dargs "^6.0.0"
-    dateformat "^3.0.3"
-    debug "^4.1.0"
-    detect-conflict "^1.0.0"
-    error "^7.0.2"
-    find-up "^3.0.0"
-    github-username "^4.0.0"
-    istextorbinary "^2.2.1"
-    lodash "^4.17.10"
-    make-dir "^1.1.0"
-    mem-fs-editor "^5.0.0"
-    minimist "^1.2.0"
-    pretty-bytes "^5.1.0"
-    read-chunk "^3.0.0"
-    read-pkg-up "^4.0.0"
-    rimraf "^2.6.2"
-    run-async "^2.0.0"
-    shelljs "^0.8.0"
-    text-table "^0.2.0"
-    through2 "^3.0.0"
-    yeoman-environment "^2.0.5"
-
-yeoman-generator@^4.8.2:
-  version "4.13.0"
-  resolved "https://registry.yarnpkg.com/yeoman-generator/-/yeoman-generator-4.13.0.tgz#a6caeed8491fceea1f84f53e31795f25888b4672"
-  integrity sha512-f2/5N5IR3M2Ozm+QocvZQudlQITv2DwI6Mcxfy7R7gTTzaKgvUpgo/pQMJ+WQKm0KN0YMWCFOZpj0xFGxevc1w==
-  dependencies:
-    async "^2.6.2"
-    chalk "^2.4.2"
-    cli-table "^0.3.1"
-    cross-spawn "^6.0.5"
-    dargs "^6.1.0"
-    dateformat "^3.0.3"
-    debug "^4.1.1"
-    diff "^4.0.1"
-    error "^7.0.2"
-    find-up "^3.0.0"
-    github-username "^3.0.0"
-    istextorbinary "^2.5.1"
-    lodash "^4.17.11"
-    make-dir "^3.0.0"
-    mem-fs-editor "^7.0.1"
-    minimist "^1.2.5"
-    pretty-bytes "^5.2.0"
-    read-chunk "^3.2.0"
-    read-pkg-up "^5.0.0"
-    rimraf "^2.6.3"
-    run-async "^2.0.0"
-    semver "^7.2.1"
-    shelljs "^0.8.4"
-    text-table "^0.2.0"
-    through2 "^3.0.1"
-  optionalDependencies:
-    grouped-queue "^1.1.0"
-    yeoman-environment "^2.9.5"
-
-zip-stream@^2.1.2:
-  version "2.1.3"
-  resolved "https://registry.yarnpkg.com/zip-stream/-/zip-stream-2.1.3.tgz#26cc4bdb93641a8590dd07112e1f77af1758865b"
-  integrity sha512-EkXc2JGcKhO5N5aZ7TmuNo45budRaFGHOmz24wtJR7znbNqDPmdZtUauKX6et8KAVseAMBOyWJqEpXcHTBsh7Q==
-  dependencies:
-    archiver-utils "^2.1.0"
-    compress-commons "^2.1.1"
-    readable-stream "^3.4.0"
+    cliui "^6.0.0"
+    decamelize "^1.2.0"
+    find-up "^4.1.0"
+    get-caller-file "^2.0.1"
+    require-directory "^2.1.1"
+    require-main-filename "^2.0.0"
+    set-blocking "^2.0.0"
+    string-width "^4.2.0"
+    which-module "^2.0.0"
+    y18n "^4.0.0"
+    yargs-parser "^18.1.2"